diff --git a/.editorconfig b/.editorconfig index d2f28922d9b..a41ddcad7f9 100644 --- a/.editorconfig +++ b/.editorconfig @@ -35,6 +35,7 @@ ktlint_standard_class-signature = disabled ktlint_standard_when-entry-bracing = disabled ktlint_standard_blank-line-between-when-conditions = disabled ktlint_standard_mixed-condition-operators = disabled +ktlint_standard_no-unused-imports = enabled [*.java] ij_java_align_consecutive_assignments = false diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index dc95d0ff492..f46322c967d 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -47,6 +47,7 @@ jobs: ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }} ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }} ELEMENT_ANDROID_SENTRY_DSN: ${{ secrets.ELEMENT_ANDROID_SENTRY_DSN }} + ELEMENT_SDK_SENTRY_DSN: ${{ secrets.ELEMENT_SDK_SENTRY_DSN }} ELEMENT_CALL_SENTRY_DSN: ${{ secrets.ELEMENT_CALL_SENTRY_DSN }} ELEMENT_CALL_POSTHOG_API_HOST: ${{ secrets.ELEMENT_CALL_POSTHOG_API_HOST }} ELEMENT_CALL_POSTHOG_API_KEY: ${{ secrets.ELEMENT_CALL_POSTHOG_API_KEY }} @@ -54,7 +55,7 @@ jobs: run: ./gradlew :app:assembleGplayTchapWithpinningDebug app:assembleFDroidTchapWithpinningDebug -PallWarningsAsErrors=true $CI_GRADLE_ARG_PROPERTIES - name: Upload debug APKs if: ${{ matrix.variant == 'debug' }} - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@v6 with: name: elementx-debug path: | @@ -62,7 +63,7 @@ jobs: app/build/outputs/apk/fdroidTchapWithpinning/debug/*-universal-debug.apk - name: Upload x86_64 APK for Maestro if: ${{ matrix.variant == 'debug' }} - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@v6 with: name: elementx-apk-maestro path: | @@ -105,7 +106,7 @@ jobs: ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }} ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }} ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }} - ELEMENT_ANDROID_SENTRY_DSN: ${{ secrets.ELEMENT_ANDROID_SENTRY_DSN }} + ELEMENT_SDK_SENTRY_DSN: ${{ secrets.ELEMENT_SDK_SENTRY_DSN }} ELEMENT_CALL_SENTRY_DSN: ${{ secrets.ELEMENT_CALL_SENTRY_DSN }} ELEMENT_CALL_POSTHOG_API_HOST: ${{ secrets.ELEMENT_CALL_POSTHOG_API_HOST }} ELEMENT_CALL_POSTHOG_API_KEY: ${{ secrets.ELEMENT_CALL_POSTHOG_API_KEY }} @@ -113,7 +114,7 @@ jobs: run: ./gradlew :app:assembleGplayTchapWithpinningNightly -PallWarningsAsErrors=true $CI_GRADLE_ARG_PROPERTIES - name: Upload Tchap X nightly APKs if: ${{ matrix.variant == 'nightly' }} - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: Tchap.X.beta-nightly path: | @@ -124,7 +125,7 @@ jobs: ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }} ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }} ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }} - ELEMENT_ANDROID_SENTRY_DSN: ${{ secrets.ELEMENT_ANDROID_SENTRY_DSN }} + ELEMENT_SDK_SENTRY_DSN: ${{ secrets.ELEMENT_SDK_SENTRY_DSN }} ELEMENT_CALL_SENTRY_DSN: ${{ secrets.ELEMENT_CALL_SENTRY_DSN }} ELEMENT_CALL_POSTHOG_API_HOST: ${{ secrets.ELEMENT_CALL_POSTHOG_API_HOST }} ELEMENT_CALL_POSTHOG_API_KEY: ${{ secrets.ELEMENT_CALL_POSTHOG_API_KEY }} @@ -132,7 +133,7 @@ jobs: run: ./gradlew :app:assembleGplaybTchapWithpinningNightly -PallWarningsAsErrors=true $CI_GRADLE_ARG_PROPERTIES - name: Upload Btchap X nightly APKs if: ${{ matrix.variant == 'nightly' }} - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v6 with: name: Btchap.X.beta-nightly path: | diff --git a/.github/workflows/build_enterprise.yml b/.github/workflows/build_enterprise.yml index 51ac46b11fd..f60b25d5d99 100644 --- a/.github/workflows/build_enterprise.yml +++ b/.github/workflows/build_enterprise.yml @@ -54,6 +54,7 @@ jobs: ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }} ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }} ELEMENT_ANDROID_SENTRY_DSN: ${{ secrets.ELEMENT_ANDROID_SENTRY_DSN }} + ELEMENT_SDK_SENTRY_DSN: ${{ secrets.ELEMENT_SDK_SENTRY_DSN }} ELEMENT_CALL_SENTRY_DSN: ${{ secrets.ELEMENT_CALL_SENTRY_DSN }} ELEMENT_CALL_POSTHOG_API_HOST: ${{ secrets.ELEMENT_CALL_POSTHOG_API_HOST }} ELEMENT_CALL_POSTHOG_API_KEY: ${{ secrets.ELEMENT_CALL_POSTHOG_API_KEY }} @@ -61,7 +62,7 @@ jobs: run: ./gradlew :app:assembleGplayTchapWithpinningDebug -PallWarningsAsErrors=true $CI_GRADLE_ARG_PROPERTIES - name: Upload debug Enterprise APKs if: ${{ matrix.variant == 'debug' }} - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@v6 with: name: elementx-enterprise-debug path: | diff --git a/.github/workflows/maestro-local.yml b/.github/workflows/maestro-local.yml index a77a3ae214c..0d4585dbd0d 100644 --- a/.github/workflows/maestro-local.yml +++ b/.github/workflows/maestro-local.yml @@ -45,7 +45,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@v5 + uses: actions/upload-artifact@v6 with: name: elementx-apk-maestro path: | @@ -71,7 +71,7 @@ jobs: ref: ${{ github.ref }} lfs: true # Tchap TODO : Remove this when maven is using matrix-sdk from Tchap repository - name: Download APK artifact from previous job - uses: actions/download-artifact@v6 + uses: actions/download-artifact@v7 with: name: elementx-apk-maestro - name: Enable KVM group perms @@ -104,7 +104,7 @@ jobs: script: | .github/workflows/scripts/maestro/maestro-local-with-screen-recording.sh app-gplay-tchap-withpinning-x86_64-debug.apk - name: Upload test results - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@v6 with: name: test-results path: | diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 8bf33941141..9f517494a59 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -31,6 +31,7 @@ jobs: ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }} ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }} ELEMENT_ANDROID_SENTRY_DSN: ${{ secrets.ELEMENT_ANDROID_SENTRY_DSN }} + ELEMENT_SDK_SENTRY_DSN: ${{ secrets.ELEMENT_SDK_SENTRY_DSN }} ELEMENT_CALL_SENTRY_DSN: ${{ secrets.ELEMENT_CALL_SENTRY_DSN }} ELEMENT_CALL_POSTHOG_API_HOST: ${{ secrets.ELEMENT_CALL_POSTHOG_API_HOST }} ELEMENT_CALL_POSTHOG_API_KEY: ${{ secrets.ELEMENT_CALL_POSTHOG_API_KEY }} diff --git a/.github/workflows/nightlyReports.yml b/.github/workflows/nightlyReports.yml index 2788e13576c..f46c746054b 100644 --- a/.github/workflows/nightlyReports.yml +++ b/.github/workflows/nightlyReports.yml @@ -43,7 +43,7 @@ jobs: - name: ✅ Upload kover report if: always() - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@v6 with: name: kover-results path: | @@ -75,7 +75,7 @@ jobs: run: ./gradlew dependencyCheckAnalyze $CI_GRADLE_ARG_PROPERTIES - name: Upload dependency analysis if: always() - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@v6 with: name: dependency-analysis path: build/reports/dependency-check-report.html diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 471aeb77b8e..4345dcc9347 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -102,7 +102,7 @@ jobs: run: ./gradlew :tests:konsist:testDebugUnitTest $CI_GRADLE_ARG_PROPERTIES --no-daemon - name: Upload reports if: always() - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@v6 with: name: konsist-report path: | @@ -182,7 +182,7 @@ jobs: run: ./gradlew :app:lintGplayTchapWithpinningDebug :app:lintFdroidTchapWithpinningDebug lintDebug $CI_GRADLE_ARG_PROPERTIES --continue - name: Upload reports if: always() - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@v6 with: name: linting-report path: | @@ -225,7 +225,7 @@ jobs: run: ./gradlew detekt $CI_GRADLE_ARG_PROPERTIES --no-daemon - name: Upload reports if: always() - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@v6 with: name: detekt-report path: | @@ -268,7 +268,7 @@ jobs: run: ./gradlew ktlintCheck $CI_GRADLE_ARG_PROPERTIES - name: Upload reports if: always() - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@v6 with: name: ktlint-report path: | @@ -334,7 +334,7 @@ jobs: # https://github.com/actions/checkout/issues/881 ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }} - name: Download reports from previous jobs - uses: actions/download-artifact@v6 + uses: actions/download-artifact@v7 - name: Prepare Danger if: always() run: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 96ba4f39afd..53e59921087 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -32,13 +32,14 @@ jobs: ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }} ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }} ELEMENT_ANDROID_SENTRY_DSN: ${{ secrets.ELEMENT_ANDROID_SENTRY_DSN }} + ELEMENT_SDK_SENTRY_DSN: ${{ secrets.ELEMENT_SDK_SENTRY_DSN }} ELEMENT_CALL_SENTRY_DSN: ${{ secrets.ELEMENT_CALL_SENTRY_DSN }} ELEMENT_CALL_POSTHOG_API_HOST: ${{ secrets.ELEMENT_CALL_POSTHOG_API_HOST }} ELEMENT_CALL_POSTHOG_API_KEY: ${{ secrets.ELEMENT_CALL_POSTHOG_API_KEY }} ELEMENT_CALL_RAGESHAKE_URL: ${{ secrets.ELEMENT_CALL_RAGESHAKE_URL }} run: ./gradlew bundleGplayTchapWithpinningRelease $CI_GRADLE_ARG_PROPERTIES - name: Upload bundle as artifact - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@v6 with: name: elementx-app-gplay-bundle-unsigned path: | @@ -76,7 +77,7 @@ jobs: ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }} run: ./gradlew bundleGplayTchapWithpinningRelease $CI_GRADLE_ARG_PROPERTIES - name: Upload bundle as artifact - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@v6 with: name: elementx-enterprise-app-gplay-bundle-unsigned path: | @@ -104,7 +105,7 @@ jobs: ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }} run: ./gradlew assembleFdroidTchapWithpinningRelease $CI_GRADLE_ARG_PROPERTIES - name: Upload apks as artifact - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@v6 with: name: elementx-app-fdroid-apks-unsigned path: | diff --git a/.github/workflows/sync-localazy.yml b/.github/workflows/sync-localazy.yml index 23c904c2298..9d7eb8257d5 100644 --- a/.github/workflows/sync-localazy.yml +++ b/.github/workflows/sync-localazy.yml @@ -36,7 +36,7 @@ jobs: ./tools/localazy/importSupportedLocalesFromLocalazy.py ./tools/test/generateAllScreenshots.py - name: Create Pull Request for Strings - uses: peter-evans/create-pull-request@84ae59a2cdc2258d6fa0732dd66352dddae2a412 # v7.0.9 + uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0 with: token: ${{ secrets.DANGER_GITHUB_API_TOKEN }} commit-message: Sync Strings from Localazy diff --git a/.github/workflows/sync-sas-strings.yml b/.github/workflows/sync-sas-strings.yml index d474064e8ed..243e3462849 100644 --- a/.github/workflows/sync-sas-strings.yml +++ b/.github/workflows/sync-sas-strings.yml @@ -23,7 +23,7 @@ jobs: - name: Run SAS String script run: ./tools/sas/import_sas_strings.py - name: Create Pull Request for SAS Strings - uses: peter-evans/create-pull-request@84ae59a2cdc2258d6fa0732dd66352dddae2a412 # v7.0.9 + uses: peter-evans/create-pull-request@98357b18bf14b5342f975ff684046ec3b2a07725 # v8.0.0 with: commit-message: Sync SAS Strings title: Sync SAS Strings diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index f2d65019810..fdca1d44ce8 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -63,7 +63,7 @@ jobs: - name: 🚫 Upload kover failed coverage reports if: failure() - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@v6 with: name: kover-error-report path: | @@ -75,7 +75,7 @@ jobs: - name: 🚫 Upload test results on error if: failure() - uses: actions/upload-artifact@v5 + uses: actions/upload-artifact@v6 with: name: tests-and-screenshot-tests-results path: | @@ -85,7 +85,7 @@ jobs: # https://github.com/codecov/codecov-action - name: ☂️ Upload coverage reports to codecov - uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5.5.1 + uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2 # Skip in forks if: ${{ github.repository == 'element-hq/element-x-android' && ('pull_request' != github.event_name || github.event.pull_request.head.repo.full_name == github.repository) }} with: diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml index 3efb2d8dd4c..dbbf81b44bc 100644 --- a/.idea/kotlinc.xml +++ b/.idea/kotlinc.xml @@ -1,6 +1,6 @@ - - \ No newline at end of file + diff --git a/CHANGES.md b/CHANGES.md index 01a83c0ccf0..164739cb607 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,200 @@ +Changes in Element X v26.01.0 +============================= + + + +## What's Changed +### ✨ Features +* Link new device using QrCode - First version by @bmarty in https://github.com/element-hq/element-x-android/pull/5909 +* Voice message: variable play back speed by @bmarty in https://github.com/element-hq/element-x-android/pull/5963 +* Change Room’s Access to/from Space members by @ganfra in https://github.com/element-hq/element-x-android/pull/5979 +* Create spaces by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5982 +### 🙌 Improvements +* change(room member): make sure we never display name/avatar when member is banned by @ganfra in https://github.com/element-hq/element-x-android/pull/5826 +* Change : room details edit by @ganfra in https://github.com/element-hq/element-x-android/pull/5844 +* Space feature flags by @ganfra in https://github.com/element-hq/element-x-android/pull/5827 +* Update unsaved change dialog by @bmarty in https://github.com/element-hq/element-x-android/pull/5845 +* change(notification): handle invite notification for spaces by @ganfra in https://github.com/element-hq/element-x-android/pull/5854 +* Change : space settings iteration by @ganfra in https://github.com/element-hq/element-x-android/pull/5908 +* Change : add "settings" entry menu by @ganfra in https://github.com/element-hq/element-x-android/pull/5948 +* Changes : iterate again on permissions by @ganfra in https://github.com/element-hq/element-x-android/pull/5950 +### 🐛 Bugfixes +* fix: usersWithRole(Owner) returns creators only if privilegedCreatorRole is true by @ganfra in https://github.com/element-hq/element-x-android/pull/5832 +* Limit composer height dynamically by @bmarty in https://github.com/element-hq/element-x-android/pull/5835 +* Fix work requests for inaccessible sessions being re-scheduled indefinitely by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5849 +* Fix permission setting navigation by @bmarty in https://github.com/element-hq/element-x-android/pull/5877 +* URL-encode deep link path segments and decode them when parsing by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5880 +* Fix crash when calling `Room.predecessorRoom` when the room is destroyed by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5894 +* fix: edit moderators not working by @ganfra in https://github.com/element-hq/element-x-android/pull/5906 +* Use the right video preset when sharing videos by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5892 +* Add `threadInfo` field to message like timeline events by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5930 +* Fix unverified account after account creation by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5914 +* Fix class cast exception by @bmarty in https://github.com/element-hq/element-x-android/pull/5958 +* Fix : iterate on unban permissions by @ganfra in https://github.com/element-hq/element-x-android/pull/5959 +* Use `VerificationState.VERIFIED` as soon as it's available by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5973 +* Make the notification silent when the message is an outgoing message by @bmarty in https://github.com/element-hq/element-x-android/pull/5961 +* Remove previously used id filtering from `RoomSyncSubscriber` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5985 +* When handling incoming share, reuse existing room screen if possible by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6001 +* When a duplicate room list entry is found, report it and remove it by @jmartinesp in https://github.com/element-hq/element-x-android/pull/6006 +### 🗣 Translations +* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/5860 +* Sync Strings - Adding translations for Croatian by @ElementBot in https://github.com/element-hq/element-x-android/pull/5904 +* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/5946 +* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/5956 +* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/5971 +* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/5994 +### 🧱 Build +* Restore `no-unused-imports` behaviour for `ktlintFormat` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5847 +* Fix: use the right `BuildTimeConfig` field for the SDK DSN by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5856 +* Add a way to configure value of useLegacyPackaging by @bmarty in https://github.com/element-hq/element-x-android/pull/5862 +* Improve proguard config to keep the names in the classes in our packages by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5882 +* Fix crash when changing the push provider in nightlies by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5951 +### Dependency upgrades +* fix(deps): update dependency androidx.exifinterface:exifinterface to v1.4.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5846 +* fix(deps): update metro to v0.8.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5833 +* fix(deps): update dependency org.maplibre.gl:android-sdk to v12.2.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5831 +* chore(deps): update plugin sonarqube to v7.2.0.6526 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5851 +* fix(deps): update dependency org.matrix.rustcomponents:sdk-android to v25.12.4 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5855 +* fix(deps): update dependency io.sentry:sentry-android to v8.28.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5853 +* fix(deps): update dependency io.nlopez.compose.rules:detekt to v0.5.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5852 +* Update dependency io.mockk:mockk to v1.14.7 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5866 +* Update metro to v0.8.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5867 +* Update peter-evans/create-pull-request action to v7.0.11 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5865 +* Update camera to v1.5.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5857 +* fix(deps): update showkase to v1.0.5 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5868 +* chore(deps): update codecov/codecov-action action to v5.5.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5874 +* fix(deps): update dependency org.maplibre.gl:android-sdk to v12.2.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5876 +* fix(deps): update dependency net.zetetic:sqlcipher-android to v4.12.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5872 +* fix(deps): update dependency org.matrix.rustcomponents:sdk-android to v25.12.10 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5881 +* Update android.gradle.plugin to v8.13.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5887 +* fix(deps): update dependency com.google.crypto.tink:tink-android to v1.20.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5875 +* Update Compose BOM to version 2025.12.00. by @bmarty in https://github.com/element-hq/element-x-android/pull/5179 +* Sync compound tokens https://github.com/element-hq/compound-design-tokens/releases/tag/v6.4.3 by @bmarty in https://github.com/element-hq/element-x-android/pull/5913 +* fix(deps): update lifecycle to v2.10.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5240 +* fix(deps): update dependency io.nlopez.compose.rules:detekt to v0.5.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5911 +* fix(deps): update kotlin by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5417 +* fix(deps): update activity to v1.12.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5770 +* fix(deps): update dependency org.matrix.rustcomponents:sdk-android to v25.12.17 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5912 +* fix(deps): update dependency io.sentry:sentry-android to v8.29.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5918 +* fix(deps): update dependency com.google.firebase:firebase-bom to v34.7.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5915 +* fix(deps): update haze to v1.7.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5712 +* chore(deps): update peter-evans/create-pull-request action to v8 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5878 +* fix(deps): update dependency com.posthog:posthog-android to v3.27.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5871 +* chore(deps): update plugin sonarqube to v7.2.1.6560 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5905 +* fix(deps): update metro to v0.9.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5920 +* fix(deps): update activity to v1.12.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5924 +* Update plugin sonarqube to v7.2.2.6593 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5927 +* fix(deps): update media3 to v1.9.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5931 +* fix(deps): update metro to v0.9.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5940 +* fix(deps): update dependency io.nlopez.compose.rules:detekt to v0.5.3 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5939 +* fix(deps): update dependency com.google.zxing:core to v3.5.4 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5935 +* Upgrade robolectric to version 4.16 by @bmarty in https://github.com/element-hq/element-x-android/pull/5923 +* fix(deps): update dependency androidx.webkit:webkit to v1.15.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5925 +* chore(deps): update github artifact actions (major) by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5932 +* fix(deps): update dependency org.maplibre.gl:android-sdk to v12.3.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5883 +* fix(deps): update dependency io.github.sergio-sastre.composablepreviewscanner:android to v0.8.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5916 +* fix(deps): update dependency org.matrix.rustcomponents:sdk-android to v25.12.19 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5943 +* fix(deps): update kotlin by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5917 +* fix(deps): update dependency com.posthog:posthog-android to v3.28.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5941 +* fix(deps): update wysiwyg to v2.41.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5921 +* fix(deps): update roborazzi to v1.53.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5962 +* fix(deps): update roborazzi to v1.54.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5970 +* fix(deps): update dependency org.unifiedpush.android:connector to v3.2.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5972 +* fix(deps): update metro to v0.9.3 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5967 +* Upgrade compose to 2025.12.01 by @bmarty in https://github.com/element-hq/element-x-android/pull/5969 +* fix(deps): update dependency org.matrix.rustcomponents:sdk-android to v26 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5977 +* fix(deps): update dependency org.matrix.rustcomponents:sdk-android to v26.1.9 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5986 +* fix(deps): update roborazzi to v1.56.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5987 +* fix(deps): update dependency com.posthog:posthog-android to v3.28.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5988 +* fix(deps): update metro to v0.9.4 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5991 +* fix(deps): update dependency org.matrix.rustcomponents:sdk-android to v26.1.12 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5999 +### Others +* Enable Sentry in the SDK and allow bridging spans by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5808 +* Add alert to encrypted rooms with visible history (Android). by @kaylendog in https://github.com/element-hq/element-x-android/pull/5709 +* Add accessibility to the "sending" picto. by @bmarty in https://github.com/element-hq/element-x-android/pull/5869 +* Add SDK database vacuuming operations by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5858 +* Sync compound tokens https://github.com/element-hq/compound-design-tokens/releases/tag/v6.4.2 by @bmarty in https://github.com/element-hq/element-x-android/pull/5897 +* RoomSummary: move the icon related to the last message state on start of the message. by @bmarty in https://github.com/element-hq/element-x-android/pull/5888 +* Qr code scanner cleanup by @bmarty in https://github.com/element-hq/element-x-android/pull/5891 +* Design : update user rows by @ganfra in https://github.com/element-hq/element-x-android/pull/5900 +* misc : rework power levels apis by @ganfra in https://github.com/element-hq/element-x-android/pull/5879 +* Fix preview name by @bmarty in https://github.com/element-hq/element-x-android/pull/5919 +* Allow uploading extra data to Sentry when analytics is enabled by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5910 +* Show history visibility banner strictly for `shared` rooms instead of `invited`. by @kaylendog in https://github.com/element-hq/element-x-android/pull/5936 +* Simplify the copy of the history visibility settings by @bmarty in https://github.com/element-hq/element-x-android/pull/5942 +* Use only font from compound by @bmarty in https://github.com/element-hq/element-x-android/pull/5945 +* Cleanup FFI object fixtures. by @bmarty in https://github.com/element-hq/element-x-android/pull/5957 +* Add variable playback speed feature for voice messages by @Medformatik in https://github.com/element-hq/element-x-android/pull/5504 +* Ensure that avatars always have a content description. by @bmarty in https://github.com/element-hq/element-x-android/pull/5968 +* Ensure space feature is enabled by @ganfra in https://github.com/element-hq/element-x-android/pull/5960 +* Adjust metrics to the new specifications by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5937 +* Use `TextFieldState` for room list search by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5975 +* fix(deps): update roborazzi to v1.55.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5976 +* Iterate on verification screen by @bmarty in https://github.com/element-hq/element-x-android/pull/5981 +* Add preview with a11y details. by @bmarty in https://github.com/element-hq/element-x-android/pull/5984 +* Change the title for `AnalyticsTransactions.coldStart` and `.catchUp` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5998 +* [a11y] voice message improvements by @bmarty in https://github.com/element-hq/element-x-android/pull/5980 + +## New Contributors +* @Medformatik made their first contribution in https://github.com/element-hq/element-x-android/pull/5504 + +**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v25.12.0...v26.01.0 + +Changes in Element X v25.12.0 +============================= + + + +## What's Changed +### ✨ Features +* Room list: enable latest event sorter. by @bmarty in https://github.com/element-hq/element-x-android/pull/5825 +* Add room list indicators about last message by @bmarty in https://github.com/element-hq/element-x-android/pull/5824 +### 🙌 Improvements +* Change : improve room and space member list by @ganfra in https://github.com/element-hq/element-x-android/pull/5806 +* Change : security and privacy rework by @ganfra in https://github.com/element-hq/element-x-android/pull/5816 +### 🐛 Bugfixes +* Ensure confirmation dialog is displayed when an admin add other admin to a room by @bmarty in https://github.com/element-hq/element-x-android/pull/5786 +* Edit user profile cancel confirmation by @bmarty in https://github.com/element-hq/element-x-android/pull/5788 +* Fix editing owner by @bmarty in https://github.com/element-hq/element-x-android/pull/5807 +* Uris should take precedence in plain text intents by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5785 +* Fix long voice recording by @bmarty in https://github.com/element-hq/element-x-android/pull/5821 +### 🗣 Translations +* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/5792 +* Sync Strings by @ElementBot in https://github.com/element-hq/element-x-android/pull/5830 +### 🧱 Build +* Use regex to check forbidden terms by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5784 +* Update Gradle Wrapper from 8.14.3 to 9.2.1 by @ElementBot in https://github.com/element-hq/element-x-android/pull/5751 +### Dependency upgrades +* fix(deps): update dependency androidx.sqlite:sqlite-ktx to v2.6.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5769 +* fix(deps): update datastore to v1.2.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5789 +* chore(deps): update peter-evans/create-pull-request action to v7.0.9 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5793 +* fix(deps): update dependency io.nlopez.compose.rules:detekt to v0.4.28 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5795 +* fix(deps): update metro to v0.7.7 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5771 +* chore(deps): update plugin sonarqube to v7.1.0.6387 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5783 +* fix(deps): update dependency io.github.sergio-sastre.composablepreviewscanner:android to v0.7.2 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5799 +* fix(deps): update dependency org.matrix.rustcomponents:sdk-android to v25.11.24 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5796 +* fix(deps): update dependency io.sentry:sentry-android to v8.27.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5803 +* fix(deps): update dependency io.element.android:emojibase-bindings to v1.5.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5801 +* fix(deps): update roborazzi to v1.52.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5804 +* fix(deps): update dependency org.maplibre.gl:android-sdk to v12.2.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5814 +* chore(deps): update actions/checkout action to v6 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5805 +* fix(deps): update dependency com.google.testparameterinjector:test-parameter-injector to v1.20 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5800 +* fix(deps): update android.gradle.plugin to v8.13.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5260 +* fix(deps): update dependency org.matrix.rustcomponents:sdk-android to v25.11.26 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5818 +* fix(deps): update dependencyanalysis to v3.5.1 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5819 +* fix(deps): update dependency com.posthog:posthog-android to v3.27.0 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5834 +* fix(deps): update dependency io.element.android:element-call-embedded to v0.16.3 by @renovate[bot] in https://github.com/element-hq/element-x-android/pull/5839 +* Upgrade the Rust SDK to `v25.12.2` by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5838 +### Others +* misc : use newLatestEvent api from sdk by @ganfra in https://github.com/element-hq/element-x-android/pull/5809 +* Inject RoomMemberListDataSource in the presenter constructor. by @bmarty in https://github.com/element-hq/element-x-android/pull/5822 +* Add more performance checks by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5767 +* Load `JoinedRoom` in home screen, pass it to the room flow by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5817 +* Revert "fix(deps): update dependency com.posthog:posthog-android to v3.27.0" by @jmartinesp in https://github.com/element-hq/element-x-android/pull/5836 + + +**Full Changelog**: https://github.com/element-hq/element-x-android/compare/v25.11.3...v25.12.0 + Changes in Element X v25.11.3 ============================= diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 3dc96b79cfb..d3b3bf730d5 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -236,6 +236,10 @@ android { resources.pickFirsts += setOf( "META-INF/versions/9/OSGI-INF/MANIFEST.MF", ) + + jniLibs { + useLegacyPackaging = project.findProperty("useLegacyPackaging")?.toString()?.toBoolean() + } } } diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 96109425fa3..b09ecf69ca4 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -66,7 +66,10 @@ -dontwarn androidx.window.sidecar.SidecarWindowLayoutInfo # Also needed after AGP 8.13.1 upgrade, it seems like proguard is now more aggressive on removing unused code --keep class org.matrix.rustcomponents.sdk.** { *;} --keep class uniffi.** { *;} --keep class io.element.android.x.di.** { *; } --keepnames class io.element.android.x.** +-keep,allowshrinking class org.matrix.rustcomponents.sdk.** { *;} +-keep,allowshrinking class uniffi.** { *;} +-keep,allowshrinking class io.element.android.x.di.** { *; } +-keepclasseswithmembernames,allowoptimization,allowshrinking class io.element.android.** { *; } + +# Keep Metro classes +-keep,allowshrinking class dev.zacsweers.metro.** { *; } 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 6e157f6ac1a..6768d0d8db1 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 @@ -17,6 +17,7 @@ import io.element.android.features.lockscreen.api.LockScreenService 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 +import io.element.android.libraries.di.identifiers.SentrySdkDsn import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.matrix.api.platform.InitPlatformService import io.element.android.libraries.matrix.api.tracing.TracingService @@ -48,4 +49,6 @@ interface AppBindings { fun featureFlagService(): FeatureFlagService fun buildMeta(): BuildMeta + + fun sentrySdkDsn(): SentrySdkDsn? } 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 0eea5123b40..2a844b1331d 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 @@ -38,6 +38,7 @@ class PlatformInitializer : Initializer { logLevel = logLevel, extraTargets = listOf(ELEMENT_X_TARGET), traceLogPacks = runBlocking { preferencesStore.getTracingLogPacksFlow().first() }, + sdkSentryDsn = appBindings.sentrySdkDsn()?.value?.takeIf { it.isNotBlank() }, ) bugReporter.setCurrentTracingLogLevel(logLevel.name) platformService.init(tracingConfiguration) diff --git a/app/src/main/res/xml/locales_config.xml b/app/src/main/res/xml/locales_config.xml index a92d7b2ef91..a171646bad1 100644 --- a/app/src/main/res/xml/locales_config.xml +++ b/app/src/main/res/xml/locales_config.xml @@ -15,6 +15,7 @@ + diff --git a/appconfig/src/main/kotlin/io/element/android/appconfig/LearnMoreConfig.kt b/appconfig/src/main/kotlin/io/element/android/appconfig/LearnMoreConfig.kt index 0bc1304fcdf..aeb9f3a6d48 100644 --- a/appconfig/src/main/kotlin/io/element/android/appconfig/LearnMoreConfig.kt +++ b/appconfig/src/main/kotlin/io/element/android/appconfig/LearnMoreConfig.kt @@ -13,5 +13,8 @@ object LearnMoreConfig { const val DEVICE_VERIFICATION_URL: String = "https://element.io/help#encryption-device-verification" const val SECURE_BACKUP_URL: String = "https://element.io/help#encryption5" const val IDENTITY_CHANGE_URL: String = "https://element.io/help#encryption18" + + // TCHAP - Add FAQ_URL in Preferences const val FAQ_URL: String = "https://aide.tchap.numerique.gouv.fr" + const val HISTORY_VISIBLE_URL: String = "https://element.io/en/help#e2ee-history-sharing" } diff --git a/appconfig/src/main/kotlin/io/element/android/appconfig/RageshakeConfig.kt b/appconfig/src/main/kotlin/io/element/android/appconfig/RageshakeConfig.kt index 1f6609ecc33..38dcd67c9e2 100644 --- a/appconfig/src/main/kotlin/io/element/android/appconfig/RageshakeConfig.kt +++ b/appconfig/src/main/kotlin/io/element/android/appconfig/RageshakeConfig.kt @@ -25,4 +25,9 @@ object RageshakeConfig { * The maximum size of the upload request. Default value is just below CloudFlare's max request size. */ const val MAX_LOG_UPLOAD_SIZE = 50 * 1024 * 1024L + + /** + * The maximum size of a single log file. + */ + const val MAX_LOG_CONTENT_SIZE = 100 * 1024 * 1024L } diff --git a/appconfig/src/main/kotlin/io/element/android/appconfig/TimelineConfig.kt b/appconfig/src/main/kotlin/io/element/android/appconfig/TimelineConfig.kt index 539b67825d5..d4fe7d1fc51 100644 --- a/appconfig/src/main/kotlin/io/element/android/appconfig/TimelineConfig.kt +++ b/appconfig/src/main/kotlin/io/element/android/appconfig/TimelineConfig.kt @@ -17,19 +17,19 @@ object TimelineConfig { * Event types that will be filtered out from the timeline (i.e. not displayed). */ val excludedEvents = listOf( - StateEventType.CALL_MEMBER, - StateEventType.ROOM_ALIASES, - StateEventType.ROOM_CANONICAL_ALIAS, - StateEventType.ROOM_GUEST_ACCESS, - StateEventType.ROOM_HISTORY_VISIBILITY, - StateEventType.ROOM_JOIN_RULES, - StateEventType.ROOM_POWER_LEVELS, - StateEventType.ROOM_SERVER_ACL, - StateEventType.ROOM_TOMBSTONE, - StateEventType.SPACE_CHILD, - StateEventType.SPACE_PARENT, - StateEventType.POLICY_RULE_ROOM, - StateEventType.POLICY_RULE_SERVER, - StateEventType.POLICY_RULE_USER, + StateEventType.CallMember, + StateEventType.RoomAliases, + StateEventType.RoomCanonicalAlias, + StateEventType.RoomGuestAccess, + StateEventType.RoomHistoryVisibility, + StateEventType.RoomJoinRules, + StateEventType.RoomPowerLevels, + StateEventType.RoomServerAcl, + StateEventType.RoomTombstone, + StateEventType.SpaceChild, + StateEventType.SpaceParent, + StateEventType.PolicyRuleRoom, + StateEventType.PolicyRuleServer, + StateEventType.PolicyRuleUser, ) } diff --git a/appnav/build.gradle.kts b/appnav/build.gradle.kts index aa0bc047722..8bc821f9333 100644 --- a/appnav/build.gradle.kts +++ b/appnav/build.gradle.kts @@ -48,6 +48,7 @@ dependencies { implementation(projects.features.announcement.api) implementation(projects.features.ftue.api) + implementation(projects.features.linknewdevice.api) implementation(projects.features.share.api) implementation(projects.services.apperror.impl) @@ -62,6 +63,7 @@ dependencies { testImplementation(projects.libraries.push.test) testImplementation(projects.libraries.pushproviders.test) testImplementation(projects.features.forward.test) + testImplementation(projects.features.messages.test) testImplementation(projects.features.networkmonitor.test) testImplementation(projects.features.rageshake.test) testImplementation(projects.services.appnavstate.impl) 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 38dc39ee83a..21bdd99a470 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt @@ -47,12 +47,14 @@ 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.createroom.api.CreateRoomEntryPoint import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.features.enterprise.api.SessionEnterpriseService 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.networkmonitor.api.NetworkMonitor import io.element.android.features.networkmonitor.api.NetworkStatus import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorContainer @@ -123,6 +125,7 @@ class LoggedInFlowNode( private val secureBackupEntryPoint: SecureBackupEntryPoint, private val userProfileEntryPoint: UserProfileEntryPoint, private val ftueEntryPoint: FtueEntryPoint, + private val linkNewDeviceEntryPoint: LinkNewDeviceEntryPoint, @SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope, private val ftueService: FtueService, @@ -142,6 +145,7 @@ class LoggedInFlowNode( snackbarDispatcher: SnackbarDispatcher, private val analyticsService: AnalyticsService, private val analyticsRoomListStateWatcher: AnalyticsRoomListStateWatcher, + private val createRoomEntryPoint: CreateRoomEntryPoint, ) : BaseFlowNode( backstack = BackStack( initialElement = NavTarget.Placeholder, @@ -285,6 +289,9 @@ class LoggedInFlowNode( @Parcelize data object CreateRoom : NavTarget + @Parcelize + data object CreateSpace : NavTarget + @Parcelize data class SecureBackup( val initialElement: SecureBackupEntryPoint.InitialTarget = SecureBackupEntryPoint.InitialTarget.Root @@ -293,6 +300,9 @@ class LoggedInFlowNode( @Parcelize data object Ftue : NavTarget + @Parcelize + data object LinkNewDevice : NavTarget + @Parcelize data object RoomDirectory : NavTarget @@ -333,6 +343,10 @@ class LoggedInFlowNode( backstack.push(NavTarget.CreateRoom) } + override fun navigateToCreateSpace() { + backstack.push(NavTarget.CreateSpace) + } + override fun navigateToSetUpRecovery() { backstack.push(NavTarget.SecureBackup(initialElement = SecureBackupEntryPoint.InitialTarget.Root)) } @@ -419,6 +433,10 @@ class LoggedInFlowNode( callback.navigateToAddAccount() } + override fun navigateToLinkNewDevice() { + backstack.push(NavTarget.LinkNewDevice) + } + override fun navigateToBugReport() { callback.navigateToBugReport() } @@ -460,6 +478,14 @@ class LoggedInFlowNode( callback = callback, ) } + is NavTarget.CreateSpace -> { + val callback = object : CreateRoomEntryPoint.Callback { + override fun onRoomCreated(roomId: RoomId) { + backstack.replace(NavTarget.Room(roomIdOrAlias = RoomIdOrAlias.Id(roomId), serverNames = emptyList())) + } + } + createRoomEntryPoint.createNode(isSpace = true, parentNode = this, buildContext = buildContext, callback = callback) + } is NavTarget.SecureBackup -> { secureBackupEntryPoint.createNode( parentNode = this, @@ -475,6 +501,14 @@ class LoggedInFlowNode( NavTarget.Ftue -> { ftueEntryPoint.createNode(this, buildContext) } + NavTarget.LinkNewDevice -> { + val callback = object : LinkNewDeviceEntryPoint.Callback { + override fun onDone() { + backstack.pop() + } + } + linkNewDeviceEntryPoint.createNode(this, buildContext, callback) + } NavTarget.RoomDirectory -> { roomDirectoryEntryPoint.createNode( parentNode = this, @@ -499,9 +533,18 @@ class LoggedInFlowNode( params = ShareEntryPoint.Params(intent = navTarget.intent), 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 -> - backstack.push(NavTarget.Room(roomId.toRoomIdOrAlias())) + sessionCoroutineScope.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) + } } } }, @@ -627,7 +670,21 @@ private class AttachRoomOperation( operation = this ) } else { - Push(roomTarget).invoke(elements) + val existingRoomElement = elements.find { + val roomNavTarget = it.key.navTarget as? LoggedInFlowNode.NavTarget.Room + roomNavTarget?.roomIdOrAlias == roomTarget.roomIdOrAlias + } + if (existingRoomElement != null) { + elements.mapNotNull { element -> + if (element == existingRoomElement) { + null + } else { + element.transitionTo(STASHED, this) + } + } + existingRoomElement.transitionTo(ACTIVE, this) + } else { + Push(roomTarget).invoke(elements) + } } } } 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 46879463677..1ec1678d71d 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt @@ -320,7 +320,7 @@ class RootFlowNode( is ResolvedIntent.Navigation -> { val openingRoomFromNotification = intent.getBooleanExtra(ROOM_OPENED_FROM_NOTIFICATION, false) if (openingRoomFromNotification && resolvedIntent.deeplinkData is DeeplinkData.Room) { - analyticsService.startLongRunningTransaction(AnalyticsLongRunningTransaction.NotificationTapOpensTimeline) + analyticsService.startLongRunningTransaction(AnalyticsLongRunningTransaction.NotificationToMessage) } navigateTo(resolvedIntent.deeplinkData) } diff --git a/appnav/src/main/kotlin/io/element/android/appnav/di/MatrixSessionCache.kt b/appnav/src/main/kotlin/io/element/android/appnav/di/MatrixSessionCache.kt index 4af64cba34c..c6a031921f7 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/di/MatrixSessionCache.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/di/MatrixSessionCache.kt @@ -14,10 +14,13 @@ import com.bumble.appyx.core.state.SavedStateMap import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.SingleIn +import io.element.android.libraries.androidutils.hash.hash import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClientProvider 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.runBlocking import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -36,6 +39,7 @@ private const val SAVE_INSTANCE_KEY = "io.element.android.x.di.MatrixClientsHold class MatrixSessionCache( private val authenticationService: MatrixAuthenticationService, private val syncOrchestratorFactory: SyncOrchestrator.Factory, + private val analyticsService: AnalyticsService, ) : MatrixClientProvider { private val sessionIdsToMatrixSession = ConcurrentHashMap() private val restoreMutex = Mutex() @@ -100,6 +104,11 @@ class MatrixSessionCache( Timber.d("Restore matrix session: $sessionId") return authenticationService.restoreSession(sessionId) .onSuccess { matrixClient -> + // Add the current homeserver (hashed) to the extra info + // This may not play well with multiple sessions, but it should work for now + analyticsService.addIndexableData(AnalyticsUserData.HOMESERVER, matrixClient.userIdServerName().hash()) + + // Add the new client to the in-memory cache onNewMatrixClient(matrixClient) } .onFailure { diff --git a/appnav/src/main/kotlin/io/element/android/appnav/di/SyncOrchestrator.kt b/appnav/src/main/kotlin/io/element/android/appnav/di/SyncOrchestrator.kt index 53ce50a7889..9b1bbd1b816 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/di/SyncOrchestrator.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/di/SyncOrchestrator.kt @@ -20,6 +20,7 @@ import io.element.android.libraries.matrix.api.sync.SyncService import io.element.android.libraries.matrix.api.sync.SyncState import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.recordTransaction +import io.element.android.services.analyticsproviders.api.AnalyticsUserData import io.element.android.services.appnavstate.api.AppForegroundStateService import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.FlowPreview @@ -77,7 +78,7 @@ class SyncOrchestrator( // Wait until the sync service is not idle, either it will be running or in error/offline state val firstState = syncService.syncState.first { it != SyncState.Idle } - transaction.setData("first_sync_state", firstState.name) + transaction.putIndexableData(AnalyticsUserData.FIRST_SYNC_STATE, firstState.name) } observeStates() diff --git a/appnav/src/main/kotlin/io/element/android/appnav/di/TimelineBindings.kt b/appnav/src/main/kotlin/io/element/android/appnav/di/TimelineBindings.kt index cb78760a0f2..371b0676375 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/di/TimelineBindings.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/di/TimelineBindings.kt @@ -10,8 +10,10 @@ package io.element.android.appnav.di import io.element.android.features.messages.api.pinned.PinnedEventsTimelineProvider import io.element.android.libraries.matrix.api.timeline.TimelineProvider +import io.element.android.services.analytics.api.watchers.AnalyticsSendMessageWatcher interface TimelineBindings { val timelineProvider: TimelineProvider val pinnedEventsTimelineProvider: PinnedEventsTimelineProvider + val analyticsSendMessageWatcher: AnalyticsSendMessageWatcher } 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 1bf3516900d..6446f754b0d 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 @@ -49,7 +49,7 @@ import io.element.android.libraries.matrix.api.room.RoomMembershipObserver import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias import io.element.android.libraries.matrix.ui.room.LoadingRoomState import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.LoadJoinedRoomFlow -import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.NotificationTapOpensTimeline +import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.NotificationToMessage import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.OpenRoom import io.element.android.services.analytics.api.AnalyticsService import kotlinx.coroutines.flow.SharingStarted @@ -128,7 +128,7 @@ class RoomFlowNode( override fun onBuilt() { super.onBuilt() - val parentTransaction = analyticsService.getLongRunningTransaction(NotificationTapOpensTimeline) + val parentTransaction = analyticsService.getLongRunningTransaction(NotificationToMessage) val openRoomTransaction = analyticsService.startLongRunningTransaction(OpenRoom, parentTransaction) analyticsService.startLongRunningTransaction(LoadJoinedRoomFlow, openRoomTransaction) resolveRoomId() 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 5a6ef9133bf..05ebab2b102 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 @@ -8,7 +8,9 @@ package io.element.android.appnav.room.joined +import android.app.Activity import android.os.Parcelable +import androidx.activity.compose.LocalActivity import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.lifecycle.lifecycleScope @@ -96,6 +98,11 @@ class JoinedRoomLoadedFlowNode( private val callback: Callback = callback() override val graph = roomGraphFactory.create(inputs.room) + private val sendMessageWatcher = (graph as? TimelineBindings)?.analyticsSendMessageWatcher + + // This is an ugly hack to check activity recreation + private var currentActivity: Activity? = null + init { lifecycle.subscribe( onCreate = { @@ -104,6 +111,7 @@ class JoinedRoomLoadedFlowNode( Timber.v("OnCreate => ${inputs.room.roomId}") appNavigationStateService.onNavigateToRoom(id, inputs.room.roomId) activeRoomsHolder.addRoom(inputs.room) + sendMessageWatcher?.start() fetchRoomMembers() trackVisitedRoom() }, @@ -115,8 +123,13 @@ class JoinedRoomLoadedFlowNode( }, onDestroy = { Timber.v("OnDestroy") - activeRoomsHolder.removeRoom(inputs.room.sessionId, inputs.room.roomId) - inputs.room.destroy() + sendMessageWatcher?.stop() + // If we're just going through an activity recreation there's no need to destroy the Room object + // Destroying it would actually cause an issue where its methods can no longer be called + if (currentActivity?.isChangingConfigurations != true) { + activeRoomsHolder.removeRoom(inputs.room.sessionId, inputs.room.roomId) + inputs.room.destroy() + } appNavigationStateService.onLeavingRoom(id) } ) @@ -289,6 +302,8 @@ class JoinedRoomLoadedFlowNode( @Composable override fun View(modifier: Modifier) { + currentActivity = LocalActivity.current + BackstackView() } } diff --git a/appnav/src/main/res/values-hr/translations.xml b/appnav/src/main/res/values-hr/translations.xml new file mode 100644 index 00000000000..f75fa613f29 --- /dev/null +++ b/appnav/src/main/res/values-hr/translations.xml @@ -0,0 +1,6 @@ + + + "Odjava i nadogradnja" + "%1$s više ne podržava stari protokol. Odjavite se i ponovno prijavite kako biste se nastavili služiti aplikacijom." + "Vaš matični poslužitelj više ne podržava stari protokol. Odjavite se i ponovno prijavite kako biste se nastavili služiti aplikacijom." + diff --git a/appnav/src/test/kotlin/io/element/android/appnav/JoinedRoomLoadedFlowNodeTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/JoinedRoomLoadedFlowNodeTest.kt index 6cd7df025be..8d514a2c0fe 100644 --- a/appnav/src/test/kotlin/io/element/android/appnav/JoinedRoomLoadedFlowNodeTest.kt +++ b/appnav/src/test/kotlin/io/element/android/appnav/JoinedRoomLoadedFlowNodeTest.kt @@ -19,22 +19,29 @@ import com.bumble.appyx.testing.junit4.util.MainDispatcherRule import com.bumble.appyx.testing.unit.common.helper.parentNodeTestHelper import com.google.common.truth.Truth.assertThat import io.element.android.appnav.di.RoomGraphFactory +import io.element.android.appnav.di.TimelineBindings import io.element.android.appnav.room.RoomNavigationTarget import io.element.android.appnav.room.joined.FakeJoinedRoomLoadedFlowNodeCallback import io.element.android.appnav.room.joined.JoinedRoomLoadedFlowNode import io.element.android.features.forward.api.ForwardEntryPoint import io.element.android.features.forward.test.FakeForwardEntryPoint import io.element.android.features.messages.api.MessagesEntryPoint +import io.element.android.features.messages.api.pinned.PinnedEventsTimelineProvider +import io.element.android.features.messages.test.pinned.FakePinnedEventsTimelineProvider import io.element.android.features.roomdetails.api.RoomDetailsEntryPoint import io.element.android.features.space.api.SpaceEntryPoint import io.element.android.libraries.architecture.childNode import io.element.android.libraries.matrix.api.room.JoinedRoom +import io.element.android.libraries.matrix.api.timeline.TimelineProvider 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.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.test.timeline.FakeTimelineProvider +import io.element.android.services.analytics.api.watchers.AnalyticsSendMessageWatcher import io.element.android.services.analytics.test.FakeAnalyticsService +import io.element.android.services.analytics.test.watchers.FakeAnalyticsSendMessageWatcher import io.element.android.services.appnavstate.api.ActiveRoomsHolder import io.element.android.services.appnavstate.impl.DefaultActiveRoomsHolder import io.element.android.services.appnavstate.test.FakeAppNavigationStateService @@ -72,9 +79,20 @@ class JoinedRoomLoadedFlowNodeTest { } } - private class FakeRoomGraphFactory : RoomGraphFactory { + private class FakeRoomGraphFactory( + private val timelineProvider: FakeTimelineProvider = FakeTimelineProvider(), + private val pinnedEventsTimelineProvider: FakePinnedEventsTimelineProvider = FakePinnedEventsTimelineProvider(), + private val analyticsSendMessageWatcher: FakeAnalyticsSendMessageWatcher = FakeAnalyticsSendMessageWatcher(), + ) : RoomGraphFactory { override fun create(room: JoinedRoom): Any { - return Unit + return object : TimelineBindings { + override val timelineProvider: TimelineProvider + get() = this@FakeRoomGraphFactory.timelineProvider + override val pinnedEventsTimelineProvider: PinnedEventsTimelineProvider + get() = this@FakeRoomGraphFactory.pinnedEventsTimelineProvider + override val analyticsSendMessageWatcher: AnalyticsSendMessageWatcher + get() = this@FakeRoomGraphFactory.analyticsSendMessageWatcher + } } } diff --git a/appnav/src/test/kotlin/io/element/android/appnav/di/MatrixSessionCacheTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/di/MatrixSessionCacheTest.kt index 56c20f7a1df..36fa471dd04 100644 --- a/appnav/src/test/kotlin/io/element/android/appnav/di/MatrixSessionCacheTest.kt +++ b/appnav/src/test/kotlin/io/element/android/appnav/di/MatrixSessionCacheTest.kt @@ -19,29 +19,31 @@ 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.runCurrent import kotlinx.coroutines.test.runTest import org.junit.Test +@OptIn(ExperimentalCoroutinesApi::class) class MatrixSessionCacheTest { @Test fun `test getOrNull`() = runTest { - val fakeAuthenticationService = FakeMatrixAuthenticationService() - val matrixSessionCache = MatrixSessionCache(fakeAuthenticationService, createSyncOrchestratorFactory()) + val matrixSessionCache = createMatrixSessionCache() assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isNull() } @Test fun `test getSyncOrchestratorOrNull`() = runTest { val fakeAuthenticationService = FakeMatrixAuthenticationService() - val matrixSessionCache = MatrixSessionCache(fakeAuthenticationService, createSyncOrchestratorFactory()) + val matrixSessionCache = createMatrixSessionCache(fakeAuthenticationService) // With no matrix client there is no sync orchestrator assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isNull() assertThat(matrixSessionCache.getSyncOrchestrator(A_SESSION_ID)).isNull() // But as soon as we receive a client, we can get the sync orchestrator - val fakeMatrixClient = FakeMatrixClient(sessionCoroutineScope = backgroundScope) + val fakeMatrixClient = FakeMatrixClient(sessionCoroutineScope = backgroundScope, userIdServerNameLambda = { A_SESSION_ID.value }) fakeAuthenticationService.givenMatrixClient(fakeMatrixClient) assertThat(matrixSessionCache.getOrRestore(A_SESSION_ID).getOrNull()).isEqualTo(fakeMatrixClient) assertThat(matrixSessionCache.getSyncOrchestrator(A_SESSION_ID)).isNotNull() @@ -50,8 +52,8 @@ class MatrixSessionCacheTest { @Test fun `test getOrRestore`() = runTest { val fakeAuthenticationService = FakeMatrixAuthenticationService() - val matrixSessionCache = MatrixSessionCache(fakeAuthenticationService, createSyncOrchestratorFactory()) - val fakeMatrixClient = FakeMatrixClient(sessionCoroutineScope = backgroundScope) + val matrixSessionCache = createMatrixSessionCache(fakeAuthenticationService) + val fakeMatrixClient = FakeMatrixClient(sessionCoroutineScope = backgroundScope, userIdServerNameLambda = { A_SESSION_ID.value }) fakeAuthenticationService.givenMatrixClient(fakeMatrixClient) assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isNull() assertThat(matrixSessionCache.getOrRestore(A_SESSION_ID).getOrNull()).isEqualTo(fakeMatrixClient) @@ -63,8 +65,8 @@ class MatrixSessionCacheTest { @Test fun `test remove`() = runTest { val fakeAuthenticationService = FakeMatrixAuthenticationService() - val matrixSessionCache = MatrixSessionCache(fakeAuthenticationService, createSyncOrchestratorFactory()) - val fakeMatrixClient = FakeMatrixClient(sessionCoroutineScope = backgroundScope) + val matrixSessionCache = createMatrixSessionCache(fakeAuthenticationService) + val fakeMatrixClient = FakeMatrixClient(sessionCoroutineScope = backgroundScope, userIdServerNameLambda = { A_SESSION_ID.value }) fakeAuthenticationService.givenMatrixClient(fakeMatrixClient) assertThat(matrixSessionCache.getOrRestore(A_SESSION_ID).getOrNull()).isEqualTo(fakeMatrixClient) assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isEqualTo(fakeMatrixClient) @@ -76,8 +78,8 @@ class MatrixSessionCacheTest { @Test fun `test remove all`() = runTest { val fakeAuthenticationService = FakeMatrixAuthenticationService() - val matrixSessionCache = MatrixSessionCache(fakeAuthenticationService, createSyncOrchestratorFactory()) - val fakeMatrixClient = FakeMatrixClient(sessionCoroutineScope = backgroundScope) + val matrixSessionCache = createMatrixSessionCache(fakeAuthenticationService) + val fakeMatrixClient = FakeMatrixClient(sessionCoroutineScope = backgroundScope, userIdServerNameLambda = { A_SESSION_ID.value }) fakeAuthenticationService.givenMatrixClient(fakeMatrixClient) assertThat(matrixSessionCache.getOrRestore(A_SESSION_ID).getOrNull()).isEqualTo(fakeMatrixClient) assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isEqualTo(fakeMatrixClient) @@ -89,8 +91,8 @@ class MatrixSessionCacheTest { @Test fun `test save and restore`() = runTest { val fakeAuthenticationService = FakeMatrixAuthenticationService() - val matrixSessionCache = MatrixSessionCache(fakeAuthenticationService, createSyncOrchestratorFactory()) - val fakeMatrixClient = FakeMatrixClient(sessionCoroutineScope = backgroundScope) + val matrixSessionCache = createMatrixSessionCache(fakeAuthenticationService) + val fakeMatrixClient = FakeMatrixClient(sessionCoroutineScope = backgroundScope, userIdServerNameLambda = { A_SESSION_ID.value }) fakeAuthenticationService.givenMatrixClient(fakeMatrixClient) matrixSessionCache.getOrRestore(A_SESSION_ID) val savedStateMap = MutableSavedStateMapImpl { true } @@ -109,29 +111,45 @@ class MatrixSessionCacheTest { @Test fun `test AuthenticationService listenToNewMatrixClients emits a Client value and we save it`() = runTest { val fakeAuthenticationService = FakeMatrixAuthenticationService() - val matrixSessionCache = MatrixSessionCache(fakeAuthenticationService, createSyncOrchestratorFactory()) + val matrixSessionCache = createMatrixSessionCache(fakeAuthenticationService, createSyncOrchestratorFactory()) assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isNull() - fakeAuthenticationService.givenMatrixClient(FakeMatrixClient(sessionId = A_SESSION_ID, sessionCoroutineScope = backgroundScope)) val loginSucceeded = fakeAuthenticationService.login("user", "pass") assertThat(loginSucceeded.isSuccess).isTrue() + + runCurrent() + assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isNotNull() } - private fun TestScope.createSyncOrchestratorFactory() = object : SyncOrchestrator.Factory { - override fun create( - syncService: SyncService, - sessionCoroutineScope: CoroutineScope, - ): SyncOrchestrator { - return SyncOrchestrator( - syncService = syncService, - sessionCoroutineScope = sessionCoroutineScope, - appForegroundStateService = FakeAppForegroundStateService(), - networkMonitor = FakeNetworkMonitor(), - dispatchers = testCoroutineDispatchers(), - analyticsService = FakeAnalyticsService(), - ) + private fun TestScope.createMatrixSessionCache( + authenticationService: FakeMatrixAuthenticationService = FakeMatrixAuthenticationService(), + syncOrchestratorFactory: SyncOrchestrator.Factory = createSyncOrchestratorFactory(), + analyticsService: FakeAnalyticsService = FakeAnalyticsService(), + ) = MatrixSessionCache( + authenticationService = authenticationService, + syncOrchestratorFactory = syncOrchestratorFactory, + analyticsService = analyticsService, + ) + + private fun TestScope.createSyncOrchestratorFactory(): SyncOrchestrator.Factory { + val dispatchers = testCoroutineDispatchers() + + return object : SyncOrchestrator.Factory { + override fun create( + syncService: SyncService, + sessionCoroutineScope: CoroutineScope, + ): SyncOrchestrator { + return SyncOrchestrator( + syncService = syncService, + sessionCoroutineScope = sessionCoroutineScope, + appForegroundStateService = FakeAppForegroundStateService(), + networkMonitor = FakeNetworkMonitor(), + dispatchers = dispatchers, + analyticsService = FakeAnalyticsService(), + ) + } } } } diff --git a/build.gradle.kts b/build.gradle.kts index d3b68354e26..7a5b71a27a1 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -46,7 +46,7 @@ allprojects { config.from(files("$rootDir/tools/detekt/detekt.yml")) } dependencies { - detektPlugins("io.nlopez.compose.rules:detekt:0.4.28") + detektPlugins("io.nlopez.compose.rules:detekt:0.5.3") detektPlugins(project(":tests:detekt-rules")) } 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 7ddf80b92ca..8b537a53fe9 100644 --- a/codegen/src/main/kotlin/io/element/android/codegen/ContributesNodeProcessor.kt +++ b/codegen/src/main/kotlin/io/element/android/codegen/ContributesNodeProcessor.kt @@ -91,7 +91,7 @@ class ContributesNodeProcessor( .addAnnotation(Binds::class) .addAnnotation(IntoMap::class) .addAnnotation( - AnnotationSpec.Companion.builder(ClassName.bestGuess(nodeKeyFqName.asString())).addMember( + AnnotationSpec.builder(ClassName.bestGuess(nodeKeyFqName.asString())).addMember( CLASS_PLACEHOLDER, ClassName.bestGuess(ksClass.qualifiedName!!.asString()) ).build() diff --git a/fastlane/metadata/android/en-US/changelogs/202601000.txt b/fastlane/metadata/android/en-US/changelogs/202601000.txt new file mode 100644 index 00000000000..98c450dce09 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/202601000.txt @@ -0,0 +1,2 @@ +Main changes in this version: iterated on spaces, improved the room list stability and performance, and a long list of bug fixes. +Full changelog: https://github.com/element-hq/element-x-android/releases diff --git a/fastlane/metadata/android/en-US/changelogs/202601010.txt b/fastlane/metadata/android/en-US/changelogs/202601010.txt new file mode 100644 index 00000000000..605371b852a --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/202601010.txt @@ -0,0 +1,2 @@ +Main changes in this version: iterated on spaces, improved the room list stability and performance, fix an issue with the cached well-known config. +Full changelog: https://github.com/element-hq/element-x-android/releases diff --git a/features/analytics/api/src/main/res/values-hr/translations.xml b/features/analytics/api/src/main/res/values-hr/translations.xml new file mode 100644 index 00000000000..ac1e83c87b9 --- /dev/null +++ b/features/analytics/api/src/main/res/values-hr/translations.xml @@ -0,0 +1,7 @@ + + + "Podijelite anonimne podatke o korištenju kako biste nam pomogli u otkrivanju problema." + "Možete pročitati sve naše uvjete %1$s ." + "ovdje" + "Dijeljenje analitičkih podataka" + diff --git a/features/analytics/impl/src/main/res/values-hr/translations.xml b/features/analytics/impl/src/main/res/values-hr/translations.xml new file mode 100644 index 00000000000..b85046c7a9a --- /dev/null +++ b/features/analytics/impl/src/main/res/values-hr/translations.xml @@ -0,0 +1,10 @@ + + + "Nećemo bilježiti niti profilirati nikakve osobne podatke" + "Podijelite anonimne podatke o korištenju kako biste nam pomogli u otkrivanju problema." + "Možete pročitati sve naše uvjete %1$s ." + "ovdje" + "Ovo možete isključiti u bilo kojem trenutku" + "Nećemo dijeliti vaše podatke s trećim stranama" + "Pomozite nam poboljšati %1$s" + diff --git a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/spaces/SpaceAnnouncementView.kt b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/spaces/SpaceAnnouncementView.kt index d96de7511fa..3fe6ec4456d 100644 --- a/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/spaces/SpaceAnnouncementView.kt +++ b/features/announcement/impl/src/main/kotlin/io/element/android/features/announcement/impl/spaces/SpaceAnnouncementView.kt @@ -81,7 +81,7 @@ private fun SpaceAnnouncementHeader( showBetaLabel = true, subTitle = stringResource(id = R.string.screen_space_announcement_subtitle), iconStyle = BigIcon.Style.Default( - vectorIcon = CompoundIcons.Space(), + vectorIcon = CompoundIcons.SpaceSolid(), usePrimaryTint = true, ), ) diff --git a/features/announcement/impl/src/main/res/values-hr/translations.xml b/features/announcement/impl/src/main/res/values-hr/translations.xml new file mode 100644 index 00000000000..e78f29f19a1 --- /dev/null +++ b/features/announcement/impl/src/main/res/values-hr/translations.xml @@ -0,0 +1,11 @@ + + + "Pregledajte prostore koje ste stvorili ili kojima ste se pridružili" + "Prihvatite ili odbijte pozivnice za prostore" + "Otkrijte sve sobe kojima se možete pridružiti u svojim prostorima" + "Pridružite se javnim prostorima" + "Napustite sve prostore kojima ste se pridružili" + "Uskoro stiže filtriranje i stvaranje prostora te upravljanje njima." + "Dobrodošli u beta inačicu prostora! S ovom prvom inačicom možete:" + "Predstavljamo prostore" + diff --git a/features/announcement/impl/src/main/res/values-ro/translations.xml b/features/announcement/impl/src/main/res/values-ro/translations.xml index 48fa06fca4e..716f1faeb2a 100644 --- a/features/announcement/impl/src/main/res/values-ro/translations.xml +++ b/features/announcement/impl/src/main/res/values-ro/translations.xml @@ -5,7 +5,7 @@ "Descoperiți toate camerele la care vă puteți alătura în spațiile dumneavoastră." "Alăturați-vă spațiilor publice" "Părăsiți spațiile la care v-ați alăturat." - "Crearea și gestionarea spațiilor vor fi disponibile în curând." + "Filtrarea, crearea și gestionarea spațiilor vor fi disponibile în curând." "Bun venit la versiunea beta a Spațiilor! Cu această primă versiune puteți:" "Vă prezentăm Spații" 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 91fcc2593e2..4183e225314 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 @@ -177,8 +177,8 @@ class DefaultActiveCallManager( suspend fun incomingCallTimedOut(displayMissedCallNotification: Boolean) = mutex.withLock { Timber.tag(tag).d("Incoming call timed out") - val previousActiveCall = activeCall.value ?: return - val notificationData = (previousActiveCall.callState as? CallState.Ringing)?.notificationData ?: return + val previousActiveCall = activeCall.value ?: return@withLock + val notificationData = (previousActiveCall.callState as? CallState.Ringing)?.notificationData ?: return@withLock activeCall.value = null if (activeWakeLock?.isHeld == true) { Timber.tag(tag).d("Releasing partial wakelock after timeout") @@ -196,11 +196,11 @@ class DefaultActiveCallManager( Timber.tag(tag).d("Hung up call: $callType") val currentActiveCall = activeCall.value ?: run { Timber.tag(tag).w("No active call, ignoring hang up") - return + return@withLock } if (currentActiveCall.callType != callType) { Timber.tag(tag).w("Call type $callType does not match the active call type, ignoring") - return + return@withLock } if (currentActiveCall.callState is CallState.Ringing) { // Decline the call diff --git a/features/call/impl/src/main/res/values-hr/translations.xml b/features/call/impl/src/main/res/values-hr/translations.xml new file mode 100644 index 00000000000..e58dc362fa5 --- /dev/null +++ b/features/call/impl/src/main/res/values-hr/translations.xml @@ -0,0 +1,8 @@ + + + "Poziv u tijeku" + "Dodirnite za povratak u poziv" + "☎️ Poziv u tijeku" + "Element Call ne podržava korištenje Bluetooth audiouređaja u ovoj inačici Androida. Odaberite drugi audiouređaj." + "Dolazni Element Call" + diff --git a/features/createroom/api/src/main/kotlin/io/element/android/features/createroom/api/CreateRoomEntryPoint.kt b/features/createroom/api/src/main/kotlin/io/element/android/features/createroom/api/CreateRoomEntryPoint.kt index 1c6a9f04db9..22757aba062 100644 --- a/features/createroom/api/src/main/kotlin/io/element/android/features/createroom/api/CreateRoomEntryPoint.kt +++ b/features/createroom/api/src/main/kotlin/io/element/android/features/createroom/api/CreateRoomEntryPoint.kt @@ -16,6 +16,7 @@ import io.element.android.libraries.matrix.api.core.RoomId interface CreateRoomEntryPoint : FeatureEntryPoint { fun createNode( + isSpace: Boolean, parentNode: Node, buildContext: BuildContext, callback: Callback, diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/CreateRoomFlowNode.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/CreateRoomFlowNode.kt index 7fea6fc0e50..89dbddd1864 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/CreateRoomFlowNode.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/CreateRoomFlowNode.kt @@ -24,6 +24,7 @@ import io.element.android.features.createroom.impl.addpeople.AddPeopleNode import io.element.android.features.createroom.impl.configureroom.ConfigureRoomNode import io.element.android.libraries.architecture.BackstackView import io.element.android.libraries.architecture.BaseFlowNode +import io.element.android.libraries.architecture.NodeInputs import io.element.android.libraries.architecture.callback import io.element.android.libraries.architecture.createNode import io.element.android.libraries.di.SessionScope @@ -37,23 +38,29 @@ class CreateRoomFlowNode( @Assisted plugins: List, ) : BaseFlowNode( backstack = BackStack( - initialElement = NavTarget.ConfigureRoom, + initialElement = NavTarget.ConfigureRoom(isSpace = plugins.filterIsInstance().first().isSpace), savedStateMap = buildContext.savedStateMap, ), buildContext = buildContext, plugins = plugins ) { + @Parcelize + data class Inputs( + val isSpace: Boolean + ) : NodeInputs, Parcelable + private val callback: CreateRoomEntryPoint.Callback = callback() override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { return when (navTarget) { - NavTarget.ConfigureRoom -> { + is NavTarget.ConfigureRoom -> { + val inputs = ConfigureRoomNode.Inputs(isSpace = navTarget.isSpace) val callback = object : ConfigureRoomNode.Callback { override fun onCreateRoomSuccess(roomId: RoomId) { backstack.replace(NavTarget.AddPeople(roomId)) } } - createNode(buildContext, plugins = listOf(callback)) + createNode(buildContext, plugins = listOf(inputs, callback)) } is NavTarget.AddPeople -> { val inputs = AddPeopleNode.Inputs(navTarget.roomId) @@ -74,7 +81,7 @@ class CreateRoomFlowNode( sealed interface NavTarget : Parcelable { @Parcelize - data object ConfigureRoom : NavTarget + data class ConfigureRoom(val isSpace: Boolean) : NavTarget @Parcelize data class AddPeople(val roomId: RoomId) : NavTarget diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/DefaultCreateRoomEntryPoint.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/DefaultCreateRoomEntryPoint.kt index 2261d294cfa..63163e7a281 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/DefaultCreateRoomEntryPoint.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/DefaultCreateRoomEntryPoint.kt @@ -18,10 +18,12 @@ import io.element.android.libraries.di.SessionScope @ContributesBinding(SessionScope::class) class DefaultCreateRoomEntryPoint : CreateRoomEntryPoint { override fun createNode( + isSpace: Boolean, parentNode: Node, buildContext: BuildContext, callback: CreateRoomEntryPoint.Callback, ): Node { - return parentNode.createNode(buildContext, listOf(callback)) + val inputs = CreateRoomFlowNode.Inputs(isSpace) + return parentNode.createNode(buildContext, listOf(inputs, callback)) } } diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomNode.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomNode.kt index 43ceee3594e..e021a7a0f9e 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomNode.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomNode.kt @@ -8,6 +8,7 @@ package io.element.android.features.createroom.impl.configureroom +import android.os.Parcelable import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import com.bumble.appyx.core.lifecycle.subscribe @@ -18,23 +19,35 @@ 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.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.services.analytics.api.AnalyticsService +import kotlinx.parcelize.Parcelize @ContributesNode(SessionScope::class) @AssistedInject class ConfigureRoomNode( @Assisted buildContext: BuildContext, @Assisted plugins: List, - private val presenter: ConfigureRoomPresenter, + presenterFactory: ConfigureRoomPresenter.Factory, private val analyticsService: AnalyticsService, ) : Node(buildContext, plugins = plugins) { interface Callback : Plugin { fun onCreateRoomSuccess(roomId: RoomId) } + @Parcelize + data class Inputs( + val isSpace: Boolean, + ) : NodeInputs, Parcelable + + private val inputs = inputs() + + private val presenter = presenterFactory.create(inputs.isSpace) + init { lifecycle.subscribe( onResume = { 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 7523ac536d8..4609fe3810b 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 @@ -19,7 +19,9 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.core.net.toUri -import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject import im.vector.app.features.analytics.plan.CreatedRoom import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.Presenter @@ -41,7 +43,7 @@ import io.element.android.libraries.matrix.ui.room.address.RoomAddressValidityEf import io.element.android.libraries.mediapickers.api.PickerProvider import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProvider import io.element.android.libraries.mediaupload.api.MediaPreProcessor -import io.element.android.libraries.permissions.api.PermissionsEvents +import io.element.android.libraries.permissions.api.PermissionsEvent import io.element.android.libraries.permissions.api.PermissionsPresenter import io.element.android.services.analytics.api.AnalyticsService import kotlinx.collections.immutable.toImmutableList @@ -50,8 +52,9 @@ import kotlinx.coroutines.launch import timber.log.Timber import kotlin.jvm.optionals.getOrDefault -@Inject +@AssistedInject class ConfigureRoomPresenter( + @Assisted private val isSpace: Boolean, private val dataStore: CreateRoomConfigStore, private val matrixClient: MatrixClient, private val mediaPickerProvider: PickerProvider, @@ -62,13 +65,22 @@ class ConfigureRoomPresenter( private val roomAliasHelper: RoomAliasHelper, private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider, ) : Presenter { + @AssistedFactory + interface Factory { + fun create(isSpace: Boolean): ConfigureRoomPresenter + } + private val cameraPermissionPresenter: PermissionsPresenter = permissionsPresenterFactory.create(android.Manifest.permission.CAMERA) private var pendingPermissionRequest = false + init { + dataStore.setIsSpace(isSpace) + } + @Composable override fun present(): ConfigureRoomState { val cameraPermissionState = cameraPermissionPresenter.present() - val createRoomConfig by dataStore.getCreateRoomConfigFlow().collectAsState(CreateRoomConfig()) + val createRoomConfig by dataStore.getCreateRoomConfigFlow().collectAsState() val homeserverName = remember { matrixClient.userIdServerName() } val isKnockFeatureEnabled by remember { featureFlagService.isFeatureEnabledFlow(FeatureFlags.Knock) @@ -133,7 +145,7 @@ class ConfigureRoomPresenter( cameraPhotoPicker.launch() } else { pendingPermissionRequest = true - cameraPermissionState.eventSink(PermissionsEvents.RequestPermissions) + cameraPermissionState.eventSink(PermissionsEvent.RequestPermissions) } AvatarAction.Remove -> dataStore.setAvatarUri(uri = null) } @@ -175,7 +187,8 @@ class ConfigureRoomPresenter( preset = RoomPreset.PUBLIC_CHAT, invite = config.invites.map { it.userId }, avatar = avatarUrl, - roomAliasName = config.roomVisibility.roomAddress() + roomAliasName = config.roomVisibility.roomAddress(), + isSpace = isSpace, ) } is RoomVisibilityState.Private -> { @@ -190,6 +203,7 @@ class ConfigureRoomPresenter( preset = RoomPreset.PRIVATE_CHAT, invite = config.invites.map { it.userId }, avatar = avatarUrl, + isSpace = isSpace, ) } // TCHAP - Disable PrivateNotEncrypted room, waiting for back implementation diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomStateProvider.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomStateProvider.kt index 7f760b46fba..dc78ed7aab0 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomStateProvider.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomStateProvider.kt @@ -78,6 +78,18 @@ open class ConfigureRoomStateProvider : PreviewParameterProvider Unit, modifier: Modifier = Modifier, ) { + val isSpace = state.config.isSpace val focusManager = LocalFocusManager.current val isAvatarActionsSheetVisible = remember { mutableStateOf(false) } @@ -81,6 +87,7 @@ fun ConfigureRoomView( modifier = modifier.clearFocusOnTap(focusManager), topBar = { ConfigureRoomToolbar( + isSpace = isSpace, isNextActionEnabled = state.isValid, onBackClick = onBackClick, onNextClick = { @@ -96,9 +103,10 @@ fun ConfigureRoomView( .imePadding() .verticalScroll(rememberScrollState()) .consumeWindowInsets(padding), - verticalArrangement = Arrangement.spacedBy(24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), ) { RoomNameWithAvatar( + isSpace = isSpace, modifier = Modifier.padding(horizontal = 16.dp), avatarUri = state.config.avatarUri, roomName = state.config.roomName.orEmpty(), @@ -110,39 +118,37 @@ fun ConfigureRoomView( topic = state.config.topic.orEmpty(), onTopicChange = { state.eventSink(ConfigureRoomEvents.TopicChanged(it)) }, ) - RoomVisibilityOptions( + + RoomVisibilityAndAccessOptions( selected = when (state.config.roomVisibility) { is RoomVisibilityState.Private -> RoomVisibilityItem.Private // TCHAP - Disable PrivateNotEncrypted room, waiting for back implementation // is RoomVisibilityState.PrivateNotEncrypted -> RoomVisibilityItem.PrivateNotEncrypted // TCHAP room type - is RoomVisibilityState.Public -> RoomVisibilityItem.Public + is RoomVisibilityState.Public -> when (state.config.roomVisibility.roomAccess) { + RoomAccess.Knocking -> RoomVisibilityItem.AskToJoin + RoomAccess.Anyone -> RoomVisibilityItem.Public + } }, + isKnockingEnabled = state.isKnockFeatureEnabled, onOptionClick = { focusManager.clearFocus() state.eventSink(ConfigureRoomEvents.RoomVisibilityChanged(it)) }, ) - if (state.config.roomVisibility is RoomVisibilityState.Public && state.isKnockFeatureEnabled) { - RoomAccessOptions( - selected = when (state.config.roomVisibility.roomAccess) { - RoomAccess.Anyone -> RoomAccessItem.Anyone - RoomAccess.Knocking -> RoomAccessItem.AskToJoin - }, - onOptionClick = { - focusManager.clearFocus() - state.eventSink(ConfigureRoomEvents.RoomAccessChanged(it)) - }, - ) - RoomAddressField( - modifier = Modifier.padding(horizontal = 16.dp), - address = state.config.roomVisibility.roomAddress.value, - homeserverName = state.homeserverName, - addressValidity = state.roomAddressValidity, - onAddressChange = { state.eventSink(ConfigureRoomEvents.RoomAddressChanged(it)) }, - label = stringResource(R.string.screen_create_room_room_address_section_title), - supportingText = stringResource(R.string.screen_create_room_room_address_section_footer), - ) - Spacer(Modifier) + + if (state.config.roomVisibility !is RoomVisibilityState.Private) { + Column { + ListSectionHeader(title = stringResource(R.string.screen_create_room_room_address_section_title)) + RoomAddressField( + modifier = Modifier.padding(horizontal = 16.dp), + address = state.config.roomVisibility.roomAddress().getOrNull().orEmpty(), + homeserverName = state.homeserverName, + addressValidity = state.roomAddressValidity, + onAddressChange = { state.eventSink(ConfigureRoomEvents.RoomAddressChanged(it)) }, + label = null, + supportingText = stringResource(R.string.screen_create_room_room_address_section_footer), + ) + } } } } @@ -158,11 +164,11 @@ fun ConfigureRoomView( async = state.createRoomAction, progressDialog = { AsyncActionViewDefaults.ProgressDialog( - progressText = stringResource(CommonStrings.common_creating_room), + progressText = stringResource(if (isSpace) CommonStrings.common_creating_space else CommonStrings.common_creating_room), ) }, onSuccess = { onCreateRoomSuccess(it) }, - errorMessage = { stringResource(R.string.screen_create_room_error_creating_room) }, + errorMessage = { stringResource(if (isSpace) R.string.screen_create_room_error_creating_space else R.string.screen_create_room_error_creating_room) }, onRetry = { state.eventSink(ConfigureRoomEvents.CreateRoom) }, onErrorDismiss = { state.eventSink(ConfigureRoomEvents.CancelCreateRoom) }, ) @@ -175,12 +181,13 @@ fun ConfigureRoomView( @OptIn(ExperimentalMaterial3Api::class) @Composable private fun ConfigureRoomToolbar( + isSpace: Boolean, isNextActionEnabled: Boolean, onBackClick: () -> Unit, onNextClick: () -> Unit, ) { TopAppBar( - titleStr = stringResource(R.string.screen_create_room_title), + titleStr = stringResource(if (isSpace) R.string.screen_create_room_new_space_title else R.string.screen_create_room_new_room_title), navigationIcon = { BackButton(onClick = onBackClick) }, actions = { TextButton( @@ -194,6 +201,7 @@ private fun ConfigureRoomToolbar( @Composable private fun RoomNameWithAvatar( + isSpace: Boolean, avatarUri: String?, roomName: String, onAvatarClick: () -> Unit, @@ -205,25 +213,33 @@ private fun RoomNameWithAvatar( horizontalArrangement = Arrangement.spacedBy(16.dp), verticalAlignment = Alignment.CenterVertically, ) { - val a11yAvatar = stringResource(CommonStrings.a11y_room_avatar) - UnsavedAvatar( - avatarUri = avatarUri, - avatarSize = AvatarSize.EditRoomDetails, - avatarType = AvatarType.Room(), - modifier = Modifier - .clickable( - onClick = onAvatarClick, - onClickLabel = stringResource(CommonStrings.action_open_context_menu), - ) - .clearAndSetSemantics { - contentDescription = a11yAvatar - }, - ) + Box( + modifier = Modifier.padding(end = 8.dp).size(AvatarSize.EditRoomDetails.dp), + contentAlignment = Alignment.Center, + ) { + val avatarState = remember(avatarUri) { + if (avatarUri != null) { + AvatarPickerState.Selected( + avatarData = AvatarData(id = "#", name = null, url = avatarUri, size = AvatarSize.EditRoomDetails), + type = if (isSpace) AvatarType.Space() else AvatarType.Room(), + ) + } else { + val containerSize = 48.dp + val padding = PaddingValues((AvatarSize.EditRoomDetails.dp - containerSize) / 2) + AvatarPickerState.Pick(buttonSize = 48.dp, iconSize = 24.dp, externalPadding = padding) + } + } + AvatarPickerView( + state = avatarState, + onClick = onAvatarClick, + ) + } TextField( - label = stringResource(R.string.screen_create_room_room_name_label), + modifier = Modifier.padding(bottom = 18.dp), + label = stringResource(CommonStrings.common_name), value = roomName, - placeholder = stringResource(CommonStrings.common_room_name_placeholder), + placeholder = stringResource(R.string.screen_create_room_name_placeholder), singleLine = true, onValueChange = onChangeRoomName, ) @@ -242,7 +258,7 @@ private fun RoomTopic( value = topic, onValueChange = onTopicChange, maxLines = 3, - supportingText = stringResource(CommonStrings.common_topic_placeholder), + placeholder = stringResource(R.string.screen_create_room_topic_placeholder), keyboardOptions = KeyboardOptions( capitalization = KeyboardCapitalization.Sentences, ), @@ -258,40 +274,65 @@ private fun ConfigureRoomOptions( Column( modifier = modifier.selectableGroup() ) { - Text( - text = title, - style = ElementTheme.typography.fontBodyLgMedium, - color = ElementTheme.colors.textPrimary, - modifier = Modifier.padding(horizontal = 16.dp), - ) + ListSectionHeader(title = title) content() } } @Composable -private fun RoomVisibilityOptions( +private fun RoomVisibilityAndAccessOptions( selected: RoomVisibilityItem, + isKnockingEnabled: Boolean, onOptionClick: (RoomVisibilityItem) -> Unit, modifier: Modifier = Modifier, ) { ConfigureRoomOptions( + // TCHAP string update +// title = stringResource(R.string.screen_create_room_room_access_section_title), title = stringResource(R.string.tchap_screen_create_room_room_access_encryption_section_title), modifier = modifier, ) { RoomVisibilityItem.entries.forEach { item -> + if (item == RoomVisibilityItem.AskToJoin && !isKnockingEnabled) { + return@forEach + } + val isSelected = item == selected ListItem( leadingContent = ListItemContent.Custom { RoundedIconAtom( size = RoundedIconAtomSize.Big, - resourceId = item.icon, + resourceId = when (item) { + RoomVisibilityItem.Public -> CompoundDrawables.ic_compound_public + RoomVisibilityItem.AskToJoin -> CompoundDrawables.ic_compound_user_add + RoomVisibilityItem.Private -> CompoundDrawables.ic_compound_lock + // TCHAP - Disable PrivateNotEncrypted room, waiting for back implementation +// RoomVisibilityItem.PrivateNotEncrypted -> CompoundDrawables.ic_compound_lock_off + }, tint = if (isSelected) ElementTheme.colors.iconPrimary else ElementTheme.colors.iconSecondary, + backgroundTint = Color.Transparent, ) }, - headlineContent = { Text(text = stringResource(item.title)) }, + headlineContent = { + val title = when (item) { + RoomVisibilityItem.Public -> stringResource(R.string.screen_create_room_public_option_title) + RoomVisibilityItem.AskToJoin -> stringResource(R.string.screen_create_room_room_access_section_knocking_option_title) + RoomVisibilityItem.Private -> stringResource(R.string.screen_create_room_private_option_title) + // TCHAP - Disable PrivateNotEncrypted room, waiting for back implementation +// RoomVisibilityItem.PrivateNotEncrypted -> stringResource(R.string.screen_create_room_private_option_title) + } + Text(text = title) + }, supportingContent = { - val content = stringResource(id = item.description) - Text(text = content) + // TODO handle description of items in a certain space/org + val description = when (item) { + RoomVisibilityItem.Public -> stringResource(R.string.screen_create_room_public_option_short_description) + RoomVisibilityItem.AskToJoin -> stringResource(R.string.screen_create_room_room_access_section_knocking_option_description) + RoomVisibilityItem.Private -> stringResource(R.string.screen_create_room_private_option_description) + // TCHAP - Disable PrivateNotEncrypted room, waiting for back implementation +// RoomVisibilityItem.PrivateNotEncrypted -> stringResource(R.string.tchap_screen_create_room_private_not_encrypted_option_description) + } + Text(text = description) }, trailingContent = ListItemContent.RadioButton(selected = isSelected), onClick = { onOptionClick(item) }, @@ -300,27 +341,6 @@ private fun RoomVisibilityOptions( } } -@Composable -private fun RoomAccessOptions( - selected: RoomAccessItem, - onOptionClick: (RoomAccessItem) -> Unit, - modifier: Modifier = Modifier, -) { - ConfigureRoomOptions( - title = stringResource(R.string.screen_create_room_room_access_section_header), - modifier = modifier, - ) { - RoomAccessItem.entries.forEach { item -> - ListItem( - headlineContent = { Text(text = stringResource(item.title)) }, - supportingContent = { Text(text = stringResource(item.description)) }, - trailingContent = ListItemContent.RadioButton(selected = item == selected), - onClick = { onOptionClick(item) }, - ) - } - } -} - @PreviewWithLargeHeight @Composable internal fun ConfigureRoomViewLightPreview(@PreviewParameter(ConfigureRoomStateProvider::class) state: ConfigureRoomState) = diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/CreateRoomConfig.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/CreateRoomConfig.kt index b9d05a144fe..8355047ddf8 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/CreateRoomConfig.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/CreateRoomConfig.kt @@ -13,6 +13,7 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf data class CreateRoomConfig( + val isSpace: Boolean = false, val roomName: String? = null, val topic: String? = null, val avatarUri: String? = null, diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/CreateRoomConfigStore.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/CreateRoomConfigStore.kt index c6b4e3271fa..1394bbe6866 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/CreateRoomConfigStore.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/CreateRoomConfigStore.kt @@ -74,11 +74,11 @@ class CreateRoomConfigStore( RoomVisibilityItem.Private -> RoomVisibilityState.Private // TCHAP - Disable PrivateNotEncrypted room, waiting for back implementation // RoomVisibilityItem.PrivateNotEncrypted -> RoomVisibilityState.PrivateNotEncrypted // TCHAP room type - RoomVisibilityItem.Public -> { + RoomVisibilityItem.Public, RoomVisibilityItem.AskToJoin -> { val roomAliasName = roomAliasHelper.roomAliasNameFromRoomDisplayName(config.roomName.orEmpty()) RoomVisibilityState.Public( roomAddress = RoomAddress.AutoFilled(roomAliasName), - roomAccess = RoomAccess.Anyone, + roomAccess = if (visibility == RoomVisibilityItem.AskToJoin) RoomAccess.Knocking else RoomAccess.Anyone, ) } } @@ -116,6 +116,12 @@ class CreateRoomConfigStore( } } + fun setIsSpace(isSpace: Boolean) { + createRoomConfigFlow.getAndUpdate { config -> + config.copy(isSpace = isSpace) + } + } + fun clearCachedData() { cachedAvatarUri = null } diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/RoomAccessItem.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/RoomAccessItem.kt index 2d37be91036..9d140b952cb 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/RoomAccessItem.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/RoomAccessItem.kt @@ -8,19 +8,7 @@ package io.element.android.features.createroom.impl.configureroom -import androidx.annotation.StringRes -import io.element.android.features.createroom.impl.R - -enum class RoomAccessItem( - @StringRes val title: Int, - @StringRes val description: Int -) { - Anyone( - title = R.string.screen_create_room_room_access_section_anyone_option_title, - description = R.string.screen_create_room_room_access_section_anyone_option_description, - ), - AskToJoin( - title = R.string.screen_create_room_room_access_section_knocking_option_title, - description = R.string.screen_create_room_room_access_section_knocking_option_description, - ), +enum class RoomAccessItem { + Anyone, + AskToJoin, } diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/RoomVisibilityItem.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/RoomVisibilityItem.kt index fc0eb96f9d1..0f50c8c7002 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/RoomVisibilityItem.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/RoomVisibilityItem.kt @@ -8,32 +8,10 @@ package io.element.android.features.createroom.impl.configureroom -import androidx.annotation.DrawableRes -import androidx.annotation.StringRes -import io.element.android.features.createroom.impl.R -import io.element.android.libraries.designsystem.icons.CompoundDrawables - -enum class RoomVisibilityItem( - @DrawableRes val icon: Int, - @StringRes val title: Int, - @StringRes val description: Int -) { - // TCHAP room type - Private( - icon = CompoundDrawables.ic_compound_lock_solid, - title = R.string.tchap_screen_create_room_private_encrypted_option_title, - description = R.string.tchap_screen_create_room_private_encrypted_option_description, - ), - +enum class RoomVisibilityItem { + Public, + AskToJoin, + Private, // TCHAP - Disable PrivateNotEncrypted room, waiting for back implementation -// PrivateNotEncrypted( -// icon = CompoundDrawables.ic_compound_lock_off, -// title = R.string.screen_create_room_private_option_title, -// description = R.string.tchap_screen_create_room_private_not_encrypted_option_description, -// ), - Public( - icon = CompoundDrawables.ic_compound_public, - title = R.string.screen_create_room_public_option_title, - description = R.string.tchap_screen_create_room_public_option_description, - ) +// PrivateNotEncrypted } diff --git a/features/createroom/impl/src/main/res/values-be/translations.xml b/features/createroom/impl/src/main/res/values-be/translations.xml index f5d6a234e2d..4cc6aee8227 100644 --- a/features/createroom/impl/src/main/res/values-be/translations.xml +++ b/features/createroom/impl/src/main/res/values-be/translations.xml @@ -4,14 +4,10 @@ "Запрасіць карыстальнікаў" "Пры стварэнні пакоя адбылася памылка" "Толькі запрошаныя людзі могуць атрымаць доступ да гэтага пакоя. Усе паведамленні абаронены end-to-end шыфраваннем." - "Прыватны пакой" "Любы можа знайсці гэты пакой. Вы можаце змяніць гэта ў любы час у наладах пакоя." - "Публічны пакой" - "Хто заўгодна" - "Доступ у пакой" + "Публічны пакой (для ўсіх)" "Папрасіце далучыцца" - "Назва пакоя" - "Стварыце пакой" + "Хто заўгодна" "Тэма (неабавязкова)" diff --git a/features/createroom/impl/src/main/res/values-bg/translations.xml b/features/createroom/impl/src/main/res/values-bg/translations.xml index 249058b7af8..4c6bfed0e47 100644 --- a/features/createroom/impl/src/main/res/values-bg/translations.xml +++ b/features/createroom/impl/src/main/res/values-bg/translations.xml @@ -4,15 +4,12 @@ "Поканване на хора" "Възникна грешка при създаването на стаята" "Само поканени хора имат достъп до тази стая. Всички съобщения са шифровани от край до край." - "Частна стая" "Всеки може да намери тази стая. Можете да промените това по всяко време в настройките на стаята." - "Общодостъпна стая" - "Всеки може да се присъедини към тази стая" - "Всеки" + "Публична стая (всеки)" + "Всеки може да се присъедини към тази стая" + "Всеки" "За да бъде тази стая видима в директорията на общодостъпните стаи, ще ви е необходим адрес на стаята." - "Име на стаята" "Видимост на стаята" - "Създаване на стая" "Тема за разговор (незадължително)" 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 e19cfbcf917..39f9d573e02 100644 --- a/features/createroom/impl/src/main/res/values-cs/translations.xml +++ b/features/createroom/impl/src/main/res/values-cs/translations.xml @@ -4,19 +4,15 @@ "Pozvat přátele" "Při vytváření místnosti došlo k chybě" "Do této místnosti mají přístup pouze pozvaní lidé. Všechny zprávy jsou koncově šifrovány." - "Soukromá místnost" "Tuto místnost může najít kdokoli. To můžete kdykoli změnit v nastavení místnosti." "Veřejná místnost" - "Do této místnosti může vstoupit kdokoli" - "Kdokoliv" - "Přístup do místnosti" "Kdokoli může požádat o vstup do místnosti, ale správce nebo moderátor bude muset žádost přijmout" "Požádat o připojení" + "Do této místnosti může vstoupit kdokoli" + "Kdokoliv" "Aby byla tato místnost viditelná v adresáři veřejných místností, budete potřebovat adresu místnosti." "Adresa místnosti" - "Název místnosti" "Viditelnost místnosti" - "Vytvořit místnost" "Téma (nepovinné)" diff --git a/features/createroom/impl/src/main/res/values-cy/translations.xml b/features/createroom/impl/src/main/res/values-cy/translations.xml index 52168014bf8..9ac9e5e3bcd 100644 --- a/features/createroom/impl/src/main/res/values-cy/translations.xml +++ b/features/createroom/impl/src/main/res/values-cy/translations.xml @@ -4,19 +4,15 @@ "Gwahodd pobl" "Bu gwall wrth greu\'r ystafell" "Dim ond pobl wahoddwyd all gael mynediad i\'r ystafell hon. Mae pob neges wedi\'i hamgryptio o\'r dechrau i\'r diwedd." - "Ystafell breifat" "Gall unrhyw un ddod o hyd i\'r ystafell hon. Gallwch newid hyn unrhyw bryd yng ngosodiadau ystafell." "Ystafell gyhoeddus" - "Gall unrhyw un ymuno â\'r ystafell hon" - "Unrhyw un" - "Mynediad i\'r Ystafell" "Gall unrhyw un ofyn am gael ymuno â\'r ystafell ond bydd rhaid i weinyddwr neu gymedrolwr dderbyn y cais" "Gofyn i gael ymuno" + "Gall unrhyw un ymuno â\'r ystafell hon" + "Unrhyw un" "Er mwyn i\'r ystafell hon fod yn weladwy yn y cyfeiriadur ystafelloedd cyhoeddus, bydd angen cyfeiriad ystafell arnoch." "Cyfeiriad yr ystafell" - "Enw\'r ystafell" "Gwelededd yr ystafell" - "Creu ystafell" "Pwnc (dewisol)" diff --git a/features/createroom/impl/src/main/res/values-da/translations.xml b/features/createroom/impl/src/main/res/values-da/translations.xml index 422aae09bd8..13d8b01b627 100644 --- a/features/createroom/impl/src/main/res/values-da/translations.xml +++ b/features/createroom/impl/src/main/res/values-da/translations.xml @@ -4,18 +4,14 @@ "Invitér andre" "Der opstod en fejl ved oprettelsen af rummet" "Kun inviterede personer kan få adgang til dette rum. Alle meddelelser er ende-til-ende krypteret." - "Privat rum" "Alle kan finde dette rum. Du kan ændre dette når som helst i rummets indstillinger." - "Offentligt rum" - "Alle kan deltage i dette rum" - "Enhver" - "Adgang til rummet" "Alle kan bede om at deltage i rummet, men en administrator eller en moderator skal acceptere anmodningen" "Spørg om at deltage" + "Alle kan deltage i dette rum" + "Enhver" "Hvis dette rum skal være synligt i det offentlige register, skal du bruge en rum-adresse." - "Navn på rum" + "Rummets adresse" "Rummets synlighed" - "Opret et rum" "Emne (valgfrit)" diff --git a/features/createroom/impl/src/main/res/values-de/translations.xml b/features/createroom/impl/src/main/res/values-de/translations.xml index 9c48001c92b..55d7a0b66e9 100644 --- a/features/createroom/impl/src/main/res/values-de/translations.xml +++ b/features/createroom/impl/src/main/res/values-de/translations.xml @@ -3,20 +3,26 @@ "Neuer Chat" "Nutzer einladen" "Beim Erstellen des Chats ist ein Fehler aufgetreten" - "Nur eingeladene Personen haben Zutritt zu diesem Chat. Alle Nachrichten sind Ende-zu-Ende verschlüsselt." - "Privater Chat" + "Der Space konnte wegen eines unbekannten Fehlers nicht erstellt werden. Versuch\' es später nochmal." + "Name hinzufügen…" + "Neuer Chat" + "Neuer Space" + "Nur eingeladene Personen haben Zutritt zu diesem Chat." + "Privat" "Jeder kann diesen Chat finden. Du kannst dies jederzeit in den Einstellungen des Chats ändern." - "Öffentlicher Chat" - "Jeder darf diesem Chat beitreten" - "Jeder" - "Chat Zugang" + "Jeder kann beitreten." + "Öffentlicher Chatroom" "Jeder kann den Beitritt zum Chat erbitten, aber ein Admin oder Moderator muss die Anfrage akzeptieren." - "Beitritt beantragen" - "Du benötigst eine Chat-Adresse, damit dieser Chat im öffentlichen Verzeichnis sichtbar ist." - "Chatroom Adresse" - "Chat-Name" + "Anfrage zum Beitritt zulassen" + "Nur eingeladene Personen können beitreten." + "Privat" + "Jeder darf diesem Chat beitreten." + "Jeder" + "Wer hat Zugang" + "Du benötigst eine Adresse, um diesen Chat im öffentlichen Verzeichnis sichtbar zu machen." + "Adresse" " Sichtbarkeit des Chats" - "Chat erstellen" "Thema (optional)" + "Beschreibung hinzufügen…" 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 37ccd49d0ed..3b1c94c4430 100644 --- a/features/createroom/impl/src/main/res/values-el/translations.xml +++ b/features/createroom/impl/src/main/res/values-el/translations.xml @@ -4,19 +4,15 @@ "Πρόσκληση ατόμων" "Προέκυψε σφάλμα κατά τη δημιουργία της αίθουσας" "Μόνο τα άτομα που έχουν προσκληθεί μπορούν να έχουν πρόσβαση σε αυτή την αίθουσα. Όλα τα μηνύματα είναι κρυπτογραφημένα από άκρο σε άκρο." - "Ιδιωτική αίθουσα" "Ο καθένας μπορεί να βρει αυτή την αίθουσα. Αυτό μπορείτε να το αλλάξετε ανά πάσα στιγμή στις ρυθμίσεις της αίθουσας." - "Δημόσια αίθουσα" - "Οποιοσδήποτε μπορεί να συμμετάσχει σε αυτή την αίθουσα" - "Οποιοσδήποτε" - "Πρόσβαση στην Αίθουσα" + "Δημόσιο δωμάτιο" "Οποιοσδήποτε μπορεί να ζητήσει να συμμετάσχει στην αίθουσα, αλλά ένας διαχειριστής ή ένας συντονιστής θα πρέπει να αποδεχτεί το αίτημα" "Αίτημα συμμετοχής" + "Οποιοσδήποτε μπορεί να συμμετάσχει σε αυτή την αίθουσα" + "Οποιοσδήποτε" "Για να είναι ορατή αυτή η αίθουσα στον δημόσιο κατάλογο αιθουσών, θα χρειαστείτε μια διεύθυνση αίθουσας." "Διεύθυνση δωματίου" - "Όνομα αίθουσας" "Ορατότητα αίθουσας" - "Δημιουργία αίθουσας" "Θέμα (προαιρετικό)" diff --git a/features/createroom/impl/src/main/res/values-es/translations.xml b/features/createroom/impl/src/main/res/values-es/translations.xml index 64806977c03..34362f537d3 100644 --- a/features/createroom/impl/src/main/res/values-es/translations.xml +++ b/features/createroom/impl/src/main/res/values-es/translations.xml @@ -4,18 +4,14 @@ "Invitar personas" "Se ha producido un error al crear la sala" "Solo las personas invitadas pueden acceder a esta sala. Todos los mensajes están cifrados de extremo a extremo." - "Sala privada" "Cualquiera puede encontrar esta sala. Puedes cambiar esto en cualquier momento en los ajustes de la sala." - "Sala pública" - "Cualquiera puede unirse a esta sala" - "Cualquiera" - "Acceso a la sala" + "Sala pública (cualquiera)" "Cualquiera puede solicitar unirse a la sala, pero un administrador o un moderador tendrá que aceptar la solicitud" "Solicitud para unirse" + "Cualquiera puede unirse a esta sala" + "Cualquiera" "Para que esta sala sea visible en el directorio de salas públicas, necesitarás una dirección de sala." - "Nombre de la sala" "Visibilidad de la sala" - "Crear una sala" "Tema (opcional)" diff --git a/features/createroom/impl/src/main/res/values-et/translations.xml b/features/createroom/impl/src/main/res/values-et/translations.xml index 6a1d9dc58ae..0527302e0ed 100644 --- a/features/createroom/impl/src/main/res/values-et/translations.xml +++ b/features/createroom/impl/src/main/res/values-et/translations.xml @@ -4,19 +4,15 @@ "Kutsu osalejaid" "Jututoa loomisel tekkis viga" "Ligipääs siia jututuppa on vaid kutse alusel. Kõik sõnumid siin jututoas on läbivalt krüptitud." - "Privaatne jututuba" "Kõik saavad seda jututuba leida. Sa võid seda jututoa seadistustest alati muuta." "Avalik jututuba" - "Kõik võivad selle jututoaga liituda" - "Kõik" - "Ligipääs jututoale" "Kõik võivad paluda selle jututoaga liitumist, kuid peakasutaja või moderaator peavad selle kinnitama" "Küsi võimalust liitumiseks" + "Kõik võivad selle jututoaga liituda" + "Kõik kasutajad" "Selleks, et see jututuba oleks nähtav jututubade avalikus kataloogis, sa vajad jututoa aadressi." "Jututoa aadress" - "Jututoa nimi" "Jututoa nähtavus" - "Loo jututuba" "Teema (kui soovid lisada)" diff --git a/features/createroom/impl/src/main/res/values-eu/translations.xml b/features/createroom/impl/src/main/res/values-eu/translations.xml index 537aa495a5b..7e4fefe08f0 100644 --- a/features/createroom/impl/src/main/res/values-eu/translations.xml +++ b/features/createroom/impl/src/main/res/values-eu/translations.xml @@ -4,16 +4,12 @@ "Gonbidatu jendea" "Errorea gertatu da gela sortzean" "Gonbidatutako jendea soilik sar daiteke gelara. Mezu guztiak daude ertzetik ertzera zifratuta." - "Gela pribatua" "Edonork aurki dezake gela hau. Gelaren ezarpenetan aldatu dezakezu hobespena." "Gela publikoa" - "Edonor sar daiteke gela honetara" - "Edonork" - "Gelarako sarbidea" + "Edonor sar daiteke gela honetara" + "Edonork" "Gelaren helbidea" - "Gelaren izena" "Gelaren ikusgarritasuna" - "Sortu gela" "Mintzagaia (aukerakoa)" diff --git a/features/createroom/impl/src/main/res/values-fa/translations.xml b/features/createroom/impl/src/main/res/values-fa/translations.xml index 09869c76f6c..499b0a24ada 100644 --- a/features/createroom/impl/src/main/res/values-fa/translations.xml +++ b/features/createroom/impl/src/main/res/values-fa/translations.xml @@ -7,14 +7,11 @@ "اتاق خصوصی" "هرکسی می‌تواند اتاق را بیابد. می‌توانید بعداً در تظیمات اتاق عوضش کنید." - "اتاق عمومی" - "هرکسی می‌تواند به این اتاق بپیوندد" - "هرکسی" - "دسترسی اتاق" + "اتاق عمومی (هرکسی)" "درخواست دعوت" + "هرکسی می‌تواند به این اتاق بپیوندد" + "هرکسی" "نشانی اتاق" - "نام اتاق" "نمایانی اتاق" - "ایجاد اتاق" "موضوع (اختیاری)" diff --git a/features/createroom/impl/src/main/res/values-fi/translations.xml b/features/createroom/impl/src/main/res/values-fi/translations.xml index df541d3dee2..e4369839891 100644 --- a/features/createroom/impl/src/main/res/values-fi/translations.xml +++ b/features/createroom/impl/src/main/res/values-fi/translations.xml @@ -4,19 +4,15 @@ "Kutsu henkilöitä" "Huoneen luomisessa tapahtui virhe" "Vain kutsutut henkilöt pääsevät tähän huoneeseen. Kaikki viestit ovat päästä päähän salattuja." - "Yksityinen huone" "Kuka tahansa voi löytää tämän huoneen. Voit muuttaa tämän milloin tahansa huoneen asetuksista." "Julkinen huone" - "Kuka tahansa voi liittyä tähän huoneeseen" - "Kuka tahansa" - "Huoneeseen Pääsy" "Kuka tahansa voi pyytää saada liittyä huoneeseen, mutta ylläpitäjän tai valvojan on hyväksyttävä pyyntö" "Pyydä liittymistä" + "Kuka tahansa voi liittyä tähän huoneeseen" + "Kuka tahansa" "Jotta tämä huone näkyisi julkisessa huonehakemistossa, tarvitset huoneen osoitteen." "Huoneen osoite" - "Huoneen nimi" "Huoneen näkyvyys" - "Luo huone" "Aihe (valinnainen)" diff --git a/features/createroom/impl/src/main/res/values-fr/translations.xml b/features/createroom/impl/src/main/res/values-fr/translations.xml index afbdc919ba7..72b53148939 100644 --- a/features/createroom/impl/src/main/res/values-fr/translations.xml +++ b/features/createroom/impl/src/main/res/values-fr/translations.xml @@ -3,20 +3,26 @@ "Nouveau salon" "Inviter des amis" "Une erreur s’est produite lors de la création du salon" - "Seules les personnes invitées peuvent accéder à ce salon. Tous les messages sont chiffrés de bout en bout." - "Salon privé" + "L’espace n’a pas pu être créé à cause d’une erreur inconnue. Réessayez plus tard." + "Ajouter un nom…" + "Nouveau salon" + "Nouvel espace" + "Seules les personnes invitées peuvent joindre." + "Privé" "N’importe qui peut trouver ce salon. Vous pouvez modifier cela à tout moment dans les paramètres du salon." + "Tout le monde peut joindre" "Salon public" - "Tout le monde peut rejoindre ce salon" - "Tout le monde" - "Accès au salon" - "Tout le monde peut demander à rejoindre le salon, mais un administrateur ou un modérateur devra accepter la demande" - "Demander à rejoindre" - "Pour que ce salon soit visible dans le répertoire des salons publics, vous aurez besoin d’une adresse de salon." - "Adresse du salon" - "Nom du salon" + "Tout le monde peut demander à joindre, mais un administrateur ou un modérateur devra accepter la demande" + "Autoriser la demande à joindre" + "Seules les personnes invitées peuvent joindre." + "Privé" + "Tout le monde peut joindre" + "Tout le monde" + "Qui a accès" + "Vous aurez besoin d’une adresse pour qu’il soit visible dans le répertoire public." + "Adresse" "Visibilité du salon" - "Créer un salon" "Sujet (facultatif)" + "Ajouter une description…" diff --git a/features/createroom/impl/src/main/res/values-hr/translations.xml b/features/createroom/impl/src/main/res/values-hr/translations.xml new file mode 100644 index 00000000000..1a23597ff4b --- /dev/null +++ b/features/createroom/impl/src/main/res/values-hr/translations.xml @@ -0,0 +1,17 @@ + + + "Nova soba" + "Pozovi osobe" + "Došlo je do pogreške prilikom stvaranja sobe" + "Samo pozvane osobe mogu pristupiti ovoj sobi. Sve su poruke sveobuhvatno šifrirane." + "Svatko može pronaći ovu sobu. +To možete u svakom trenutku promijeniti u postavkama sobe." + "Svatko može zatražiti pridruživanje sobi, ali administrator ili moderator morat će prihvatiti zahtjev." + "Zatraži pridruživanje" + "Svatko se može pridružiti ovoj sobi" + "Svatko" + "Da bi ova soba bila vidljiva u javnom direktoriju soba, trebat će vam adresa sobe." + "Adresa sobe" + "Vidljivost sobe" + "Tema (neobavezno)" + diff --git a/features/createroom/impl/src/main/res/values-hu/translations.xml b/features/createroom/impl/src/main/res/values-hu/translations.xml index 24f71983ce2..2e80833b2e4 100644 --- a/features/createroom/impl/src/main/res/values-hu/translations.xml +++ b/features/createroom/impl/src/main/res/values-hu/translations.xml @@ -4,19 +4,15 @@ "Ismerősök meghívása" "Hiba történt a szoba létrehozásakor" "Csak a meghívottak léphetnek be ebbe a szobába. Az összes üzenet végpontok közti titkosítással van védve." - "Privát szoba" "Bárki megtalálhatja ezt a szobát. Ezt bármikor módosíthatja a szobabeállításokban." "Nyilvános szoba" - "Bárki csatlakozhat ehhez a szobához" - "Bárki" - "Szobahozzáférés" "Bárki kérheti, hogy csatlakozzon a szobához, de egy adminisztrátornak vagy moderátornak el kell fogadnia a kérést" "Csatlakozás kérése" + "Bárki csatlakozhat ehhez a szobához" + "Bárki" "Ahhoz, hogy ez a szoba látható legyen a nyilvános szobák címtárában, meg kell adnia a szoba címét." "Szoba címe" - "Szoba neve" "Szoba láthatósága" - "Szoba létrehozása" "Téma (nem kötelező)" diff --git a/features/createroom/impl/src/main/res/values-in/translations.xml b/features/createroom/impl/src/main/res/values-in/translations.xml index 219f621068f..20f23fd5c94 100644 --- a/features/createroom/impl/src/main/res/values-in/translations.xml +++ b/features/createroom/impl/src/main/res/values-in/translations.xml @@ -4,19 +4,15 @@ "Undang orang-orang" "Terjadi kesalahan saat membuat ruangan" "Hanya orang-orang yang diundang dapat mengakses ruangan ini. Semua pesan terenkripsi secara ujung ke ujung." - "Ruangan pribadi" "Siapa pun dapat mencari ruangan ini. Anda dapat mengubah ini kapan pun dalam pengaturan ruangan." "Ruangan publik" - "Siapa pun dapat bergabung dengan ruangan ini" - "Siapa pun" - "Akses Ruangan" "Siapa pun dapat meminta untuk bergabung dengan ruangan tetapi administrator atau moderator harus menerima permintaan tersebut" "Minta untuk bergabung" + "Siapa pun dapat bergabung dengan ruangan ini" + "Siapa pun" "Supaya ruangan ini terlihat di direktori ruangan publik, Anda memerlukan alamat ruangan." "Alamat ruangan" - "Nama ruangan" "Keterlihatan ruangan" - "Buat ruangan" "Topik (opsional)" diff --git a/features/createroom/impl/src/main/res/values-it/translations.xml b/features/createroom/impl/src/main/res/values-it/translations.xml index 741b88b7635..03d4c140fc3 100644 --- a/features/createroom/impl/src/main/res/values-it/translations.xml +++ b/features/createroom/impl/src/main/res/values-it/translations.xml @@ -4,19 +4,15 @@ "Invita persone" "Si è verificato un errore durante la creazione della stanza" "Solo le persone invitate possono accedere a questa stanza. Tutti i messaggi sono cifrati end-to-end." - "Stanza privata" "Chiunque può trovare questa stanza. Puoi modificarlo in qualsiasi momento nelle impostazioni della stanza." "Stanza pubblica" - "Chiunque può entrare in questa stanza" - "Chiunque" - "Accesso alla stanza" "Chiunque può chiedere di entrare nella stanza, ma un amministratore o un moderatore dovrà accettare la richiesta" "Chiedi di entrare" + "Chiunque può entrare in questa stanza" + "Chiunque" "Affinché questa stanza sia visibile nell\'elenco delle stanze pubbliche, è necessario un indirizzo della stanza." "Indirizzo della stanza" - "Nome stanza" "Visibilità della stanza" - "Crea una stanza" "Argomento (facoltativo)" diff --git a/features/createroom/impl/src/main/res/values-ka/translations.xml b/features/createroom/impl/src/main/res/values-ka/translations.xml index 20c7af40de1..22fc67afced 100644 --- a/features/createroom/impl/src/main/res/values-ka/translations.xml +++ b/features/createroom/impl/src/main/res/values-ka/translations.xml @@ -4,10 +4,8 @@ "ხალხის მოწვევა" "ოთახის შექმნისას შეცდომა მოხდა" "ამ ოთახში შეტყობინებები დაშიფრულია. შემდგომ დაშიფვრის გამორთვა შეუძლებელია." - "კერძო ოთახი" "ყველას ამ ოთახის მოძებნა შეუძლია. თქვენ ნებისმიერ დროს შეგიძლიათ ამის შეცვლა ოთახის პარამეტრებში." - "ოთახის სახელი" - "ოთახის შექმნა" + "საჯარო ოთახი" "თემა (სურვილისამებრ)" diff --git a/features/createroom/impl/src/main/res/values-ko/translations.xml b/features/createroom/impl/src/main/res/values-ko/translations.xml index 3dfdc46e7cd..b1cd90bbf19 100644 --- a/features/createroom/impl/src/main/res/values-ko/translations.xml +++ b/features/createroom/impl/src/main/res/values-ko/translations.xml @@ -4,18 +4,14 @@ "사람 초대하기" "방을 생성하던 중 오류가 발생했어요" "초대받은 사람만 이 방에 액세스할 수 있습니다. 모든 메시지는 종단 간 암호화됩니다." - "비공개 방" "누구나 이 방을 찾을 수 있습니다. 방 설정에서 언제든지 변경할 수 있습니다." - "공개 방" - "누구나 이 방에 참여할 수 있습니다." - "누구나" - "방 액세스" + "공개 방 (모두)" "누구나 방에 참여 요청을 할 수 있지만, 관리자나 운영자가 요청을 수락해야 합니다." "참가 요청" + "누구나 이 방에 참여할 수 있습니다." + "누구나" "이 방이 공개 방 디렉토리에 표시되려면 방 주소가 필요합니다." - "방 이름" "방 표시 여부" - "방 만들기" "주제 (선택)" diff --git a/features/createroom/impl/src/main/res/values-lt/translations.xml b/features/createroom/impl/src/main/res/values-lt/translations.xml index 2fb7b3eb5c4..3d5566cb151 100644 --- a/features/createroom/impl/src/main/res/values-lt/translations.xml +++ b/features/createroom/impl/src/main/res/values-lt/translations.xml @@ -4,10 +4,7 @@ "Pakviesti žmonių" "Kuriant kambarį įvyko klaida" "Į šį kambarį gali patekti tik pakviesti žmonės. Visi pranešimai yra užšifruoti nuo pradžios iki galo." - "Privatus kambarys" "Bet kas gali rasti šį kambarį. Tai galite bet kada pakeisti kambario nustatymuose." - "Kambario pavadinimas" - "Kurti kambarį" "Tema (nebūtina)" diff --git a/features/createroom/impl/src/main/res/values-nb/translations.xml b/features/createroom/impl/src/main/res/values-nb/translations.xml index 9a159811124..e5ad63c84b8 100644 --- a/features/createroom/impl/src/main/res/values-nb/translations.xml +++ b/features/createroom/impl/src/main/res/values-nb/translations.xml @@ -4,18 +4,15 @@ "Inviter folk" "Det oppsto en feil under opprettelsen av rommet" "Bare inviterte personer har tilgang til dette rommet. Alle meldinger er ende-til-ende-kryptert." - "Privat rom" "Alle kan finne dette rommet. Du kan endre dette når som helst i rominnstillingene." "Offentlig rom" - "Alle kan bli med i dette rommet" - "Alle" - "Tilgang til rom" "Alle kan be om å få bli med i rommet, men en administrator eller moderator må godta forespørselen" "Be om å bli med" + "Alle kan bli med i dette rommet" + "Alle" "For at dette rommet skal være synlig i den offentlige romkatalogen, trenger du en romadresse." - "Romnavn" + "Romadresse" "Romsynlighet" - "Opprett et rom" "Emne (valgfritt)" diff --git a/features/createroom/impl/src/main/res/values-nl/translations.xml b/features/createroom/impl/src/main/res/values-nl/translations.xml index 5142148171f..4691cb1f198 100644 --- a/features/createroom/impl/src/main/res/values-nl/translations.xml +++ b/features/createroom/impl/src/main/res/values-nl/translations.xml @@ -4,16 +4,12 @@ "Mensen uitnodigen" "Er is een fout opgetreden bij het aanmaken van de kamer" "Alleen uitgenodigde personen hebben toegang tot deze kamer. Alle berichten zijn end-to-end versleuteld." - "Privé kamer" "Iedereen kan deze kamer vinden. Je kunt dit op elk gewenst moment wijzigen in de kamerinstellingen." "Openbare kamer" - "Iedereen kan toetreden tot deze kamer" - "Iedereen" - "Toegang tot de kamer" "Iedereen kan vragen om toe te treden tot de kamer, maar een beheerder of moderator moet het verzoek accepteren" "Vraag om toe te treden" - "Naam van de kamer" - "Creëer een kamer" + "Iedereen kan toetreden tot deze kamer" + "Iedereen" "Onderwerp (optioneel)" diff --git a/features/createroom/impl/src/main/res/values-pl/translations.xml b/features/createroom/impl/src/main/res/values-pl/translations.xml index 446644b6222..325d40cf5bb 100644 --- a/features/createroom/impl/src/main/res/values-pl/translations.xml +++ b/features/createroom/impl/src/main/res/values-pl/translations.xml @@ -4,19 +4,15 @@ "Zaproś znajomych" "Wystąpił błąd w trakcie tworzenia pokoju" "Tylko zaproszone osoby mogą dołączyć do tego pokoju. Wszystkie wiadomości są szyfrowane end-to-end." - "Pokój prywatny" "Każdy może znaleźć ten pokój. Możesz to zmienić w ustawieniach pokoju." "Pokój publiczny" - "Każdy może dołączyć do tego pokoju" - "Wszyscy" - "Dostęp do pokoju" "Każdy może poprosić o dołączenie do pokoju, ale administrator lub moderator będzie musiał zatwierdzić prośbę" "Poproś o dołączenie" + "Każdy może dołączyć do tego pokoju" + "Wszyscy" "Aby ten pokój był widoczny w katalogu pomieszczeń publicznych, będziesz potrzebował adres pokoju." "Adres pokoju" - "Nazwa pokoju" "Widoczność pomieszczenia" - "Utwórz pokój" "Temat (opcjonalnie)" diff --git a/features/createroom/impl/src/main/res/values-pt-rBR/translations.xml b/features/createroom/impl/src/main/res/values-pt-rBR/translations.xml index 399c9fec170..174f42db06a 100644 --- a/features/createroom/impl/src/main/res/values-pt-rBR/translations.xml +++ b/features/createroom/impl/src/main/res/values-pt-rBR/translations.xml @@ -3,20 +3,21 @@ "Nova sala" "Convidar pessoas" "Ocorreu um erro ao criar a sala" - "Apenas as pessoas convidadas podem entrar nesta sala. Todas as mensagens são criptografadas de ponta a ponta." - "Sala privada" + "O espaço não pôde ser criado por conta de um erro desconhecido. Tente novamente mais tarde." + "Novo espaço" + "Apenas pessoas convidadas podem entrar." + "Privada" "Qualquer um pode encontrar esta sala. Você pode mudar isso a qualquer momento nas configurações da sala." - "Sala pública" - "Qualquer pessoa pode entrar nesta sala" - "Qualquer pessoa" - "Acesso à sala" - "Qualquer pessoa pode pedir para entrar na sala, mas um administrador ou moderador terá de aceitar a solicitação" + "Qualquer um pode entrar." + "Publica" + "Qualquer um pode pedir para entrar, mas um administrador ou moderador deve aceitar a solicitação" "Pedir para entrar" + "Qualquer um pode entrar." + "Qualquer pessoa" + "Quem tem acesso" "Para que esta sala fique visível no diretório público de salas, você precisará de um endereço de sala." "Endereço da sala" - "Nome da sala" "Visibilidade da sala" - "Criar uma sala" "Tópico (opcional)" diff --git a/features/createroom/impl/src/main/res/values-pt/translations.xml b/features/createroom/impl/src/main/res/values-pt/translations.xml index 1524914bb2a..6225f531a5d 100644 --- a/features/createroom/impl/src/main/res/values-pt/translations.xml +++ b/features/createroom/impl/src/main/res/values-pt/translations.xml @@ -4,19 +4,15 @@ "Convidar pessoas" "Ocorreu um erro ao criar a sala" "Apenas as pessoas convidadas podem aceder a esta sala. Todas as mensagens são cifradas ponta-a-ponta." - "Sala privada" "Qualquer um pode encontrar esta sala. Pode alterar esta opção nas definições da sala." "Sala pública" - "Qualquer pessoa pode entrar nesta sala" - "Qualquer pessoa" - "Acesso à sala" "Qualquer pessoa pode pedir para entrar na sala, mas um administrador ou um moderador terá de aceitar o pedido" "Pedir para participar" + "Qualquer pessoa pode entrar nesta sala" + "Qualquer pessoa" "Para que esta sala seja visível no diretório público de salas, precisas de um endereço de sala." "Endereço da sala" - "Nome da sala" "Visibilidade da sala" - "Criar uma sala" "Descrição (opcional)" 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 b9fe78bb198..01ce6bfa38e 100644 --- a/features/createroom/impl/src/main/res/values-ro/translations.xml +++ b/features/createroom/impl/src/main/res/values-ro/translations.xml @@ -4,19 +4,15 @@ "Invitați prieteni" "A apărut o eroare la crearea camerei" "Doar persoanele invitate pot accesa această cameră. Toate mesajele sunt criptate end-to-end." - "Cameră privată" "Oricine poate găsi această cameră. Puteți modifica acest lucru oricând în setări." "Cameră publică" - "Oricine se poate alătura acestei camere" - "Oricine" - "Acces la cameră" "Oricine poate cere să se alăture camerei, dar un administrator sau un moderator va trebui să accepte cererea" "Cereți să vă alăturați" + "Oricine se poate alătura acestei camere" + "Oricine" "Pentru ca această cameră să fie vizibilă în directorul de camere publice, veți avea nevoie de o adresă de cameră." "Adresa camerei" - "Numele camerei" "Vizibilitatea camerei" - "Creați o cameră" "Subiect (opțional)" diff --git a/features/createroom/impl/src/main/res/values-ru/translations.xml b/features/createroom/impl/src/main/res/values-ru/translations.xml index e8716731148..4a009eaa104 100644 --- a/features/createroom/impl/src/main/res/values-ru/translations.xml +++ b/features/createroom/impl/src/main/res/values-ru/translations.xml @@ -4,19 +4,15 @@ "Пригласить в комнату" "Произошла ошибка при создании комнаты" "Доступ в эту комнату имеют только приглашенные пользователи. Все сообщения защищены сквозным шифрованием." - "Частная комната" "Любой желающий может найти эту комнату. Вы можете изменить это в любое время в настройках комнаты." "Общедоступная комната" - "Любой желающий может присоединиться к этой комнате" - "Любой" - "Доступ в комнату" "Любой желающий может подать заявку на присоединение к комнате, но администратор или модератор должен будет принять запрос." "Попросить присоединиться" + "Любой желающий может присоединиться к этой комнате" + "Любой" "Чтобы эта комната была видна в каталоге общедоступных, вам необходим ее адрес" "Адрес комнаты" - "Название комнаты" "Видимость комнаты" - "Создать комнату" "Тема (необязательно)" diff --git a/features/createroom/impl/src/main/res/values-sk/translations.xml b/features/createroom/impl/src/main/res/values-sk/translations.xml index 7b6d89b2e1d..555571fba9e 100644 --- a/features/createroom/impl/src/main/res/values-sk/translations.xml +++ b/features/createroom/impl/src/main/res/values-sk/translations.xml @@ -4,19 +4,15 @@ "Pozvať ľudí" "Pri vytváraní miestnosti došlo k chybe" "Do tejto miestnosti majú prístup iba pozvaní ľudia. Všetky správy sú end-to-end šifrované." - "Súkromná miestnosť" "Túto miestnosť môže nájsť ktokoľvek. Môžete to kedykoľvek zmeniť v nastaveniach miestnosti." "Verejná miestnosť" - "Do tejto miestnosti sa môže pripojiť ktokoľvek" - "Ktokoľvek" - "Prístup do miestnosti" "Ktokoľvek môže požiadať o pripojenie sa k miestnosti, ale administrátor alebo moderátor bude musieť žiadosť schváliť" "Požiadať o pripojenie" + "Do tejto miestnosti sa môže pripojiť ktokoľvek" + "Ktokoľvek" "Aby bola táto miestnosť viditeľná v adresári verejných miestností, budete potrebovať adresu miestnosti." "Adresa miestnosti" - "Názov miestnosti" "Viditeľnosť miestnosti" - "Vytvoriť miestnosť" "Téma (voliteľné)" diff --git a/features/createroom/impl/src/main/res/values-sv/translations.xml b/features/createroom/impl/src/main/res/values-sv/translations.xml index 8cd01ebd0ef..779bd893eac 100644 --- a/features/createroom/impl/src/main/res/values-sv/translations.xml +++ b/features/createroom/impl/src/main/res/values-sv/translations.xml @@ -4,19 +4,15 @@ "Bjud in personer" "Ett fel uppstod när rummet skapades" "Endast inbjudna personer har tillgång till detta rum. Alla meddelanden är totalsträckskrypterade." - "Privat rum" "Vem som helst kan hitta det här rummet. Du kan ändra detta när som helst i rumsinställningarna." "Offentligt rum" - "Vem som helst kan gå med i det här rummet" - "Vem som helst" - "Rumsåtkomst" "Vem som helst kan be om att gå med i rummet men en administratör eller en moderator måste acceptera begäran" "Be om att gå med" + "Vem som helst kan gå med i det här rummet" + "Vem som helst" "För att detta rum ska vara synligt i den allmänna rumskatalogen behöver du en rumsadress." "Rumsadress" - "Rumsnamn" "Rumssynlighet" - "Skapa ett rum" "Ämne (valfritt)" diff --git a/features/createroom/impl/src/main/res/values-tr/translations.xml b/features/createroom/impl/src/main/res/values-tr/translations.xml index d97139c973e..34406bb0fd8 100644 --- a/features/createroom/impl/src/main/res/values-tr/translations.xml +++ b/features/createroom/impl/src/main/res/values-tr/translations.xml @@ -4,19 +4,15 @@ "İnsanları davet et" "Oda oluşturulurken bir hata oluştu" "Bu odaya yalnızca davet edilen kişiler erişebilir. Tüm mesajlar uçtan uca şifrelenir." - "Özel oda" "Bu odayı herkes bulabilir. Bunu istediğiniz zaman oda ayarlarından değiştirebilirsiniz." "Herkese açık oda" - "Bu odaya herkes katılabilir" - "Herkes" - "Oda Erişimi" "Herkes odaya katılmayı isteyebilir ancak bir yönetici veya moderatörün isteği kabul etmesi gerekecektir" "Katılmak için sor" + "Bu odaya herkes katılabilir" + "Herkes" "Bu odanın genel oda dizininde görünür olması için bir oda adresine ihtiyacınız olacaktır." "Oda adresi" - "Oda adı" "Oda görünürlüğü" - "Bir oda oluştur" "Konu (isteğe bağlı)" diff --git a/features/createroom/impl/src/main/res/values-uk/translations.xml b/features/createroom/impl/src/main/res/values-uk/translations.xml index 047b4dd9d24..e665d5c2f36 100644 --- a/features/createroom/impl/src/main/res/values-uk/translations.xml +++ b/features/createroom/impl/src/main/res/values-uk/translations.xml @@ -4,19 +4,15 @@ "Запросити людей" "Під час створення кімнати сталася помилка" "Лише запрошені люди мають доступ до цієї кімнати. Усі повідомлення захищені наскрізним шифруванням." - "Приватна кімната (тільки за запрошенням)" "Будь-хто може знайти цю кімнату. Ви можете змінити це в будь-який час у налаштуваннях кімнати." - "Загальнодоступна кімната" - "Будь-хто може приєднатися до цієї кімнати" - "Кожний" - "Доступ до кімнати" + "Публічна кімната" "Будь-хто може попросити приєднатися до кімнати, але адміністратор або модератор повинен буде прийняти запит" "Запросити приєднатися" + "Будь-хто може приєднатися до цієї кімнати" + "Кожний" "Щоб цю кімнату було видно в каталозі загальнодоступних кімнат, вам знадобиться її адреса." "Адреса кімнати" - "Назва кімнати" "Видимість кімнати" - "Створити кімнату" "Тема (необов\'язково)" diff --git a/features/createroom/impl/src/main/res/values-ur/translations.xml b/features/createroom/impl/src/main/res/values-ur/translations.xml index b68992085fe..ce1e23ffc9d 100644 --- a/features/createroom/impl/src/main/res/values-ur/translations.xml +++ b/features/createroom/impl/src/main/res/values-ur/translations.xml @@ -4,11 +4,7 @@ "لوگوں کو مدعو کریں" "کمرہ تخلیق کرتے ہوئے ایک نقص واقع ہوا" "صرف مدعو لوگ ہی اس کمرے تک رسائی حاصل کر سکتے ہیں۔ تمام پیغامات آخر تا آخر مرموز کردہ ہیں۔" - "نجی کمرہ" "کوئی بھی یہ کمرہ ڈھونڈ سکتا ہے۔ آپ اسے کمرے کی ترتیبات میں کسی بھی وقت تبدیل کرسکتے ہیں۔" - "عوامی کمرہ" - "کمرے کا نام" - "ایک کمرہ بنائیں" "موضوع (اختیاری)" diff --git a/features/createroom/impl/src/main/res/values-uz/translations.xml b/features/createroom/impl/src/main/res/values-uz/translations.xml index 34062f96699..46905b5f1d5 100644 --- a/features/createroom/impl/src/main/res/values-uz/translations.xml +++ b/features/createroom/impl/src/main/res/values-uz/translations.xml @@ -4,18 +4,14 @@ "Odamlarni taklif qiling" "Xonani yaratishda xatolik yuz berdi" "Faqat taklif etilgan shaxslargina bu xonaga kira oladi. Barcha xabarlar boshdan-oxirigacha shifrlanadi." - "Shaxsiy xona" "Bu xonani har kim topishi mumkin. Buni xona sozlamalaridan istalgan vaqtda oʻzgartirishingiz mumkin." - "Jamoat xonasi" - "Bu xonaga istalgan kishi qo‘shilishi mumkin" - "Har kim" - "Xonaga kirish" + "Jamoat xonasi (har kim)" "Xonaga qo‘shilishni istalgan kishi so‘rashi mumkin, lekin administrator yoki moderator so‘rovni qabul qilishi kerak" "Qo‘shilishni so‘rang" + "Bu xonaga istalgan kishi qo‘shilishi mumkin" + "Har kim" "Ushbu xona ommaviy xonalar ro‘yxatida ko‘rinishi uchun sizga xona manzili kerak bo‘ladi." - "Xona nomi" "Xonaning ko‘rinishi" - "Xonani yaratish" "Mavzu (ixtiyoriy)" 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 476f9cff7eb..595b06d55e2 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 @@ -4,19 +4,15 @@ "邀請夥伴" "建立聊天室時發生錯誤" "僅被邀請的人才能存取此聊天室。所有訊息均會端到端加密。" - "私密聊天室" "任何人都可以找到此聊天室。 您隨時都可以在聊天室設定中變更此設定。" - "公開的聊天室" - "任何人都可以加入此聊天室" - "任何人" - "聊天室存取權" + "公開聊天室" "任何人都可以要求加入聊天室,但管理員或版主必須接受該請求" "要求加入" + "任何人都可以加入此聊天室" + "任何人" "為了讓此聊天室在公開聊天室目錄中可見,您需要聊天室地址。" "聊天室地址" - "聊天室名稱" "聊天室能見度" - "建立聊天室" "主題(非必填)" 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 d20e34801d0..1a189884cfd 100644 --- a/features/createroom/impl/src/main/res/values-zh/translations.xml +++ b/features/createroom/impl/src/main/res/values-zh/translations.xml @@ -4,19 +4,15 @@ "邀请朋友" "创建聊天室时出错" "只有受邀用户才能访问此聊天室。所有消息均经过端到端加密。" - "私有聊天室" "任何人都能找到此聊天室。 你可以随时在聊天室设置中更改。" - "公共聊天室" - "任何人都可以加入此房间" - "任何人" - "房间访问权限" + "公开聊天室" "任何人都可以请求加入房间,但必须由管理员或审核人接受" "请求加入" + "任何人都可以加入此房间" + "任何人" "要使该房间在公开房间目录中可见,您需要一个房间地址。" "房间地址" - "聊天室名称" "房间可见性" - "创建聊天室" "主题(可选)" diff --git a/features/createroom/impl/src/main/res/values/localazy.xml b/features/createroom/impl/src/main/res/values/localazy.xml index fa9a1cb2760..3ec2d9d6f32 100644 --- a/features/createroom/impl/src/main/res/values/localazy.xml +++ b/features/createroom/impl/src/main/res/values/localazy.xml @@ -3,20 +3,26 @@ "New room" "Invite people" "An error occurred when creating the room" - "Only people invited can access this room. All messages are end-to-end encrypted." - "Private room" + "The space could not be created because of an unknown error. Try again later." + "Add name…" + "New room" + "New space" + "Only people invited can join." + "Private" "Anyone can find this room. You can change this anytime in room settings." - "Public room" - "Anyone can join this room" - "Anyone" - "Room Access" - "Anyone can ask to join the room but an administrator or a moderator will have to accept the request" - "Ask to join" - "In order for this room to be visible in the public room directory, you will need a room address." - "Room address" - "Room name" + "Anyone can join." + "Public" + "Anyone can ask to join but an administrator or a moderator must accept the request." + "Allow ask to join" + "Only people invited can join." + "Private" + "Anyone can join." + "Public" + "Who has access" + "You’ll need an address in order to make it visible in the public directory." + "Address" "Room visibility" - "Create a room" "Topic (optional)" + "Add description…" diff --git a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/DefaultCreateRoomEntryPointTest.kt b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/DefaultCreateRoomEntryPointTest.kt index 35b6637bbf6..5b7a6c1142c 100644 --- a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/DefaultCreateRoomEntryPointTest.kt +++ b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/DefaultCreateRoomEntryPointTest.kt @@ -40,6 +40,7 @@ class DefaultCreateRoomEntryPointTest { override fun onRoomCreated(roomId: RoomId) = lambdaError() } val result = entryPoint.createNode( + isSpace = false, parentNode = parentNode, buildContext = BuildContext.root(null), callback = callback, diff --git a/features/createroom/impl/src/test/kotlin/io/element/android/features/startchat/impl/configureroom/ConfigureRoomPresenterTest.kt b/features/createroom/impl/src/test/kotlin/io/element/android/features/startchat/impl/configureroom/ConfigureRoomPresenterTest.kt index c8a6c2bd8a9..e82d8c39129 100644 --- a/features/createroom/impl/src/test/kotlin/io/element/android/features/startchat/impl/configureroom/ConfigureRoomPresenterTest.kt +++ b/features/createroom/impl/src/test/kotlin/io/element/android/features/startchat/impl/configureroom/ConfigureRoomPresenterTest.kt @@ -51,15 +51,10 @@ 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.test -import io.mockk.every import io.mockk.mockk -import io.mockk.mockkStatic -import io.mockk.unmockkAll import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest -import org.junit.After -import org.junit.Before import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith @@ -76,17 +71,6 @@ class ConfigureRoomPresenterTest { @get:Rule val warmUpRule = WarmUpRule() - @Before - fun setup() { - mockkStatic(File::readBytes) - every { any().readBytes() } returns byteArrayOf() - } - - @After - fun tearDown() { - unmockkAll() - } - @Test fun `present - initial state`() = runTest { val presenter = createConfigureRoomPresenter() @@ -261,20 +245,25 @@ class ConfigureRoomPresenterTest { val initialState = initialState() dataStore.setAvatarUri(Uri.parse(AN_URI_FROM_GALLERY)) skipItems(1) - mediaPreProcessor.givenResult(Result.success(MediaUploadInfo.Image(mockk(), mockk(), mockk()))) - matrixClient.givenUploadMediaResult(Result.failure(AN_EXCEPTION)) - - initialState.eventSink(ConfigureRoomEvents.CreateRoom) - assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Loading::class.java) - val stateAfterCreateRoom = awaitItem() - assertThat(stateAfterCreateRoom.createRoomAction).isInstanceOf(AsyncAction.Failure::class.java) - assertThat(analyticsService.capturedEvents.filterIsInstance()).isEmpty() - - matrixClient.givenUploadMediaResult(Result.success(AN_AVATAR_URL)) - stateAfterCreateRoom.eventSink(ConfigureRoomEvents.CreateRoom) - assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Uninitialized::class.java) - assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Loading::class.java) - assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Success::class.java) + val file = File.createTempFile("test", "jpg") + try { + mediaPreProcessor.givenResult(Result.success(MediaUploadInfo.Image(file, mockk(), mockk()))) + matrixClient.givenUploadMediaResult(Result.failure(AN_EXCEPTION)) + + initialState.eventSink(ConfigureRoomEvents.CreateRoom) + assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Loading::class.java) + val stateAfterCreateRoom = awaitItem() + assertThat(stateAfterCreateRoom.createRoomAction).isInstanceOf(AsyncAction.Failure::class.java) + assertThat(analyticsService.capturedEvents.filterIsInstance()).isEmpty() + + matrixClient.givenUploadMediaResult(Result.success(AN_AVATAR_URL)) + stateAfterCreateRoom.eventSink(ConfigureRoomEvents.CreateRoom) + assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Uninitialized::class.java) + assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Loading::class.java) + assertThat(awaitItem().createRoomAction).isInstanceOf(AsyncAction.Success::class.java) + } finally { + file.delete() + } } } @@ -391,6 +380,7 @@ class ConfigureRoomPresenterTest { ) private fun createConfigureRoomPresenter( + isSpace: Boolean = false, roomAliasHelper: RoomAliasHelper = FakeRoomAliasHelper(), dataStore: CreateRoomConfigStore = CreateRoomConfigStore(roomAliasHelper), matrixClient: MatrixClient = createMatrixClient(), @@ -401,6 +391,7 @@ class ConfigureRoomPresenterTest { isKnockFeatureEnabled: Boolean = true, mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(), ) = ConfigureRoomPresenter( + isSpace = isSpace, dataStore = dataStore, matrixClient = matrixClient, mediaPickerProvider = pickerProvider, 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/api/FakeCreateRoomEntryPoint.kt index 2beaecf0135..bbeb69c26ba 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/api/FakeCreateRoomEntryPoint.kt @@ -14,6 +14,7 @@ import io.element.android.tests.testutils.lambda.lambdaError class FakeCreateRoomEntryPoint : CreateRoomEntryPoint { override fun createNode( + isSpace: Boolean, parentNode: Node, buildContext: BuildContext, callback: CreateRoomEntryPoint.Callback, diff --git a/features/deactivation/impl/src/main/res/values-hr/translations.xml b/features/deactivation/impl/src/main/res/values-hr/translations.xml new file mode 100644 index 00000000000..04148fdc482 --- /dev/null +++ b/features/deactivation/impl/src/main/res/values-hr/translations.xml @@ -0,0 +1,14 @@ + + + "Potvrdite da želite deaktivirati svoj račun. Ova se radnja ne može poništiti." + "Izbriši sve moje poruke" + "Upozorenje: budući korisnici mogu vidjeti nepotpune razgovore." + "Deaktiviranje vašeg računa je %1$s, to će:" + "nepovratno" + "%1$s vaš račun (ne možete se ponovno prijaviti i vaš ID se ne može ponovno upotrijebiti)." + "Trajno onemogući" + "Ukloniti vas iz svih soba za razgovore." + "Izbrisati podatke o vašem računu s našeg poslužitelja identiteta." + "Vaše će poruke i dalje biti vidljive registriranim korisnicima, ali neće biti dostupne novim ili neregistriranim korisnicima ako ih odlučite izbrisati." + "Deaktiviraj račun" + diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInPresenter.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInPresenter.kt index 344d83c38f4..deb7fc59b90 100644 --- a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInPresenter.kt +++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/notifications/NotificationsOptInPresenter.kt @@ -19,7 +19,7 @@ import dev.zacsweers.metro.AssistedInject import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.di.annotations.AppCoroutineScope import io.element.android.libraries.permissions.api.PermissionStateProvider -import io.element.android.libraries.permissions.api.PermissionsEvents +import io.element.android.libraries.permissions.api.PermissionsEvent import io.element.android.libraries.permissions.api.PermissionsPresenter import io.element.android.libraries.permissions.noop.NoopPermissionsPresenter import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider @@ -58,7 +58,7 @@ class NotificationsOptInPresenter( if (notificationsPermissionsState.permissionGranted) { callback.onNotificationsOptInFinished() } else { - notificationsPermissionsState.eventSink(PermissionsEvents.RequestPermissions) + notificationsPermissionsState.eventSink(PermissionsEvent.RequestPermissions) } } NotificationsOptInEvents.NotNowClicked -> { 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 e4e922f9339..8f72f038de7 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 @@ -25,6 +25,7 @@ import io.element.android.compound.theme.ElementTheme import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.features.ftue.impl.R import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.designsystem.atomic.atoms.LoadingButtonAtom import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage @@ -111,13 +112,7 @@ private fun ChooseSelfVerificationModeButtons( AsyncData.Uninitialized, is AsyncData.Failure, is AsyncData.Loading -> { - Button( - modifier = Modifier.fillMaxWidth(), - enabled = false, - showProgress = true, - text = stringResource(CommonStrings.common_loading), - onClick = {}, - ) + LoadingButtonAtom() } is AsyncData.Success -> { if (state.buttonsState.data.canUseAnotherDevice) { diff --git a/features/ftue/impl/src/main/res/values-hr/translations.xml b/features/ftue/impl/src/main/res/values-hr/translations.xml new file mode 100644 index 00000000000..cb0e3308729 --- /dev/null +++ b/features/ftue/impl/src/main/res/values-hr/translations.xml @@ -0,0 +1,16 @@ + + + "Ne možete potvrditi?" + "Izradi novi ključ za oporavak" + "Potvrdite ovaj uređaj kako biste postavili sigurnu razmjenu poruka." + "Potvrdite svoj identitet" + "Upotrijebite drugi uređaj" + "Upotrijebi ključ za oporavak" + "Sada možete sigurno čitati ili slati poruke, a svatko s kim razgovarate također može vjerovati ovom uređaju." + "Uređaj je potvrđen" + "Upotrijebite drugi uređaj" + "Čekanje na drugi uređaj…" + "Postavke možete promijeniti poslije." + "Omogućite obavijesti i nikada ne propustite poruku" + "Unesi ključ za oporavak" + 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 71ee093985c..1a902456116 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 @@ -25,6 +25,7 @@ interface HomeEntryPoint : FeatureEntryPoint { interface Callback : Plugin { fun navigateToRoom(roomId: RoomId, joinedRoom: JoinedRoom?) fun navigateToCreateRoom() + fun navigateToCreateSpace() fun navigateToSettings() fun navigateToSetUpRecovery() fun navigateToEnterRecoveryKey() 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 d9f87e2edd6..0e92761bd5c 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 @@ -220,6 +220,7 @@ class HomeFlowNode( onRoomClick = ::navigateToRoom, onSettingsClick = callback::navigateToSettings, onStartChatClick = callback::navigateToCreateRoom, + onCreateSpaceClick = callback::navigateToCreateSpace, onSetUpRecoveryClick = callback::navigateToSetUpRecovery, onConfirmRecoveryKeyClick = callback::navigateToEnterRecoveryKey, onRoomSettingsClick = callback::navigateToRoomSettings, 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 e53d20857eb..3f223135c11 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 @@ -28,8 +28,6 @@ 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.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 @@ -48,7 +46,6 @@ class HomePresenter( private val homeSpacesPresenter: Presenter, private val logoutPresenter: Presenter, private val rageshakeFeatureAvailability: RageshakeFeatureAvailability, - private val featureFlagService: FeatureFlagService, private val sessionStore: SessionStore, private val announcementService: AnnouncementService, ) : Presenter { @@ -69,9 +66,6 @@ class HomePresenter( val canReportBug by remember { rageshakeFeatureAvailability.isAvailable() }.collectAsState(false) val roomListState = roomListPresenter.present() val homeSpacesState = homeSpacesPresenter.present() - val isSpaceFeatureEnabled by remember { - featureFlagService.isFeatureEnabledFlow(FeatureFlags.Space) - }.collectAsState(initial = false) var currentHomeNavigationBarItemOrdinal by rememberSaveable { mutableIntStateOf(HomeNavigationBarItem.Chats.ordinal) } val currentHomeNavigationBarItem by remember { derivedStateOf { @@ -117,7 +111,6 @@ class HomePresenter( snackbarMessage = snackbarMessage, canReportBug = canReportBug, directLogoutState = directLogoutState, - isSpaceFeatureEnabled = isSpaceFeatureEnabled, 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 90667a87341..474fb6d5baa 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 @@ -29,10 +29,9 @@ data class HomeState( val snackbarMessage: SnackbarMessage?, val canReportBug: Boolean, val directLogoutState: DirectLogoutState, - val isSpaceFeatureEnabled: Boolean, val eventSink: (HomeEvents) -> Unit, ) { val displayActions = currentHomeNavigationBarItem == HomeNavigationBarItem.Chats val displayRoomListFilters = currentHomeNavigationBarItem == HomeNavigationBarItem.Chats && roomListState.displayFilters - val showNavigationBar = isSpaceFeatureEnabled && homeSpacesState.spaceRooms.isNotEmpty() + val showNavigationBar = homeSpacesState.spaceRooms.isNotEmpty() } diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeStateProvider.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeStateProvider.kt index 43010b17202..e68ff7aa1fb 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeStateProvider.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeStateProvider.kt @@ -31,7 +31,6 @@ open class HomeStateProvider : PreviewParameterProvider { aHomeState(hasNetworkConnection = false), aHomeState(snackbarMessage = SnackbarMessage(CommonStrings.common_verification_complete)), aHomeState( - isSpaceFeatureEnabled = true, roomListState = aRoomListState( // Add more rooms to see the blur effect under the NavigationBar contentState = aRoomsContentState( @@ -42,7 +41,6 @@ open class HomeStateProvider : PreviewParameterProvider { homeSpacesState = aHomeSpacesState(), ), aHomeState( - isSpaceFeatureEnabled = true, currentHomeNavigationBarItem = HomeNavigationBarItem.Spaces, ), ) + RoomListStateProvider().values.map { @@ -60,7 +58,6 @@ internal fun aHomeState( roomListState: RoomListState = aRoomListState(), homeSpacesState: HomeSpacesState = aHomeSpacesState(), canReportBug: Boolean = true, - isSpaceFeatureEnabled: Boolean = false, directLogoutState: DirectLogoutState = aDirectLogoutState(), eventSink: (HomeEvents) -> Unit = {} ) = HomeState( @@ -73,6 +70,5 @@ internal fun aHomeState( currentHomeNavigationBarItem = currentHomeNavigationBarItem, roomListState = roomListState, homeSpacesState = homeSpacesState, - isSpaceFeatureEnabled = isSpaceFeatureEnabled, 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 eb7f3157a86..516a20e0f8d 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 @@ -77,6 +77,7 @@ fun HomeView( onSetUpRecoveryClick: () -> Unit, onConfirmRecoveryKeyClick: () -> Unit, onStartChatClick: () -> Unit, + onCreateSpaceClick: () -> Unit, onRoomSettingsClick: (roomId: RoomId) -> Unit, onMenuActionClick: (RoomListMenuAction) -> Unit, onReportRoomClick: (roomId: RoomId) -> Unit, @@ -116,6 +117,7 @@ fun HomeView( onRoomClick = { if (firstThrottler.canHandle()) onRoomClick(it) }, onOpenSettings = { if (firstThrottler.canHandle()) onSettingsClick() }, onStartChatClick = { if (firstThrottler.canHandle()) onStartChatClick() }, + onCreateSpaceClick = { if (firstThrottler.canHandle()) onCreateSpaceClick() }, onMenuActionClick = onMenuActionClick, ) // This overlaid view will only be visible when state.displaySearchResults is true @@ -141,6 +143,7 @@ private fun HomeScaffold( onRoomClick: (RoomId) -> Unit, onOpenSettings: () -> Unit, onStartChatClick: () -> Unit, + onCreateSpaceClick: () -> Unit, onMenuActionClick: (RoomListMenuAction) -> Unit, modifier: Modifier = Modifier, ) { @@ -181,6 +184,7 @@ private fun HomeScaffold( modifier = modifier.nestedScroll(scrollBehavior.nestedScrollConnection), topBar = { HomeTopBar( + selectedNavigationItem = state.currentHomeNavigationBarItem, title = stringResource(state.currentHomeNavigationBarItem.labelRes), currentUserAndNeighbors = state.currentUserAndNeighbors, showAvatarIndicator = state.showAvatarIndicator, @@ -191,19 +195,16 @@ private fun HomeScaffold( onAccountSwitch = { state.eventSink(HomeEvents.SwitchToAccount(it)) }, + onCreateSpace = onCreateSpaceClick, scrollBehavior = scrollBehavior, - displayMenuItems = state.displayActions, displayFilters = state.displayRoomListFilters, filtersState = roomListState.filtersState, + canCreateSpaces = state.homeSpacesState.canCreateSpaces, canReportBug = state.canReportBug, - modifier = if (state.isSpaceFeatureEnabled) { - Modifier.hazeEffect( - state = hazeState, - style = HazeMaterials.thick(), - ) - } else { - Modifier.background(ElementTheme.colors.bgCanvasDefault) - } + modifier = Modifier.hazeEffect( + state = hazeState, + style = HazeMaterials.thick(), + ) ) }, bottomBar = { @@ -354,6 +355,7 @@ internal fun HomeViewPreview(@PreviewParameter(HomeStateProvider::class) state: onSetUpRecoveryClick = {}, onConfirmRecoveryKeyClick = {}, onStartChatClick = {}, + onCreateSpaceClick = {}, onRoomSettingsClick = {}, onReportRoomClick = {}, onMenuActionClick = {}, @@ -373,6 +375,7 @@ internal fun HomeViewA11yPreview() = ElementPreview { onSetUpRecoveryClick = {}, onConfirmRecoveryKeyClick = {}, onStartChatClick = {}, + onCreateSpaceClick = {}, onRoomSettingsClick = {}, onReportRoomClick = {}, onMenuActionClick = {}, 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 093b91fb661..c92a5b9fb8f 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 @@ -39,6 +39,7 @@ import androidx.compose.ui.unit.dp import io.element.android.appconfig.RoomListConfig import io.element.android.compound.theme.ElementTheme import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.home.impl.HomeNavigationBarItem import io.element.android.features.home.impl.R import io.element.android.features.home.impl.filters.RoomListFiltersState import io.element.android.features.home.impl.filters.RoomListFiltersView @@ -73,6 +74,7 @@ import kotlinx.collections.immutable.toImmutableList @OptIn(ExperimentalMaterial3Api::class) @Composable fun HomeTopBar( + selectedNavigationItem: HomeNavigationBarItem, title: String, currentUserAndNeighbors: ImmutableList, showAvatarIndicator: Boolean, @@ -81,8 +83,9 @@ fun HomeTopBar( onMenuActionClick: (RoomListMenuAction) -> Unit, onOpenSettings: () -> Unit, onAccountSwitch: (SessionId) -> Unit, + onCreateSpace: () -> Unit, scrollBehavior: TopAppBarScrollBehavior, - displayMenuItems: Boolean, + canCreateSpaces: Boolean, canReportBug: Boolean, displayFilters: Boolean, filtersState: RoomListFiltersState, @@ -117,63 +120,16 @@ fun HomeTopBar( ) }, actions = { - if (displayMenuItems) { - IconButton( - onClick = onToggleSearch, - ) { - Icon( - imageVector = CompoundIcons.Search(), - contentDescription = stringResource(CommonStrings.action_search), - ) - } - if (RoomListConfig.HAS_DROP_DOWN_MENU) { - var showMenu by remember { mutableStateOf(false) } - IconButton( - onClick = { showMenu = !showMenu } - ) { - Icon( - imageVector = CompoundIcons.OverflowVertical(), - contentDescription = null, - ) - } - DropdownMenu( - expanded = showMenu, - onDismissRequest = { showMenu = false } - ) { - if (RoomListConfig.SHOW_INVITE_MENU_ITEM) { - DropdownMenuItem( - onClick = { - showMenu = false - onMenuActionClick(RoomListMenuAction.InviteFriends) - }, - text = { Text(stringResource(id = CommonStrings.action_invite)) }, - leadingIcon = { - Icon( - imageVector = CompoundIcons.ShareAndroid(), - tint = ElementTheme.colors.iconSecondary, - contentDescription = null, - ) - } - ) - } - if (RoomListConfig.SHOW_REPORT_PROBLEM_MENU_ITEM && canReportBug) { - DropdownMenuItem( - onClick = { - showMenu = false - onMenuActionClick(RoomListMenuAction.ReportBug) - }, - text = { Text(stringResource(id = CommonStrings.common_report_a_problem)) }, - leadingIcon = { - Icon( - imageVector = CompoundIcons.ChatProblem(), - tint = ElementTheme.colors.iconSecondary, - contentDescription = null, - ) - } - ) - } - } - } + when (selectedNavigationItem) { + HomeNavigationBarItem.Chats -> RoomListMenuItems( + onToggleSearch = onToggleSearch, + onMenuActionClick = onMenuActionClick, + canReportBug = canReportBug + ) + HomeNavigationBarItem.Spaces -> SpacesMenuItems( + canCreateSpaces = canCreateSpaces, + onCreateSpace = onCreateSpace + ) } }, // We want a 16dp left padding for the navigationIcon : @@ -193,6 +149,85 @@ fun HomeTopBar( } } +@Composable +private fun RoomListMenuItems( + onToggleSearch: () -> Unit, + onMenuActionClick: (RoomListMenuAction) -> Unit, + canReportBug: Boolean, +) { + IconButton( + onClick = onToggleSearch, + ) { + Icon( + imageVector = CompoundIcons.Search(), + contentDescription = stringResource(CommonStrings.action_search), + ) + } + if (RoomListConfig.HAS_DROP_DOWN_MENU) { + var showMenu by remember { mutableStateOf(false) } + IconButton( + onClick = { showMenu = !showMenu } + ) { + Icon( + imageVector = CompoundIcons.OverflowVertical(), + contentDescription = null, + ) + } + DropdownMenu( + expanded = showMenu, + onDismissRequest = { showMenu = false } + ) { + if (RoomListConfig.SHOW_INVITE_MENU_ITEM) { + DropdownMenuItem( + onClick = { + showMenu = false + onMenuActionClick(RoomListMenuAction.InviteFriends) + }, + text = { Text(stringResource(id = CommonStrings.action_invite)) }, + leadingIcon = { + Icon( + imageVector = CompoundIcons.ShareAndroid(), + tint = ElementTheme.colors.iconSecondary, + contentDescription = null, + ) + } + ) + } + if (RoomListConfig.SHOW_REPORT_PROBLEM_MENU_ITEM && canReportBug) { + DropdownMenuItem( + onClick = { + showMenu = false + onMenuActionClick(RoomListMenuAction.ReportBug) + }, + text = { Text(stringResource(id = CommonStrings.common_report_a_problem)) }, + leadingIcon = { + Icon( + imageVector = CompoundIcons.ChatProblem(), + tint = ElementTheme.colors.iconSecondary, + contentDescription = null, + ) + } + ) + } + } + } +} + +@Composable +private fun SpacesMenuItems( + canCreateSpaces: Boolean, + onCreateSpace: () -> Unit +) { + if (canCreateSpaces) { + IconButton(onClick = onCreateSpace) { + Icon( + imageVector = CompoundIcons.Plus(), + contentDescription = stringResource(CommonStrings.action_create_space) + ) + } + } +} + @Composable private fun NavigationIcon( currentUserAndNeighbors: ImmutableList, @@ -273,6 +308,7 @@ private fun AccountIcon( @Composable internal fun HomeTopBarPreview() = ElementPreview { HomeTopBar( + selectedNavigationItem = HomeNavigationBarItem.Chats, title = stringResource(R.string.screen_roomlist_main_space_title), currentUserAndNeighbors = persistentListOf(MatrixUser(UserId("@id:domain"), "Alice")), showAvatarIndicator = false, @@ -281,7 +317,8 @@ internal fun HomeTopBarPreview() = ElementPreview { onOpenSettings = {}, onAccountSwitch = {}, onToggleSearch = {}, - displayMenuItems = true, + onCreateSpace = {}, + canCreateSpaces = true, canReportBug = true, displayFilters = true, filtersState = aRoomListFiltersState(), @@ -289,11 +326,35 @@ internal fun HomeTopBarPreview() = ElementPreview { ) } +@OptIn(ExperimentalMaterial3Api::class) +@PreviewsDayNight +@Composable +internal fun HomeTopBarSpacesPreview() = ElementPreview { + HomeTopBar( + selectedNavigationItem = HomeNavigationBarItem.Spaces, + title = stringResource(R.string.screen_home_tab_spaces), + currentUserAndNeighbors = persistentListOf(MatrixUser(UserId("@id:domain"), "Alice")), + showAvatarIndicator = false, + areSearchResultsDisplayed = false, + scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()), + onOpenSettings = {}, + onAccountSwitch = {}, + onToggleSearch = {}, + onCreateSpace = {}, + canCreateSpaces = true, + canReportBug = true, + displayFilters = false, + filtersState = aRoomListFiltersState(), + onMenuActionClick = {}, + ) +} + @OptIn(ExperimentalMaterial3Api::class) @PreviewsDayNight @Composable internal fun HomeTopBarWithIndicatorPreview() = ElementPreview { HomeTopBar( + selectedNavigationItem = HomeNavigationBarItem.Chats, title = stringResource(R.string.screen_roomlist_main_space_title), currentUserAndNeighbors = persistentListOf(MatrixUser(UserId("@id:domain"), "Alice")), showAvatarIndicator = true, @@ -302,7 +363,8 @@ internal fun HomeTopBarWithIndicatorPreview() = ElementPreview { onOpenSettings = {}, onAccountSwitch = {}, onToggleSearch = {}, - displayMenuItems = true, + onCreateSpace = {}, + canCreateSpaces = true, canReportBug = true, displayFilters = true, filtersState = aRoomListFiltersState(), @@ -315,6 +377,7 @@ internal fun HomeTopBarWithIndicatorPreview() = ElementPreview { @Composable internal fun HomeTopBarMultiAccountPreview() = ElementPreview { HomeTopBar( + selectedNavigationItem = HomeNavigationBarItem.Chats, title = stringResource(R.string.screen_roomlist_main_space_title), currentUserAndNeighbors = aMatrixUserList().take(3).toImmutableList(), showAvatarIndicator = false, @@ -323,7 +386,8 @@ internal fun HomeTopBarMultiAccountPreview() = ElementPreview { onOpenSettings = {}, onAccountSwitch = {}, onToggleSearch = {}, - displayMenuItems = true, + onCreateSpace = {}, + canCreateSpaces = true, canReportBug = true, displayFilters = true, filtersState = aRoomListFiltersState(), 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 12df2be250a..dc4ea5b50f0 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 @@ -46,6 +46,7 @@ import io.element.android.features.home.impl.model.RoomListRoomSummaryProvider import io.element.android.features.home.impl.model.RoomSummaryDisplayType import io.element.android.features.home.impl.roomlist.RoomListEvents import io.element.android.libraries.core.extensions.orEmpty +import io.element.android.libraries.core.extensions.toSafeLength import io.element.android.libraries.designsystem.atomic.atoms.UnreadIndicatorAtom import io.element.android.libraries.designsystem.atomic.molecules.InviteButtonsRowMolecule import io.element.android.libraries.designsystem.components.avatar.Avatar @@ -122,7 +123,6 @@ internal fun RoomSummaryRow( ) { NameAndTimestampRow( name = room.name, - latestEvent = room.latestEvent, timestamp = room.timestamp, isHighlighted = room.isHighlighted ) @@ -139,7 +139,6 @@ internal fun RoomSummaryRow( ) { NameAndTimestampRow( name = room.name, - latestEvent = room.latestEvent, timestamp = null, isHighlighted = room.isHighlighted ) @@ -215,7 +214,6 @@ private fun RoomSummaryScaffoldRow( @Composable private fun NameAndTimestampRow( name: String?, - latestEvent: LatestEvent, timestamp: String?, isHighlighted: Boolean, modifier: Modifier = Modifier @@ -231,34 +229,12 @@ private fun NameAndTimestampRow( // Name Text( style = ElementTheme.typography.fontBodyLgMedium, - text = name ?: stringResource(id = CommonStrings.common_no_room_name), + text = name?.toSafeLength(ellipsize = true) ?: stringResource(id = CommonStrings.common_no_room_name), fontStyle = FontStyle.Italic.takeIf { name == null }, color = ElementTheme.colors.roomListRoomName, maxLines = 1, overflow = TextOverflow.Ellipsis ) - // Picto - when (latestEvent) { - is LatestEvent.Sending -> { - Spacer(modifier = Modifier.width(4.dp)) - Icon( - modifier = Modifier.size(16.dp), - imageVector = CompoundIcons.Time(), - contentDescription = null, - tint = ElementTheme.colors.iconTertiary, - ) - } - is LatestEvent.Error -> { - Spacer(modifier = Modifier.width(4.dp)) - Icon( - modifier = Modifier.size(16.dp), - imageVector = CompoundIcons.ErrorSolid(), - contentDescription = null, - tint = ElementTheme.colors.iconCriticalPrimary, - ) - } - else -> Unit - } } // Timestamp Text( @@ -304,7 +280,6 @@ private fun MessagePreviewAndIndicatorRow( ) { Row( modifier = modifier.fillMaxWidth(), - horizontalArrangement = spacedBy(28.dp) ) { if (room.isTombstoned) { Text( @@ -318,6 +293,16 @@ private fun MessagePreviewAndIndicatorRow( ) } else { if (room.latestEvent is LatestEvent.Error) { + Icon( + modifier = Modifier + .padding(top = 2.dp) + .size(16.dp), + imageVector = CompoundIcons.ErrorSolid(), + // The last message contains the error. + contentDescription = null, + tint = ElementTheme.colors.iconCriticalPrimary, + ) + Spacer(modifier = Modifier.width(6.dp)) Text( modifier = Modifier.weight(1f), text = stringResource(CommonStrings.common_message_failed_to_send), @@ -328,6 +313,17 @@ private fun MessagePreviewAndIndicatorRow( overflow = TextOverflow.Ellipsis, ) } else { + if (room.latestEvent is LatestEvent.Sending) { + Icon( + modifier = Modifier + .padding(top = 2.dp) + .size(16.dp), + imageVector = CompoundIcons.Time(), + contentDescription = stringResource(CommonStrings.common_sending), + tint = ElementTheme.colors.iconTertiary, + ) + Spacer(modifier = Modifier.width(6.dp)) + } val messagePreview = room.latestEvent.content() val annotatedMessagePreview = messagePreview as? AnnotatedString ?: AnnotatedString(text = messagePreview.orEmpty().toString()) Text( @@ -341,7 +337,7 @@ private fun MessagePreviewAndIndicatorRow( ) } } - + Spacer(modifier = Modifier.width(16.dp)) // Call and unread Row( modifier = Modifier @@ -387,7 +383,7 @@ private fun InviteNameAndIndicatorRow( Text( modifier = Modifier.weight(1f), style = ElementTheme.typography.fontBodyLgMedium, - text = name ?: stringResource(id = CommonStrings.common_no_room_name), + text = name?.toSafeLength(ellipsize = true) ?: stringResource(id = CommonStrings.common_no_room_name), fontStyle = FontStyle.Italic.takeIf { name == null }, color = ElementTheme.colors.roomListRoomName, maxLines = 1, diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/datasource/RoomListDataSource.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/datasource/RoomListDataSource.kt index ef5d1ffcc63..c17923fb040 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/datasource/RoomListDataSource.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/datasource/RoomListDataSource.kt @@ -19,6 +19,7 @@ import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService import io.element.android.libraries.matrix.api.roomlist.RoomListService import io.element.android.libraries.matrix.api.roomlist.RoomSummary +import io.element.android.services.analytics.api.AnalyticsService import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CoroutineScope @@ -31,7 +32,7 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.withContext -import timber.log.Timber +import java.lang.IllegalStateException import kotlin.time.Duration.Companion.seconds @Inject @@ -43,6 +44,7 @@ class RoomListDataSource( @SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope, private val dateTimeObserver: DateTimeObserver, + private val analyticsService: AnalyticsService, ) { init { observeNotificationSettings() @@ -139,10 +141,18 @@ class RoomListDataSource( // TODO remove once https://github.com/element-hq/element-x-android/issues/5031 has been confirmed as fixed val duplicates = cachingResults.filter { (_, operations) -> operations.size > 1 } if (duplicates.isNotEmpty()) { - Timber.e("Found duplicates in room summaries after an UI update: $duplicates. This could be a race condition/caching issue of some kind") + analyticsService.trackError( + IllegalStateException( + "Found duplicates in room summaries after a local UI update: $duplicates. " + + "This could be a race condition/caching issue of some kind" + ) + ) + + // Remove duplicates before emitting the new values + _allRooms.emit(roomListRoomSummaries.distinctBy { it.roomId }.toImmutableList()) + } else { + _allRooms.emit(roomListRoomSummaries.toImmutableList()) } - - _allRooms.emit(roomListRoomSummaries.toImmutableList()) } private fun buildAndCacheItem(roomSummaries: List, index: Int): RoomListRoomSummary? { diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersView.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersView.kt index a7e6d173d12..e95b8df579e 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersView.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/filters/RoomListFiltersView.kt @@ -156,6 +156,8 @@ private fun RoomListClearFiltersButton( .align(Alignment.Center) .size(16.dp), imageVector = CompoundIcons.Close(), + // TCHAP theme : color used when background is blue Tchap +// tint = ElementTheme.colors.iconOnSolidPrimary, tint = ElementTheme.iconOnSolidBlueTchap, contentDescription = stringResource(id = R.string.screen_roomlist_clear_filters), ) diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListStateProvider.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListStateProvider.kt index 188f4468de3..7f58e05c7ef 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListStateProvider.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/roomlist/RoomListStateProvider.kt @@ -91,7 +91,7 @@ internal fun aRoomListRoomSummaryList(): ImmutableList { timestamp = "14:18", latestEvent = LatestEvent.Synced("A very very very very long message which suites on two lines"), avatarData = AvatarData("!id", "R", size = AvatarSize.RoomListItem), - id = "!roomId:domain", + id = "!roomId5:domain", ), aRoomListRoomSummary( name = "Room#2", diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchEvents.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchEvents.kt index 20222c144dd..d8269fbc04f 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchEvents.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchEvents.kt @@ -10,6 +10,5 @@ package io.element.android.features.home.impl.search sealed interface RoomListSearchEvents { data object ToggleSearchVisibility : RoomListSearchEvents - data class QueryChanged(val query: String) : RoomListSearchEvents data object ClearQuery : RoomListSearchEvents } diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchPresenter.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchPresenter.kt index eef22669f4b..168dd134600 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchPresenter.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchPresenter.kt @@ -8,6 +8,8 @@ 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.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -31,29 +33,24 @@ class RoomListSearchPresenter( var isSearchActive by remember { mutableStateOf(false) } - var searchQuery by remember { - mutableStateOf("") - } + val searchQuery = rememberTextFieldState() LaunchedEffect(isSearchActive) { dataSource.setIsActive(isSearchActive) } - LaunchedEffect(searchQuery) { - dataSource.setSearchQuery(searchQuery) + LaunchedEffect(searchQuery.text) { + dataSource.setSearchQuery(searchQuery.text.toString()) } fun handleEvent(event: RoomListSearchEvents) { when (event) { RoomListSearchEvents.ClearQuery -> { - searchQuery = "" - } - is RoomListSearchEvents.QueryChanged -> { - searchQuery = event.query + searchQuery.clearText() } RoomListSearchEvents.ToggleSearchVisibility -> { isSearchActive = !isSearchActive - searchQuery = "" + searchQuery.clearText() } } } diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchState.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchState.kt index f361802bfae..e5da17b6fe3 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchState.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchState.kt @@ -8,13 +8,14 @@ package io.element.android.features.home.impl.search +import androidx.compose.foundation.text.input.TextFieldState import io.element.android.features.home.impl.model.RoomListRoomSummary import kotlinx.collections.immutable.ImmutableList data class RoomListSearchState( val showMatrixId: Boolean, val isSearchActive: Boolean, - val query: String, + val query: TextFieldState, val results: ImmutableList, val eventSink: (RoomListSearchEvents) -> Unit ) diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchStateProvider.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchStateProvider.kt index 537871511f4..b12d32393a5 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchStateProvider.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/search/RoomListSearchStateProvider.kt @@ -8,6 +8,7 @@ package io.element.android.features.home.impl.search +import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.features.home.impl.model.RoomListRoomSummary import io.element.android.features.home.impl.roomlist.aRoomListRoomSummaryList @@ -34,7 +35,7 @@ fun aRoomListSearchState( ) = RoomListSearchState( showMatrixId = false, isSearchActive = isSearchActive, - query = query, + query = TextFieldState(initialText = query), results = results, eventSink = eventSink, ) 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 5bce36771c8..5c283e2f418 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 @@ -18,16 +18,13 @@ 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.foundation.text.input.TextFieldLineLimits import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.material3.TextFieldDefaults 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.saveable.rememberSaveable -import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.focus.FocusRequester @@ -35,7 +32,6 @@ import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import io.element.android.compound.tokens.generated.CompoundIcons @@ -112,23 +108,14 @@ private fun RoomListSearchContent( }, navigationIcon = { BackButton(onClick = ::onBackButtonClick) }, title = { - // TODO replace `state.query` with TextFieldState when it's available for M3 TextField // The stateSaver will keep the selection state when returning to this UI - var value by rememberSaveable(stateSaver = TextFieldValue.Saver) { - mutableStateOf(TextFieldValue(state.query)) - } - val focusRequester = remember { FocusRequester() } FilledTextField( modifier = Modifier .fillMaxWidth() .focusRequester(focusRequester), - value = value, - singleLine = true, - onValueChange = { - value = it - state.eventSink(RoomListSearchEvents.QueryChanged(it.text)) - }, + state = state.query, + lineLimits = TextFieldLineLimits.SingleLine, colors = TextFieldDefaults.colors( focusedContainerColor = Color.Transparent, unfocusedContainerColor = Color.Transparent, @@ -138,20 +125,18 @@ private fun RoomListSearchContent( disabledIndicatorColor = Color.Transparent, errorIndicatorColor = Color.Transparent, ), - trailingIcon = { - if (value.text.isNotEmpty()) { - IconButton(onClick = { - state.eventSink(RoomListSearchEvents.ClearQuery) - // Clear local state too - value = value.copy(text = "") - }) { + trailingIcon = if (state.query.text.isNotEmpty()) { + @Composable { + IconButton(onClick = { state.eventSink(RoomListSearchEvents.ClearQuery) }) { Icon( imageVector = CompoundIcons.Close(), contentDescription = stringResource(CommonStrings.action_cancel) ) } } - } + } else { + null + }, ) LaunchedEffect(Unit) { diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesPresenter.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesPresenter.kt index a890a61ac3c..00129235fe6 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesPresenter.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesPresenter.kt @@ -15,6 +15,8 @@ import androidx.compose.runtime.remember import dev.zacsweers.metro.Inject import io.element.android.features.invite.api.SeenInvitesStore 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.matrix.api.MatrixClient import io.element.android.libraries.matrix.ui.safety.rememberHideInvitesAvatar import kotlinx.collections.immutable.persistentListOf @@ -27,9 +29,11 @@ import kotlinx.coroutines.flow.map class HomeSpacesPresenter( private val client: MatrixClient, private val seenInvitesStore: SeenInvitesStore, + private val featureFlagsService: FeatureFlagService, ) : Presenter { @Composable override fun present(): HomeSpacesState { + val canCreateSpaces by featureFlagsService.isFeatureEnabledFlow(FeatureFlags.CreateSpaces).collectAsState(false) val hideInvitesAvatar by client.rememberHideInvitesAvatar() val spaceRooms by remember { client.spaceService.spaceRoomsFlow.map { it.toImmutableList() } @@ -48,6 +52,7 @@ class HomeSpacesPresenter( spaceRooms = spaceRooms, seenSpaceInvites = seenSpaceInvites, hideInvitesAvatar = hideInvitesAvatar, + canCreateSpaces = canCreateSpaces, eventSink = ::handleEvent, ) } diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesState.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesState.kt index 7dcb3702196..9bcf7131c8a 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesState.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesState.kt @@ -18,6 +18,7 @@ data class HomeSpacesState( val spaceRooms: ImmutableList, val seenSpaceInvites: ImmutableSet, val hideInvitesAvatar: Boolean, + val canCreateSpaces: Boolean, val eventSink: (HomeSpacesEvents) -> Unit, ) diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesStateProvider.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesStateProvider.kt index 8c03cff7ee4..c1a32a1f342 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesStateProvider.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesStateProvider.kt @@ -30,6 +30,13 @@ open class HomeSpacesStateProvider : PreviewParameterProvider { ), spaceRooms = aListOfSpaceRooms(), ), + aHomeSpacesState( + space = CurrentSpace.Space( + spaceRoom = aSpaceRoom(roomId = RoomId("!mySpace:example.com")) + ), + spaceRooms = aListOfSpaceRooms(), + canCreateSpaces = false, + ), ) } @@ -38,12 +45,14 @@ internal fun aHomeSpacesState( spaceRooms: List = aListOfSpaceRooms(), seenSpaceInvites: Set = emptySet(), hideInvitesAvatar: Boolean = false, + canCreateSpaces: Boolean = true, eventSink: (HomeSpacesEvents) -> Unit = {}, ) = HomeSpacesState( space = space, spaceRooms = spaceRooms.toImmutableList(), seenSpaceInvites = seenSpaceInvites.toImmutableSet(), hideInvitesAvatar = hideInvitesAvatar, + canCreateSpaces = canCreateSpaces, eventSink = eventSink, ) diff --git a/features/home/impl/src/main/res/values-hr/translations.xml b/features/home/impl/src/main/res/values-hr/translations.xml new file mode 100644 index 00000000000..233cac78d80 --- /dev/null +++ b/features/home/impl/src/main/res/values-hr/translations.xml @@ -0,0 +1,55 @@ + + + "Onemogućite optimizaciju baterije za ovu aplikaciju kako biste bili sigurni da ćete primati sve obavijesti." + "Onemogući optimizaciju" + "Obavijesti ne stižu?" + "Vaš je signal obavijesti ažuriran – jasniji je, brži i manje ometajući." + "Ažurirali smo vaše zvukove" + "Ako ste izgubili sve postojeće uređaje, oporavite svoj kriptografski identitet i povijest poruka pomoću ključa za oporavak." + "Postavljanje oporavka" + "Postavite oporavak kako biste zaštitili svoj račun" + "Potvrdite svoj ključ za oporavak kako biste zadržali pristup pohrani ključeva i povijesti poruka." + "Unesite svoj ključ za oporavak" + "Zaboravili ste ključ za oporavak?" + "Vaša pohrana ključeva nije sinkronizirana" + "Kako biste bili sigurni da nikada nećete propustiti važan poziv, promijenite postavke kako biste omogućili obavijesti preko cijelog zaslona kada je telefon zaključan." + "Poboljšajte svoje iskustvo poziva" + "Razgovori" + "Prostori" + "Jeste li sigurni da želite odbiti poziv za pridruživanje %1$s?" + "Odbij poziv" + "Jeste li sigurni da želite odbiti ovaj privatni razgovor s korisnikom %1$s?" + "Odbij razgovor" + "Nema pozivnica" + "Pozvao vas je korisnik %1$s (%2$s)" + "Ovaj se postupak izvodi samo jednom, hvala na čekanju." + "Postavljanje vašeg računa." + "Stvori novi razgovor ili sobu" + "Ukloni filtre" + "Započnite tako da nekome pošaljete poruku." + "Još nema razgovora." + "Favoriti" + "Razgovor možete dodati u favorite u postavkama razgovora. +Zasad možete poništiti odabir filtara kako biste vidjeli ostale razgovore." + "Još nemate omiljenih razgovora" + "Pozivnice" + "Nemate pozivnica na čekanju." + "Nizak prioritet" + "Još nemate razgovora niskog prioriteta" + "Možete poništiti odabir filtara kako biste vidjeli ostale razgovore" + "Nemate razgovora za ovaj odabir" + "Osobe" + "Nemate još nijednu izravnu poruku" + "Sobe" + "Niste još ni u jednoj sobi" + "Nepročitano" + "Čestitamo! +Nemate nepročitanih poruka!" + "Zahtjev za pridruživanje je poslan" + "Razgovori" + "Označi kao pročitano" + "Označi kao nepročitano" + "Ova je soba nadograđena" + "Izgleda da koristite novi uređaj. Izvršite provjeru drugim uređajem da biste pristupili svojim šifriranim porukama." + "Potvrdi identitet" + 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 9778556dd25..582de66414e 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 @@ -47,6 +47,7 @@ class DefaultHomeEntryPointTest { val callback = object : HomeEntryPoint.Callback { override fun navigateToRoom(roomId: RoomId, joinedRoom: JoinedRoom?) = lambdaError() override fun navigateToCreateRoom() = lambdaError() + override fun navigateToCreateSpace() = lambdaError() override fun navigateToSettings() = lambdaError() override fun navigateToSetUpRecovery() = lambdaError() override fun navigateToEnterRecoveryKey() = lambdaError() 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 0ae3ea1ff37..266c33015db 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 @@ -22,9 +22,6 @@ import io.element.android.features.rageshake.api.RageshakeFeatureAvailability import io.element.android.features.rageshake.test.logs.FakeAnnouncementService 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 @@ -35,7 +32,6 @@ import io.element.android.libraries.matrix.test.AN_EXCEPTION import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.A_USER_NAME import io.element.android.libraries.matrix.test.FakeMatrixClient -import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.matrix.test.sync.FakeSyncService import io.element.android.libraries.sessionstorage.api.SessionStore import io.element.android.libraries.sessionstorage.test.InMemorySessionStore @@ -54,8 +50,6 @@ class HomePresenterTest { @get:Rule val warmUpRule = WarmUpRule() - private val isSpaceEnabled = FeatureFlags.Space.defaultValue(aBuildMeta()) - @Test fun `present - should start with no user and then load user with success`() = runTest { val matrixClient = FakeMatrixClient( @@ -79,7 +73,6 @@ class HomePresenterTest { moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { - if (isSpaceEnabled) skipItems(1) val initialState = awaitItem() assertThat(initialState.currentUserAndNeighbors.first()).isEqualTo( MatrixUser(A_USER_ID, null, null) @@ -91,8 +84,7 @@ class HomePresenterTest { MatrixUser(A_USER_ID, A_USER_NAME, AN_AVATAR_URL) ) assertThat(withUserState.showAvatarIndicator).isFalse() - assertThat(withUserState.isSpaceFeatureEnabled).isEqualTo(isSpaceEnabled) - assertThat(withUserState.showNavigationBar).isEqualTo(isSpaceEnabled) + assertThat(withUserState.showNavigationBar).isTrue() } } @@ -114,23 +106,6 @@ class HomePresenterTest { } } - @Test - fun `present - space feature enabled`() = runTest { - val presenter = createHomePresenter( - featureFlagService = FakeFeatureFlagService( - initialState = mapOf(FeatureFlags.Space.key to true), - ), - sessionStore = InMemorySessionStore( - updateUserProfileResult = { _, _, _ -> }, - ), - ) - presenter.test { - skipItems(1) - val initialState = awaitItem() - assertThat(initialState.isSpaceFeatureEnabled).isTrue() - } - } - @Test fun `present - show avatar indicator`() = runTest { val indicatorService = FakeIndicatorService() @@ -143,7 +118,6 @@ class HomePresenterTest { moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { - if (isSpaceEnabled) skipItems(1) val initialState = awaitItem() assertThat(initialState.showAvatarIndicator).isFalse() indicatorService.setShowRoomListTopBarIndicator(true) @@ -168,7 +142,6 @@ class HomePresenterTest { moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { - if (isSpaceEnabled) skipItems(1) val initialState = awaitItem() assertThat(initialState.currentUserAndNeighbors.first()).isEqualTo(MatrixUser(matrixClient.sessionId)) // No new state is coming @@ -189,7 +162,6 @@ class HomePresenterTest { moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { - if (isSpaceEnabled) skipItems(1) val initialState = awaitItem() assertThat(initialState.currentHomeNavigationBarItem).isEqualTo(HomeNavigationBarItem.Chats) initialState.eventSink(HomeEvents.SelectHomeNavigationBarItem(HomeNavigationBarItem.Spaces)) @@ -207,16 +179,12 @@ class HomePresenterTest { sessionStore = InMemorySessionStore( updateUserProfileResult = { _, _, _ -> }, ), - featureFlagService = FakeFeatureFlagService( - initialState = mapOf(FeatureFlags.Space.key to true), - ), homeSpacesPresenter = homeSpacesPresenter, announcementService = FakeAnnouncementService( showAnnouncementResult = {}, ) ) presenter.test { - skipItems(1) val initialState = awaitItem() assertThat(initialState.currentHomeNavigationBarItem).isEqualTo(HomeNavigationBarItem.Chats) assertThat(initialState.showNavigationBar).isTrue() @@ -241,7 +209,6 @@ internal fun createHomePresenter( snackbarDispatcher: SnackbarDispatcher = SnackbarDispatcher(), rageshakeFeatureAvailability: RageshakeFeatureAvailability = RageshakeFeatureAvailability { flowOf(false) }, indicatorService: IndicatorService = FakeIndicatorService(), - featureFlagService: FeatureFlagService = FakeFeatureFlagService(), homeSpacesPresenter: Presenter = Presenter { aHomeSpacesState() }, sessionStore: SessionStore = InMemorySessionStore(), announcementService: AnnouncementService = FakeAnnouncementService(), @@ -250,11 +217,10 @@ internal fun createHomePresenter( syncService = syncService, snackbarDispatcher = snackbarDispatcher, indicatorService = indicatorService, - logoutPresenter = { aDirectLogoutState() }, roomListPresenter = { aRoomListState() }, homeSpacesPresenter = homeSpacesPresenter, + logoutPresenter = { aDirectLogoutState() }, rageshakeFeatureAvailability = rageshakeFeatureAvailability, - featureFlagService = featureFlagService, sessionStore = sessionStore, announcementService = announcementService, ) diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/datasource/RoomListDataSourceTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/datasource/RoomListDataSourceTest.kt index 35c30af86eb..aaecd90c568 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/datasource/RoomListDataSourceTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/datasource/RoomListDataSourceTest.kt @@ -17,6 +17,7 @@ import io.element.android.libraries.matrix.api.roomlist.RoomListService import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService import io.element.android.libraries.matrix.test.room.aRoomSummary import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService +import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.tests.testutils.testCoroutineDispatchers import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest @@ -103,5 +104,6 @@ class RoomListDataSourceTest { notificationSettingsService = notificationSettingsService, sessionCoroutineScope = backgroundScope, dateTimeObserver = dateTimeObserver, + analyticsService = FakeAnalyticsService(), ) } 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 a4b5bde637c..81e254626c8 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 @@ -666,6 +666,7 @@ class RoomListPresenterTest { notificationSettingsService = client.notificationSettingsService, sessionCoroutineScope = backgroundScope, dateTimeObserver = FakeDateTimeObserver(), + analyticsService = FakeAnalyticsService(), ), searchPresenter = searchPresenter, sessionPreferencesStore = sessionPreferencesStore, 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 bb82d51a790..a07093aa9f1 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 @@ -273,6 +273,7 @@ private fun AndroidComposeTestRule.setRoomL onSetUpRecoveryClick: () -> Unit = EnsureNeverCalled(), onConfirmRecoveryKeyClick: () -> Unit = EnsureNeverCalled(), onCreateRoomClick: () -> Unit = EnsureNeverCalled(), + onCreateSpaceClick: () -> Unit = EnsureNeverCalled(), onRoomSettingsClick: (RoomId) -> Unit = EnsureNeverCalledWithParam(), onMenuActionClick: (RoomListMenuAction) -> Unit = EnsureNeverCalledWithParam(), onReportRoomClick: (RoomId) -> Unit = EnsureNeverCalledWithParam(), @@ -286,6 +287,7 @@ private fun AndroidComposeTestRule.setRoomL onSetUpRecoveryClick = onSetUpRecoveryClick, onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick, onStartChatClick = onCreateRoomClick, + onCreateSpaceClick = onCreateSpaceClick, onRoomSettingsClick = onRoomSettingsClick, onMenuActionClick = onMenuActionClick, onDeclineInviteAndBlockUser = onDeclineInviteAndBlockUser, diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/RoomListSearchPresenterTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/RoomListSearchPresenterTest.kt index d82055df25b..3c39a01ef21 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/RoomListSearchPresenterTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/search/RoomListSearchPresenterTest.kt @@ -35,7 +35,7 @@ class RoomListSearchPresenterTest { }.test { awaitItem().let { state -> assertThat(state.isSearchActive).isFalse() - assertThat(state.query).isEmpty() + assertThat(state.query.text.toString()).isEmpty() assertThat(state.results).isEmpty() } } @@ -75,10 +75,10 @@ class RoomListSearchPresenterTest { ).isEqualTo( RoomListFilter.None ) - state.eventSink(RoomListSearchEvents.QueryChanged("Search")) + state.query.edit { append("Search") } } awaitItem().let { state -> - assertThat(state.query).isEqualTo("Search") + assertThat(state.query.text).isEqualTo("Search") assertThat( roomListService.allRooms.currentFilter.value ).isEqualTo( @@ -87,7 +87,7 @@ class RoomListSearchPresenterTest { state.eventSink(RoomListSearchEvents.ClearQuery) } awaitItem().let { state -> - assertThat(state.query).isEmpty() + assertThat(state.query.text.toString()).isEmpty() assertThat( roomListService.allRooms.currentFilter.value ).isEqualTo( diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesPresenterTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesPresenterTest.kt index c7608833ac3..43d3a8896dc 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesPresenterTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/spaces/HomeSpacesPresenterTest.kt @@ -11,6 +11,9 @@ package io.element.android.features.home.impl.spaces import com.google.common.truth.Truth.assertThat import io.element.android.features.invite.api.SeenInvitesStore import io.element.android.features.invite.test.InMemorySeenInvitesStore +import io.element.android.libraries.featureflag.api.FeatureFlagService +import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.tests.testutils.test @@ -23,18 +26,25 @@ class HomeSpacesPresenterTest { val presenter = createPresenter() presenter.test { val state = awaitItem() + // canCreateSpaces is initially false + assertThat(state.canCreateSpaces).isFalse() assertThat(state.space).isEqualTo(CurrentSpace.Root) assertThat(state.spaceRooms).isEmpty() assertThat(state.hideInvitesAvatar).isFalse() assertThat(state.seenSpaceInvites).isEmpty() + + // It'll eventually be true + assertThat(awaitItem().canCreateSpaces).isTrue() } } private fun createPresenter( client: MatrixClient = FakeMatrixClient(), seenInvitesStore: SeenInvitesStore = InMemorySeenInvitesStore(), + featureFlagsService: FeatureFlagService = FakeFeatureFlagService(initialState = mapOf(FeatureFlags.CreateSpaces.key to true)), ) = HomeSpacesPresenter( client = client, seenInvitesStore = seenInvitesStore, + featureFlagsService = featureFlagsService, ) } diff --git a/features/invite/impl/src/main/res/values-hr/translations.xml b/features/invite/impl/src/main/res/values-hr/translations.xml new file mode 100644 index 00000000000..6d853dacf1d --- /dev/null +++ b/features/invite/impl/src/main/res/values-hr/translations.xml @@ -0,0 +1,18 @@ + + + "Nećete vidjeti nikakve poruke ili pozivnice za sobu od ovog korisnika" + "Blokiraj korisnika" + "Prijavite ovu sobu svom davatelju usluga računa." + "Navedite razlog prijave…" + "Odbij i blokiraj" + "Jeste li sigurni da želite odbiti poziv za pridruživanje %1$s?" + "Odbij poziv" + "Jeste li sigurni da želite odbiti ovaj privatni razgovor s korisnikom %1$s?" + "Odbij razgovor" + "Nema pozivnica" + "Pozvao vas je korisnik %1$s (%2$s)" + "Da, odbij i blokiraj" + "Jeste li sigurni da želite odbiti poziv za pridruživanje ovoj sobi? Time ćete također spriječiti da %1$s kontaktira s vama ili vas pozove u sobe." + "Odbij poziv i blokiraj" + "Odbij i blokiraj" + diff --git a/features/invitepeople/impl/src/main/res/values-hr/translations.xml b/features/invitepeople/impl/src/main/res/values-hr/translations.xml new file mode 100644 index 00000000000..66031c5fd7b --- /dev/null +++ b/features/invitepeople/impl/src/main/res/values-hr/translations.xml @@ -0,0 +1,5 @@ + + + "Već je član" + "Već je pozvan/a" + diff --git a/features/joinroom/impl/src/main/res/values-hr/translations.xml b/features/joinroom/impl/src/main/res/values-hr/translations.xml new file mode 100644 index 00000000000..1a8489255a2 --- /dev/null +++ b/features/joinroom/impl/src/main/res/values-hr/translations.xml @@ -0,0 +1,34 @@ + + + "Korisnik %1$s vam je zabranio pristup." + "Zabranjen vam je pristup" + "Razlog: %1$s." + "Otkaži zahtjev" + "Da, otkaži" + "Jeste li sigurni da želite otkazati svoj zahtjev za pridruživanje ovoj sobi?" + "Otkaži zahtjev za pridruživanje" + "Da, odbij i blokiraj" + "Jeste li sigurni da želite odbiti poziv za pridruživanje ovoj sobi? Time ćete također spriječiti da %1$s kontaktira s vama ili vas pozove u sobe." + "Odbij poziv i blokiraj" + "Odbij i blokiraj" + "Pridruživanje nije uspjelo" + "Morate biti pozvani da se pridružite ili možda postoje ograničenja pristupa." + "Zaboravi" + "Trebate imati pozivnicu kako biste se pridružili" + "Pozvao/la" + "Pridruži se" + "Možda ćete morati biti pozvani ili biti član prostora kako biste se pridružili." + "Pošalji zahtjev za pridruživanje" + "Dopuštenih znakova %1$d od %2$d" + "Poruka (nije obavezna)" + "Primit ćete pozivnicu za pridruživanje sobi ako vaš zahtjev bude prihvaćen." + "Zahtjev za pridruživanje je poslan" + "Nismo mogli prikazati pregled sobe. To bi moglo biti zbog problema s mrežom ili poslužiteljem." + "Nismo mogli prikazati pregled ove sobe" + "%1$s još ne podržava prostore. Prostorima možete pristupiti na internetu." + "Prostori još nisu podržani" + "Kliknite donji gumb i administrator sobe dobit će obavijest. Moći ćete se pridružiti razgovoru nakon što dobijete odobrenje." + "Morate biti član ove sobe da biste vidjeli povijest poruka." + "Želite li se pridružiti ovoj sobi?" + "Pregled nije dostupan" + diff --git a/features/knockrequests/api/src/main/kotlin/io/element/android/features/knockrequests/api/KnockRequestPermissions.kt b/features/knockrequests/api/src/main/kotlin/io/element/android/features/knockrequests/api/KnockRequestPermissions.kt new file mode 100644 index 00000000000..82bceb5be0d --- /dev/null +++ b/features/knockrequests/api/src/main/kotlin/io/element/android/features/knockrequests/api/KnockRequestPermissions.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2025 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.knockrequests.api + +import io.element.android.libraries.matrix.api.room.powerlevels.RoomPermissions + +data class KnockRequestPermissions( + val canAccept: Boolean, + val canDecline: Boolean, + val canBan: Boolean, +) { + val hasAny = canAccept || canDecline || canBan + + companion object { + val DEFAULT = KnockRequestPermissions( + canAccept = false, + canDecline = false, + canBan = false, + ) + } +} + +fun RoomPermissions.knockRequestPermissions(): KnockRequestPermissions { + return KnockRequestPermissions( + canAccept = canOwnUserInvite(), + canDecline = canOwnUserKick(), + canBan = canOwnUserBan(), + ) +} diff --git a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerPresenter.kt b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerPresenter.kt index 641efffaa32..f340d597b15 100644 --- a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerPresenter.kt +++ b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerPresenter.kt @@ -49,7 +49,7 @@ class KnockRequestsBannerPresenter( val shouldShowBanner by remember { derivedStateOf { - permissions.canHandle && knockRequests.isNotEmpty() + permissions.hasAny && knockRequests.isNotEmpty() } } diff --git a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestPermissions.kt b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestPermissions.kt deleted file mode 100644 index 2ca4d4df749..00000000000 --- a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestPermissions.kt +++ /dev/null @@ -1,33 +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.knockrequests.impl.data - -import io.element.android.libraries.matrix.api.room.JoinedRoom -import io.element.android.libraries.matrix.api.room.powerlevels.canBan -import io.element.android.libraries.matrix.api.room.powerlevels.canInvite -import io.element.android.libraries.matrix.api.room.powerlevels.canKick -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.map - -data class KnockRequestPermissions( - val canAccept: Boolean, - val canDecline: Boolean, - val canBan: Boolean, -) { - val canHandle = canAccept || canDecline || canBan -} - -fun JoinedRoom.knockRequestPermissionsFlow(): Flow { - return syncUpdateFlow.map { - val canAccept = canInvite().getOrDefault(false) - val canDecline = canKick().getOrDefault(false) - val canBan = canBan().getOrDefault(false) - KnockRequestPermissions(canAccept, canDecline, canBan) - } -} diff --git a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestsModule.kt b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestsModule.kt index eeba54f87d7..b51b78f1058 100644 --- a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestsModule.kt +++ b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestsModule.kt @@ -12,10 +12,13 @@ import dev.zacsweers.metro.BindingContainer import dev.zacsweers.metro.ContributesTo import dev.zacsweers.metro.Provides import dev.zacsweers.metro.SingleIn +import io.element.android.features.knockrequests.api.KnockRequestPermissions +import io.element.android.features.knockrequests.api.knockRequestPermissions import io.element.android.libraries.di.RoomScope import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.matrix.api.room.JoinedRoom +import io.element.android.libraries.matrix.api.room.powerlevels.permissionsFlow @BindingContainer @ContributesTo(RoomScope::class) @@ -25,7 +28,9 @@ object KnockRequestsModule { fun knockRequestsService(room: JoinedRoom, featureFlagService: FeatureFlagService): KnockRequestsService { return KnockRequestsService( knockRequestsFlow = room.knockRequestsFlow, - permissionsFlow = room.knockRequestPermissionsFlow(), + permissionsFlow = room.permissionsFlow(KnockRequestPermissions.DEFAULT) { perms -> + perms.knockRequestPermissions() + }, isKnockFeatureEnabledFlow = featureFlagService.isFeatureEnabledFlow(FeatureFlags.Knock), coroutineScope = room.roomCoroutineScope ) diff --git a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestsService.kt b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestsService.kt index 04c2f7b316d..98570e6b28e 100644 --- a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestsService.kt +++ b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/data/KnockRequestsService.kt @@ -8,6 +8,7 @@ package io.element.android.features.knockrequests.impl.data +import io.element.android.features.knockrequests.api.KnockRequestPermissions import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.room.knock.KnockRequest diff --git a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListState.kt b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListState.kt index a1bb90cae89..ae770a297d3 100644 --- a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListState.kt +++ b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListState.kt @@ -9,7 +9,7 @@ package io.element.android.features.knockrequests.impl.list import androidx.compose.runtime.Immutable -import io.element.android.features.knockrequests.impl.data.KnockRequestPermissions +import io.element.android.features.knockrequests.api.KnockRequestPermissions import io.element.android.features.knockrequests.impl.data.KnockRequestPresentable import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncData diff --git a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListStateProvider.kt b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListStateProvider.kt index 2c4c92a6b68..85fc0675ad0 100644 --- a/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListStateProvider.kt +++ b/features/knockrequests/impl/src/main/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListStateProvider.kt @@ -9,7 +9,7 @@ package io.element.android.features.knockrequests.impl.list import androidx.compose.ui.tooling.preview.PreviewParameterProvider -import io.element.android.features.knockrequests.impl.data.KnockRequestPermissions +import io.element.android.features.knockrequests.api.KnockRequestPermissions import io.element.android.features.knockrequests.impl.data.KnockRequestPresentable import io.element.android.features.knockrequests.impl.data.aKnockRequestPresentable import io.element.android.libraries.architecture.AsyncAction diff --git a/features/knockrequests/impl/src/main/res/values-hr/translations.xml b/features/knockrequests/impl/src/main/res/values-hr/translations.xml new file mode 100644 index 00000000000..7f8d39503f9 --- /dev/null +++ b/features/knockrequests/impl/src/main/res/values-hr/translations.xml @@ -0,0 +1,37 @@ + + + "Da, prihvati sve" + "Jeste li sigurni da želite prihvatiti sve zahtjeve za pridruživanje?" + "Prihvati sve zahtjeve" + "Prihvati sve" + "Nismo mogli prihvatiti sve zahtjeve. Želite li pokušati ponovno?" + "Prihvaćanje svih zahtjeva nije uspjelo" + "Prihvaćanje svih zahtjeva za pridruživanje" + "Nismo mogli prihvatiti ovaj zahtjev. Želite li pokušati ponovno?" + "Prihvaćanje zahtjeva nije uspjelo" + "Prihvaća se zahtjev za pridruživanje" + "Da, odbij i zabrani" + "Jeste li sigurni da želite odbiti i zabraniti korisnika %1$s? Taj korisnik neće moći ponovno zatražiti pristup ovoj sobi." + "Odbij i zabrani pristup" + "Odbijanje i zabrana pristupa" + "Da, odbij" + "Jeste li sigurni da želite odbiti zahtjev korisnika %1$s za pridruživanje ovoj sobi?" + "Odbij pristup" + "Odbij i zabrani" + "Nismo mogli odbiti ovaj zahtjev. Želite li pokušati ponovno?" + "Odbijanje zahtjeva nije uspjelo" + "Odbijanje zahtjeva za pridruživanje" + "Kada netko zatraži pridruživanje sobi, ovdje ćete moći vidjeti njihov zahtjev." + "Nema zahtjeva za pridruživanje koji su na čekanju" + "Učitavanje zahtjeva za pridruživanje…" + "Zahtjevi za pridruživanje" + + "%1$s i još %2$d želi se pridružiti ovoj sobi" + "%1$s i još %2$d želi se pridružiti ovoj sobi" + "%1$s i još %2$d želi se pridružiti ovoj sobi" + + "Prikaži sve" + "Prihvati" + "%1$s želi se pridružiti ovoj sobi" + "Prikaz" + diff --git a/features/knockrequests/impl/src/test/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerPresenterTest.kt b/features/knockrequests/impl/src/test/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerPresenterTest.kt index 9eaa51cecb8..3161d3e81f0 100644 --- a/features/knockrequests/impl/src/test/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerPresenterTest.kt +++ b/features/knockrequests/impl/src/test/kotlin/io/element/android/features/knockrequests/impl/banner/KnockRequestsBannerPresenterTest.kt @@ -9,7 +9,7 @@ package io.element.android.features.knockrequests.impl.banner import com.google.common.truth.Truth.assertThat -import io.element.android.features.knockrequests.impl.data.KnockRequestPermissions +import io.element.android.features.knockrequests.api.KnockRequestPermissions import io.element.android.features.knockrequests.impl.data.KnockRequestsService import io.element.android.libraries.matrix.api.room.knock.KnockRequest import io.element.android.libraries.matrix.test.A_USER_ID diff --git a/features/knockrequests/impl/src/test/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListPresenterTest.kt b/features/knockrequests/impl/src/test/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListPresenterTest.kt index b0d0b68c917..7102b017736 100644 --- a/features/knockrequests/impl/src/test/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListPresenterTest.kt +++ b/features/knockrequests/impl/src/test/kotlin/io/element/android/features/knockrequests/impl/list/KnockRequestsListPresenterTest.kt @@ -11,7 +11,7 @@ package io.element.android.features.knockrequests.impl.list import com.google.common.truth.Truth.assertThat -import io.element.android.features.knockrequests.impl.data.KnockRequestPermissions +import io.element.android.features.knockrequests.api.KnockRequestPermissions import io.element.android.features.knockrequests.impl.data.KnockRequestsService import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncData diff --git a/features/leaveroom/api/src/main/res/values-hr/translations.xml b/features/leaveroom/api/src/main/res/values-hr/translations.xml new file mode 100644 index 00000000000..4eb9e3405ce --- /dev/null +++ b/features/leaveroom/api/src/main/res/values-hr/translations.xml @@ -0,0 +1,10 @@ + + + "Jeste li sigurni da želite napustiti ovaj razgovor? Ovaj razgovor nije javan i nećete se moći ponovno pridružiti bez pozivnice." + "Jeste li sigurni da želite napustiti ovu sobu? Ovdje ste jedino vi. Ako odete, nitko se ubuduće neće moći pridružiti, pa ni vi." + "Jeste li sigurni da želite napustiti ovu sobu? Ova soba nije javna i nećete joj se moći ponovno pridružiti bez pozivnice." + "Odaberi vlasnike" + "Vi ste jedini vlasnik ove sobe. Morate prenijeti vlasništvo na nekog drugog prije nego što napustite sobu." + "Prenesi vlasništvo" + "Jeste li sigurni da želite napustiti sobu?" + diff --git a/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/LeaveRoomPresenter.kt b/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/LeaveRoomPresenter.kt index c371d425c15..6455b456592 100644 --- a/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/LeaveRoomPresenter.kt +++ b/features/leaveroom/impl/src/main/kotlin/io/element/android/features/leaveroom/impl/LeaveRoomPresenter.kt @@ -96,10 +96,7 @@ class LeaveRoomPresenter( } else { val hasPrivilegedCreatorRole = roomInfoFlow.value.privilegedCreatorRole if (!hasPrivilegedCreatorRole) return false - - val creators = usersWithRole(RoomMember.Role.Owner(isCreator = true)).first() - val superAdmins = usersWithRole(RoomMember.Role.Owner(isCreator = false)).first() - val owners = creators + superAdmins + val owners = usersWithRole { role -> role is RoomMember.Role.Owner }.first() return owners.size == 1 && owners.first().userId == sessionId } } diff --git a/features/linknewdevice/api/build.gradle.kts b/features/linknewdevice/api/build.gradle.kts new file mode 100644 index 00000000000..7d368f0a630 --- /dev/null +++ b/features/linknewdevice/api/build.gradle.kts @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2025 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 = "io.element.android.features.linknewdevice.api" +} + +dependencies { + implementation(projects.libraries.architecture) +} diff --git a/features/linknewdevice/api/src/main/kotlin/io/element/android/features/linknewdevice/api/LinkNewDeviceEntryPoint.kt b/features/linknewdevice/api/src/main/kotlin/io/element/android/features/linknewdevice/api/LinkNewDeviceEntryPoint.kt new file mode 100644 index 00000000000..061bbeb0844 --- /dev/null +++ b/features/linknewdevice/api/src/main/kotlin/io/element/android/features/linknewdevice/api/LinkNewDeviceEntryPoint.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2025 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.linknewdevice.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 + +interface LinkNewDeviceEntryPoint : FeatureEntryPoint { + interface Callback : Plugin { + fun onDone() + } + + fun createNode( + parentNode: Node, + buildContext: BuildContext, + callback: Callback, + ): Node +} diff --git a/features/linknewdevice/impl/build.gradle.kts b/features/linknewdevice/impl/build.gradle.kts new file mode 100644 index 00000000000..9c1aa9e990d --- /dev/null +++ b/features/linknewdevice/impl/build.gradle.kts @@ -0,0 +1,63 @@ +import extension.setupDependencyInjection +import extension.testCommonDependencies + +/* + * Copyright (c) 2025 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") + id("kotlin-parcelize") + alias(libs.plugins.kotlin.serialization) +} + +android { + namespace = "io.element.android.features.linknewdevice.impl" + + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } +} + +setupDependencyInjection() + +dependencies { + // TODO Cleanup + implementation(projects.appconfig) + implementation(projects.features.enterprise.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.oidc.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.enterprise.test) + testImplementation(projects.libraries.featureflag.test) + testImplementation(projects.libraries.matrix.test) + testImplementation(projects.libraries.oidc.test) + testImplementation(projects.libraries.permissions.test) + testImplementation(projects.libraries.sessionStorage.test) + testImplementation(projects.libraries.wellknown.test) +} diff --git a/features/linknewdevice/impl/src/main/AndroidManifest.xml b/features/linknewdevice/impl/src/main/AndroidManifest.xml new file mode 100644 index 00000000000..d225716fc4c --- /dev/null +++ b/features/linknewdevice/impl/src/main/AndroidManifest.xml @@ -0,0 +1,17 @@ + + + + + + + + + + + diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/DefaultLinkNewDeviceEntryPoint.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/DefaultLinkNewDeviceEntryPoint.kt new file mode 100644 index 00000000000..5edc3cdfcdf --- /dev/null +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/DefaultLinkNewDeviceEntryPoint.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2025 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.linknewdevice.impl + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import dev.zacsweers.metro.ContributesBinding +import io.element.android.features.linknewdevice.api.LinkNewDeviceEntryPoint +import io.element.android.libraries.architecture.createNode +import io.element.android.libraries.di.SessionScope + +@ContributesBinding(SessionScope::class) +class DefaultLinkNewDeviceEntryPoint : LinkNewDeviceEntryPoint { + override fun createNode( + parentNode: Node, + buildContext: BuildContext, + callback: LinkNewDeviceEntryPoint.Callback, + ): Node { + return parentNode.createNode( + buildContext = buildContext, + plugins = listOf( + callback, + ) + ) + } +} diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewDesktopHandler.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewDesktopHandler.kt new file mode 100644 index 00000000000..a8a8ff14b61 --- /dev/null +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewDesktopHandler.kt @@ -0,0 +1,74 @@ +/* + * Copyright (c) 2025 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.linknewdevice.impl + +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn +import io.element.android.libraries.core.log.logger.LoggerTag +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.linknewdevice.LinkDesktopHandler +import io.element.android.libraries.matrix.api.linknewdevice.LinkDesktopStep +import io.element.android.libraries.matrix.api.logs.LoggerTags +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import timber.log.Timber + +private val loggerTag = LoggerTag("LinkNewDesktopHandler", LoggerTags.linkNewDevice) + +@Inject +@SingleIn(SessionScope::class) +class LinkNewDesktopHandler( + private val matrixClient: MatrixClient, +) { + private val sessionScope = matrixClient.sessionCoroutineScope + private val linkDesktopStepFlow = MutableStateFlow( + LinkDesktopStep.Uninitialized + ) + + val stepFlow: StateFlow + get() = linkDesktopStepFlow.asStateFlow() + + private var currentJob: Job? = null + private var handler: LinkDesktopHandler? = null + + fun createNewHandler() { + currentJob?.cancel() + currentJob = null + handler = matrixClient.createLinkDesktopHandler().getOrNull() + } + + fun reset() { + currentJob?.cancel() + currentJob = null + sessionScope.launch { + linkDesktopStepFlow.emit(LinkDesktopStep.Uninitialized) + } + } + + fun onScannedCode(data: ByteArray) { + currentJob?.cancel() + currentJob = null + val currentHandler = handler + if (currentHandler == null) { + Timber.tag(loggerTag.value).e("onScannedCode: Handler is not initialized. Call createNewHandler() first.") + } else { + currentJob = matrixClient.sessionCoroutineScope.launch { + currentHandler.linkDesktopStep.onEach { + linkDesktopStepFlow.emit(it) + }.launchIn(this) + currentHandler.handleScannedQrCode(data) + } + } + } +} diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewDeviceFlowNode.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewDeviceFlowNode.kt new file mode 100644 index 00000000000..23c6b6ab2dc --- /dev/null +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewDeviceFlowNode.kt @@ -0,0 +1,287 @@ +/* + * Copyright (c) 2025 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.linknewdevice.impl + +import android.app.Activity +import android.os.Parcelable +import androidx.activity.compose.LocalActivity +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.ui.Modifier +import com.bumble.appyx.core.lifecycle.subscribe +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import com.bumble.appyx.navmodel.backstack.BackStack +import com.bumble.appyx.navmodel.backstack.operation.newRoot +import com.bumble.appyx.navmodel.backstack.operation.pop +import com.bumble.appyx.navmodel.backstack.operation.push +import com.bumble.appyx.navmodel.backstack.operation.replace +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.linknewdevice.api.LinkNewDeviceEntryPoint +import io.element.android.features.linknewdevice.impl.screens.desktop.DesktopNoticeNode +import io.element.android.features.linknewdevice.impl.screens.error.ErrorNode +import io.element.android.features.linknewdevice.impl.screens.error.ErrorScreenType +import io.element.android.features.linknewdevice.impl.screens.number.EnterNumberNode +import io.element.android.features.linknewdevice.impl.screens.qrcode.ShowQrCodeNode +import io.element.android.features.linknewdevice.impl.screens.root.LinkNewDeviceRootNode +import io.element.android.features.linknewdevice.impl.screens.scan.ScanQrCodeNode +import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab +import io.element.android.libraries.architecture.BackstackView +import io.element.android.libraries.architecture.BaseFlowNode +import io.element.android.libraries.architecture.callback +import io.element.android.libraries.architecture.createNode +import io.element.android.libraries.core.log.logger.LoggerTag +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.di.annotations.SessionCoroutineScope +import io.element.android.libraries.matrix.api.linknewdevice.ErrorType +import io.element.android.libraries.matrix.api.linknewdevice.LinkDesktopStep +import io.element.android.libraries.matrix.api.linknewdevice.LinkMobileStep +import io.element.android.libraries.matrix.api.logs.LoggerTags +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.parcelize.Parcelize +import timber.log.Timber + +private val tag = LoggerTag("LinkNewDeviceFlowNode", LoggerTags.linkNewDevice) + +@ContributesNode(SessionScope::class) +@AssistedInject +class LinkNewDeviceFlowNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + @SessionCoroutineScope + private val sessionCoroutineScope: CoroutineScope, + private val linkNewMobileHandler: LinkNewMobileHandler, + private val linkNewDesktopHandler: LinkNewDesktopHandler, +) : BaseFlowNode( + backstack = BackStack( + initialElement = NavTarget.Root, + savedStateMap = buildContext.savedStateMap, + ), + buildContext = buildContext, + plugins = plugins, +) { + private val callback: LinkNewDeviceEntryPoint.Callback = callback() + private var activity: Activity? = null + private var darkTheme: Boolean = false + + override fun onBuilt() { + super.onBuilt() + var linkMobileHandlerJob: Job? = null + var linkDesktopHandlerJob: Job? = null + + lifecycle.subscribe( + onCreate = { + linkNewMobileHandler.reset() + linkNewDesktopHandler.reset() + @Suppress("AssignedValueIsNeverRead") + linkMobileHandlerJob = observeLinkNewMobileHandler() + @Suppress("AssignedValueIsNeverRead") + linkDesktopHandlerJob = observeLinkNewDesktopHandler() + }, + onDestroy = { + linkMobileHandlerJob?.cancel() + linkDesktopHandlerJob?.cancel() + } + ) + } + + sealed interface NavTarget : Parcelable { + // Will display the not supported state or the device type selection + @Parcelize + data object Root : NavTarget + + @Parcelize + data class MobileShowQrCode( + val data: String, + ) : NavTarget + + @Parcelize + data object MobileEnterNumber : NavTarget + + @Parcelize + data object DesktopNotice : NavTarget + + @Parcelize + data object DesktopScanQrCode : NavTarget + + @Parcelize + data class Error( + val errorScreenType: ErrorScreenType, + ) : NavTarget + } + + private fun observeLinkNewMobileHandler(): Job { + Timber.tag(tag.value).d("startObservingLinkNewMobileHandler") + return linkNewMobileHandler.stepFlow + .onEach { linkMobileStep -> + Timber.tag(tag.value).d("step: ${linkMobileStep::class.java.simpleName}") + when (linkMobileStep) { + LinkMobileStep.Uninitialized -> Unit + LinkMobileStep.Done -> { + callback.onDone() + } + is LinkMobileStep.Error -> { + navigateToError(linkMobileStep.errorType) + } + is LinkMobileStep.QrReady -> { + // The QrCode is ready, navigate to its display + backstack.push(NavTarget.MobileShowQrCode(linkMobileStep.data)) + } + is LinkMobileStep.QrScanned -> { + backstack.replace(NavTarget.MobileEnterNumber) + } + LinkMobileStep.Starting -> { + // This step is not received at the moment, so do nothing + } + LinkMobileStep.SyncingSecrets -> { + // LinkMobileStep.Done is not received at the moment, so consider that the flow is done here + callback.onDone() + } + is LinkMobileStep.WaitingForAuth -> { + navigateToBrowser(linkMobileStep.verificationUri) + } + } + } + .launchIn(sessionCoroutineScope) + } + + private fun observeLinkNewDesktopHandler(): Job { + Timber.tag(tag.value).d("startObservingLinkNewDesktopHandler") + return linkNewDesktopHandler.stepFlow.onEach { linkDesktopStep -> + Timber.tag(tag.value).d("step: ${linkDesktopStep::class.java.simpleName}") + when (linkDesktopStep) { + LinkDesktopStep.Done -> callback.onDone() + is LinkDesktopStep.Error -> { + navigateToError(linkDesktopStep.errorType) + } + is LinkDesktopStep.EstablishingSecureChannel -> Unit + is LinkDesktopStep.InvalidQrCode -> { + // This error will be handled by the ScanQrCodeNode + } + LinkDesktopStep.Starting -> Unit + LinkDesktopStep.SyncingSecrets -> Unit + LinkDesktopStep.Uninitialized -> Unit + is LinkDesktopStep.WaitingForAuth -> { + navigateToBrowser(linkDesktopStep.verificationUri) + } + } + } + .launchIn(sessionCoroutineScope) + } + + private fun navigateToError(errorType: ErrorType) { + // Map the error to an error screen + // TODO Update this mapping + val error = when (errorType) { + is ErrorType.DeviceIdAlreadyInUse -> ErrorScreenType.UnknownError + is ErrorType.InvalidCheckCode -> ErrorScreenType.InsecureChannelDetected + is ErrorType.MissingSecretsBackup -> ErrorScreenType.UnknownError + is ErrorType.NotFound -> ErrorScreenType.Expired + is ErrorType.UnableToCreateDevice -> ErrorScreenType.UnknownError + is ErrorType.Unknown -> ErrorScreenType.UnknownError + is ErrorType.UnsupportedProtocol -> ErrorScreenType.UnknownError + } + // It is OK to push on backstack, since when user leaves the error screen, a new root will be set + backstack.push(NavTarget.Error(error)) + } + + override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { + return when (navTarget) { + NavTarget.Root -> { + val callback = object : LinkNewDeviceRootNode.Callback { + override fun onDone() { + callback.onDone() + } + + override fun linkDesktopDevice() { + linkNewDesktopHandler.reset() + backstack.push(NavTarget.DesktopNotice) + } + } + createNode(buildContext, listOf(callback)) + } + NavTarget.DesktopNotice -> { + val callback = object : DesktopNoticeNode.Callback { + override fun navigateBack() { + backstack.pop() + } + + override fun navigateToQrCodeScanner() { + backstack.push(NavTarget.DesktopScanQrCode) + } + } + createNode(buildContext, listOf(callback)) + } + NavTarget.DesktopScanQrCode -> { + val callback = object : ScanQrCodeNode.Callback { + override fun cancel() { + backstack.pop() + } + } + createNode(buildContext, listOf(callback)) + } + NavTarget.MobileEnterNumber -> { + val callback = object : EnterNumberNode.Callback { + override fun navigateToWrongNumberError() { + backstack.push(NavTarget.Error(ErrorScreenType.Mismatch2Digits)) + } + + override fun navigateBack() { + backstack.pop() + } + } + createNode(buildContext, listOf(callback)) + } + is NavTarget.MobileShowQrCode -> { + val callback = object : ShowQrCodeNode.Callback { + override fun navigateBack() { + linkNewMobileHandler.reset() + backstack.pop() + } + } + val inputs = ShowQrCodeNode.Inputs( + data = navTarget.data, + ) + createNode(buildContext, listOf(inputs, callback)) + } + is NavTarget.Error -> { + val callback = object : ErrorNode.Callback { + override fun onRetry() { + linkNewMobileHandler.reset() + linkNewDesktopHandler.reset() + backstack.newRoot(NavTarget.Root) + } + } + createNode(buildContext, listOf(callback, navTarget.errorScreenType)) + } + } + } + + private fun navigateToBrowser(url: String) { + activity?.openUrlInChromeCustomTab(null, darkTheme, url) + } + + @Composable + override fun View(modifier: Modifier) { + activity = requireNotNull(LocalActivity.current) + darkTheme = !ElementTheme.isLightTheme + DisposableEffect(Unit) { + onDispose { + activity = null + } + } + BackstackView() + } +} diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewMobileHandler.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewMobileHandler.kt new file mode 100644 index 00000000000..157d946eaa3 --- /dev/null +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/LinkNewMobileHandler.kt @@ -0,0 +1,68 @@ +/* + * Copyright (c) 2025 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.linknewdevice.impl + +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn +import io.element.android.libraries.core.log.logger.LoggerTag +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.linknewdevice.LinkMobileHandler +import io.element.android.libraries.matrix.api.linknewdevice.LinkMobileStep +import io.element.android.libraries.matrix.api.logs.LoggerTags +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import timber.log.Timber + +private val loggerTag = LoggerTag("LinkNewMobileHandler", LoggerTags.linkNewDevice) + +@Inject +@SingleIn(SessionScope::class) +class LinkNewMobileHandler( + private val matrixClient: MatrixClient, +) { + private val sessionScope = matrixClient.sessionCoroutineScope + private var currentJob: Job? = null + private var handler: LinkMobileHandler? = null + + private val linkMobileStepFlow = MutableStateFlow( + LinkMobileStep.Uninitialized + ) + + val stepFlow: StateFlow + get() = linkMobileStepFlow.asStateFlow() + + fun createAndStartNewHandler() { + Timber.tag(loggerTag.value).d("createAndStartNewHandler()") + currentJob?.cancel() + handler = matrixClient.createLinkMobileHandler().getOrNull() + handler?.let { h -> + currentJob = sessionScope.launch { + h.linkMobileStep + .onEach { + linkMobileStepFlow.emit(it) + } + .launchIn(this) + h.start() + } + } + } + + fun reset() { + currentJob?.cancel() + currentJob = null + sessionScope.launch { + linkMobileStepFlow.emit(LinkMobileStep.Uninitialized) + } + } +} diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeEvent.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeEvent.kt new file mode 100644 index 00000000000..3d94da3fc6a --- /dev/null +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeEvent.kt @@ -0,0 +1,12 @@ +/* + * Copyright (c) 2025 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.linknewdevice.impl.screens.desktop + +sealed interface DesktopNoticeEvent { + data object Continue : DesktopNoticeEvent +} diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeNode.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeNode.kt new file mode 100644 index 00000000000..895d02731ab --- /dev/null +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeNode.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2025 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.linknewdevice.impl.screens.desktop + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.libraries.architecture.callback +import io.element.android.libraries.di.SessionScope + +@ContributesNode(SessionScope::class) +@AssistedInject +class DesktopNoticeNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val presenter: DesktopNoticePresenter, +) : Node(buildContext, plugins = plugins) { + interface Callback : Plugin { + fun navigateBack() + fun navigateToQrCodeScanner() + } + + private val callback: Callback = callback() + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + DesktopNoticeView( + state = state, + modifier = modifier, + onBackClick = callback::navigateBack, + onReadyToScanClick = callback::navigateToQrCodeScanner, + ) + } +} diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticePresenter.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticePresenter.kt new file mode 100644 index 00000000000..3b01725fe14 --- /dev/null +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticePresenter.kt @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2025 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.linknewdevice.impl.screens.desktop + +import android.Manifest +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.setValue +import dev.zacsweers.metro.Inject +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.permissions.api.PermissionsEvent +import io.element.android.libraries.permissions.api.PermissionsPresenter + +@Inject +class DesktopNoticePresenter( + permissionsPresenterFactory: PermissionsPresenter.Factory, +) : Presenter { + private val cameraPermissionPresenter: PermissionsPresenter = permissionsPresenterFactory.create(Manifest.permission.CAMERA) + private var pendingPermissionRequest by mutableStateOf(false) + + @Composable + override fun present(): DesktopNoticeState { + val cameraPermissionState = cameraPermissionPresenter.present() + var canContinue by remember { mutableStateOf(false) } + LaunchedEffect(cameraPermissionState.permissionGranted) { + if (cameraPermissionState.permissionGranted && pendingPermissionRequest) { + pendingPermissionRequest = false + canContinue = true + } + } + + fun handleEvent(event: DesktopNoticeEvent) { + when (event) { + DesktopNoticeEvent.Continue -> if (cameraPermissionState.permissionGranted) { + canContinue = true + } else { + pendingPermissionRequest = true + cameraPermissionState.eventSink(PermissionsEvent.RequestPermissions) + } + } + } + + return DesktopNoticeState( + cameraPermissionState = cameraPermissionState, + canContinue = canContinue, + eventSink = ::handleEvent, + ) + } +} diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeState.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeState.kt new file mode 100644 index 00000000000..81991b5ab2e --- /dev/null +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeState.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2025 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.linknewdevice.impl.screens.desktop + +import io.element.android.libraries.permissions.api.PermissionsState + +data class DesktopNoticeState( + val cameraPermissionState: PermissionsState, + val canContinue: Boolean, + val eventSink: (DesktopNoticeEvent) -> Unit, +) diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeStateProvider.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeStateProvider.kt new file mode 100644 index 00000000000..194bd6fafce --- /dev/null +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeStateProvider.kt @@ -0,0 +1,35 @@ +/* + * 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.linknewdevice.impl.screens.desktop + +import android.Manifest +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.permissions.api.PermissionsState +import io.element.android.libraries.permissions.api.aPermissionsState + +open class DesktopNoticeStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aDesktopNoticeState(), + aDesktopNoticeState(cameraPermissionState = aPermissionsState(showDialog = true, permission = Manifest.permission.CAMERA)), + ) +} + +fun aDesktopNoticeState( + cameraPermissionState: PermissionsState = aPermissionsState( + showDialog = false, + permission = Manifest.permission.CAMERA, + ), + canContinue: Boolean = false, + eventSink: (DesktopNoticeEvent) -> Unit = {}, +) = DesktopNoticeState( + cameraPermissionState = cameraPermissionState, + canContinue = canContinue, + eventSink = eventSink +) 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 new file mode 100644 index 00000000000..c7f0bb61e75 --- /dev/null +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeView.kt @@ -0,0 +1,112 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +@file:OptIn(ExperimentalMaterial3Api::class) + +package io.element.android.features.linknewdevice.impl.screens.desktop + +import androidx.compose.foundation.layout.Column +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.material3.ExperimentalMaterial3Api +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.linknewdevice.impl.R +import io.element.android.libraries.designsystem.atomic.organisms.NumberedListOrganism +import io.element.android.libraries.designsystem.atomic.pages.FlowStepPage +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.LocalBuildMeta +import io.element.android.libraries.designsystem.theme.components.Button +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.utils.annotatedTextWithBold +import io.element.android.libraries.permissions.api.PermissionsView +import kotlinx.collections.immutable.persistentListOf + +/** + * Desktop notice screen: + * https://www.figma.com/design/pDlJZGBsri47FNTXMnEdXB/Compound-Android-Templates?node-id=2027-23618 + */ +@Composable +fun DesktopNoticeView( + state: DesktopNoticeState, + onBackClick: () -> Unit, + onReadyToScanClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val latestOnReadyToScanClick by rememberUpdatedState(onReadyToScanClick) + LaunchedEffect(state.canContinue) { + if (state.canContinue) { + latestOnReadyToScanClick() + } + } + + val appName = LocalBuildMeta.current.applicationName + FlowStepPage( + onBackClick = onBackClick, + title = stringResource(R.string.screen_link_new_device_desktop_title, appName), + iconStyle = BigIcon.Style.Default(CompoundIcons.Computer()), + modifier = modifier, + buttons = { + Button( + text = stringResource(R.string.screen_link_new_device_desktop_submit), + onClick = { state.eventSink(DesktopNoticeEvent.Continue) }, + modifier = Modifier.fillMaxWidth(), + ) + } + ) { + Column( + Modifier.fillMaxWidth() + ) { + Spacer(modifier = Modifier.height(40.dp)) + NumberedListOrganism( + modifier = Modifier.fillMaxSize(), + items = persistentListOf( + AnnotatedString(stringResource(R.string.screen_link_new_device_desktop_step1, appName)), + annotatedTextWithBold( + text = stringResource( + id = R.string.screen_link_new_device_mobile_step2, + stringResource(R.string.screen_link_new_device_mobile_step2_action), + ), + boldText = stringResource(R.string.screen_link_new_device_mobile_step2_action) + ), + AnnotatedString(stringResource(R.string.screen_link_new_device_desktop_step3)), + ) + ) + } + } + + PermissionsView( + title = stringResource(R.string.screen_qr_code_login_no_camera_permission_state_title), + content = stringResource(R.string.screen_qr_code_login_no_camera_permission_state_description, appName), + icon = { Icon(imageVector = CompoundIcons.TakePhotoSolid(), contentDescription = null) }, + state = state.cameraPermissionState, + ) +} + +@PreviewsDayNight +@Composable +internal fun DesktopNoticeViewPreview( + @PreviewParameter(DesktopNoticeStateProvider::class) state: DesktopNoticeState, +) = ElementPreview { + DesktopNoticeView( + state = state, + onBackClick = { }, + onReadyToScanClick = { }, + ) +} diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/error/ErrorNode.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/error/ErrorNode.kt new file mode 100644 index 00000000000..70fd3b49a47 --- /dev/null +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/error/ErrorNode.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2025 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.linknewdevice.impl.screens.error + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.libraries.architecture.callback +import io.element.android.libraries.architecture.inputs +import io.element.android.libraries.di.SessionScope + +@ContributesNode(SessionScope::class) +@AssistedInject +class ErrorNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, +) : Node(buildContext = buildContext, plugins = plugins) { + interface Callback : Plugin { + fun onRetry() + } + + private val callback: Callback = callback() + private val errorScreenType = inputs() + + @Composable + override fun View(modifier: Modifier) { + ErrorView( + modifier = modifier, + errorScreenType = errorScreenType, + onRetry = callback::onRetry, + ) + } +} diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/error/ErrorScreenType.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/error/ErrorScreenType.kt new file mode 100644 index 00000000000..b92a19ef8ae --- /dev/null +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/error/ErrorScreenType.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2025 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.linknewdevice.impl.screens.error + +import android.os.Parcelable +import androidx.compose.runtime.Immutable +import io.element.android.libraries.architecture.NodeInputs +import kotlinx.parcelize.Parcelize + +@Immutable +sealed interface ErrorScreenType : NodeInputs, Parcelable { + @Parcelize + data object Cancelled : ErrorScreenType + + @Parcelize + data object Expired : ErrorScreenType + + @Parcelize + data object Mismatch2Digits : ErrorScreenType + + @Parcelize + data object InsecureChannelDetected : ErrorScreenType + + @Parcelize + data object Declined : ErrorScreenType + + @Parcelize + data object ProtocolNotSupported : ErrorScreenType + + @Parcelize + data object SlidingSyncNotAvailable : ErrorScreenType + + @Parcelize + data object UnknownError : ErrorScreenType +} diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/error/ErrorScreenTypeProvider.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/error/ErrorScreenTypeProvider.kt new file mode 100644 index 00000000000..7fd699101be --- /dev/null +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/error/ErrorScreenTypeProvider.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 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.linknewdevice.impl.screens.error + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider + +class ErrorScreenTypeProvider : PreviewParameterProvider { + override val values: Sequence = sequenceOf( + ErrorScreenType.Cancelled, + ErrorScreenType.Declined, + ErrorScreenType.Expired, + ErrorScreenType.ProtocolNotSupported, + ErrorScreenType.Mismatch2Digits, + ErrorScreenType.InsecureChannelDetected, + ErrorScreenType.SlidingSyncNotAvailable, + ErrorScreenType.UnknownError, + ) +} diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/error/ErrorView.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/error/ErrorView.kt new file mode 100644 index 00000000000..3a77f19f499 --- /dev/null +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/error/ErrorView.kt @@ -0,0 +1,138 @@ +/* + * Copyright (c) 2025 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.linknewdevice.impl.screens.error + +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +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.text.AnnotatedString +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.linknewdevice.impl.R +import io.element.android.libraries.designsystem.atomic.organisms.NumberedListOrganism +import io.element.android.libraries.designsystem.atomic.pages.FlowStepPage +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.LocalBuildMeta +import io.element.android.libraries.designsystem.theme.components.Button +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.collections.immutable.persistentListOf + +@Composable +fun ErrorView( + errorScreenType: ErrorScreenType, + onRetry: () -> Unit, + modifier: Modifier = Modifier, +) { + val appName = LocalBuildMeta.current.applicationName + BackHandler(onBack = onRetry) + FlowStepPage( + modifier = modifier, + iconStyle = BigIcon.Style.AlertSolid, + title = titleText(errorScreenType, appName), + subTitle = subtitleText(errorScreenType, appName), + content = { Content(errorScreenType) }, + buttons = { Buttons(onRetry) }, + ) +} + +@Composable +private fun titleText(errorScreenType: ErrorScreenType, appName: String) = when (errorScreenType) { + ErrorScreenType.Cancelled -> stringResource(R.string.screen_qr_code_login_error_cancelled_title) + ErrorScreenType.Declined -> stringResource(R.string.screen_qr_code_login_error_declined_title) + ErrorScreenType.Expired -> stringResource(R.string.screen_qr_code_login_error_expired_title) + ErrorScreenType.ProtocolNotSupported -> stringResource(R.string.screen_qr_code_login_error_linking_not_suported_title) + ErrorScreenType.InsecureChannelDetected -> stringResource(id = R.string.screen_qr_code_login_connection_note_secure_state_title) + ErrorScreenType.Mismatch2Digits -> stringResource(id = R.string.screen_link_new_device_wrong_number_title) + ErrorScreenType.SlidingSyncNotAvailable -> stringResource(id = R.string.screen_qr_code_login_error_sliding_sync_not_supported_title, appName) + is ErrorScreenType.UnknownError -> stringResource(CommonStrings.common_something_went_wrong) +} + +@Composable +private fun subtitleText(errorScreenType: ErrorScreenType, appName: String) = when (errorScreenType) { + ErrorScreenType.Cancelled -> stringResource(R.string.screen_qr_code_login_error_cancelled_subtitle) + ErrorScreenType.Declined -> stringResource(R.string.screen_qr_code_login_error_declined_subtitle) + ErrorScreenType.Expired -> stringResource(R.string.screen_qr_code_login_error_expired_subtitle) + ErrorScreenType.ProtocolNotSupported -> stringResource(R.string.screen_qr_code_login_error_linking_not_suported_subtitle, appName) + ErrorScreenType.Mismatch2Digits -> stringResource(id = R.string.screen_link_new_device_wrong_number_subtitle) + ErrorScreenType.InsecureChannelDetected -> stringResource(id = R.string.screen_qr_code_login_connection_note_secure_state_description) + ErrorScreenType.SlidingSyncNotAvailable -> stringResource(id = R.string.screen_qr_code_login_error_sliding_sync_not_supported_subtitle, appName) + is ErrorScreenType.UnknownError -> stringResource(R.string.screen_qr_code_login_unknown_error_description) +} + +@Composable +private fun ColumnScope.InsecureChannelDetectedError() { + Text( + modifier = Modifier + .fillMaxWidth() + .verticalScroll(rememberScrollState()), + text = stringResource(R.string.screen_qr_code_login_connection_note_secure_state_list_header), + style = ElementTheme.typography.fontBodyLgMedium, + textAlign = TextAlign.Center, + ) + NumberedListOrganism( + modifier = Modifier.fillMaxSize(), + items = persistentListOf( + AnnotatedString(stringResource(R.string.screen_qr_code_login_connection_note_secure_state_list_item_1)), + AnnotatedString(stringResource(R.string.screen_qr_code_login_connection_note_secure_state_list_item_2)), + AnnotatedString(stringResource(R.string.screen_qr_code_login_connection_note_secure_state_list_item_3)), + ) + ) +} + +@Composable +private fun Content(errorScreenType: ErrorScreenType) { + when (errorScreenType) { + ErrorScreenType.InsecureChannelDetected -> { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(top = 20.dp), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(24.dp) + ) { + InsecureChannelDetectedError() + } + } + else -> Unit + } +} + +@Composable +private fun Buttons(onRetry: () -> Unit) { + Button( + modifier = Modifier.fillMaxWidth(), + text = stringResource(CommonStrings.action_start_over), + onClick = onRetry + ) +} + +@PreviewsDayNight +@Composable +internal fun ErrorViewPreview(@PreviewParameter(ErrorScreenTypeProvider::class) errorScreenType: ErrorScreenType) { + ElementPreview { + ErrorView( + errorScreenType = errorScreenType, + onRetry = {}, + ) + } +} diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/Config.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/Config.kt new file mode 100644 index 00000000000..b61cc82a22b --- /dev/null +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/Config.kt @@ -0,0 +1,12 @@ +/* + * Copyright (c) 2025 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.linknewdevice.impl.screens.number + +object Config { + const val VERIFICATION_CODE_LENGTH = 2 +} diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberEvent.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberEvent.kt new file mode 100644 index 00000000000..267b5086697 --- /dev/null +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberEvent.kt @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2025 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.linknewdevice.impl.screens.number + +sealed interface EnterNumberEvent { + data class UpdateNumber(val number: String) : EnterNumberEvent + data object Continue : EnterNumberEvent +} diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberNode.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberNode.kt new file mode 100644 index 00000000000..08d8cd02bd9 --- /dev/null +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberNode.kt @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2025 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.linknewdevice.impl.screens.number + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.libraries.architecture.callback +import io.element.android.libraries.di.SessionScope + +interface EnterNumberNavigator { + fun navigateToWrongNumberError() +} + +@ContributesNode(SessionScope::class) +@AssistedInject +class EnterNumberNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + presenterFactory: EnterNumberPresenter.Factory, +) : Node(buildContext, plugins = plugins), EnterNumberNavigator { + private val presenter = presenterFactory.create(this) + + interface Callback : Plugin { + fun navigateToWrongNumberError() + fun navigateBack() + } + + private val callback: Callback = callback() + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + EnterNumberView( + state = state, + modifier = modifier, + onBackClick = callback::navigateBack, + ) + } + + override fun navigateToWrongNumberError() { + callback.navigateToWrongNumberError() + } +} diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberPresenter.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberPresenter.kt new file mode 100644 index 00000000000..74b5b6b2948 --- /dev/null +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberPresenter.kt @@ -0,0 +1,108 @@ +/* + * Copyright (c) 2025 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.linknewdevice.impl.screens.number + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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 androidx.compose.runtime.setValue +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedFactory +import dev.zacsweers.metro.AssistedInject +import io.element.android.features.linknewdevice.impl.LinkNewMobileHandler +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.core.log.logger.LoggerTag +import io.element.android.libraries.matrix.api.linknewdevice.CheckCodeSender +import io.element.android.libraries.matrix.api.linknewdevice.LinkMobileStep +import io.element.android.libraries.matrix.api.logs.LoggerTags +import kotlinx.coroutines.launch +import timber.log.Timber + +private val tag = LoggerTag("EnterNumberPresenter", LoggerTags.linkNewDevice) + +@AssistedInject +class EnterNumberPresenter( + @Assisted private val navigator: EnterNumberNavigator, + private val linkNewMobileHandler: LinkNewMobileHandler, +) : Presenter { + @AssistedFactory + interface Factory { + fun create(navigator: EnterNumberNavigator): EnterNumberPresenter + } + + @Composable + override fun present(): EnterNumberState { + val coroutineScope = rememberCoroutineScope() + var number by remember { mutableStateOf("") } + var sendingCode by remember>> { mutableStateOf(AsyncAction.Uninitialized) } + + // Observe the flow to react on ErrorType.InvalidCheckCode + val linkMobileStep by linkNewMobileHandler.stepFlow.collectAsState() + + var checkCodeSender: CheckCodeSender? by remember { mutableStateOf(null) } + + LaunchedEffect(linkMobileStep) { + when (val step = linkMobileStep) { + is LinkMobileStep.QrScanned -> { + checkCodeSender = step.checkCodeSender + } + else -> Unit + } + } + + fun handleEvent(event: EnterNumberEvent) { + when (event) { + is EnterNumberEvent.UpdateNumber -> { + sendingCode = AsyncAction.Uninitialized + // Keep only digits as a safety measure + number = event.number.filter { it.isDigit() } + } + EnterNumberEvent.Continue -> coroutineScope.launch { + // Get the current code sender + val sender = checkCodeSender + if (sender == null) { + Timber.tag(tag.value).e("No check code sender available") + sendingCode = AsyncAction.Failure(IllegalStateException("No check code sender available")) + } else { + sendingCode = AsyncAction.Loading + val uByte = number.toUByte() + val isValid = sender.validate(uByte) + if (isValid) { + sender.send(uByte) + .fold( + onSuccess = { + Timber.tag(tag.value).d("Code sent successfully") + // Keep loading, do not set sendingCode to AsyncAction.Success(Unit) + }, + onFailure = { + Timber.tag(tag.value).e(it, "Failed to send number code") + sendingCode = AsyncAction.Failure(it) + } + ) + } else { + // Navigate to the error state + navigator.navigateToWrongNumberError() + } + } + } + } + } + + return EnterNumberState( + number = number, + sendingCode = sendingCode, + eventSink = ::handleEvent, + ) + } +} diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberState.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberState.kt new file mode 100644 index 00000000000..b8f66018dd4 --- /dev/null +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberState.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025 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.linknewdevice.impl.screens.number + +import io.element.android.features.linknewdevice.impl.screens.number.model.Number +import io.element.android.libraries.architecture.AsyncAction + +data class EnterNumberState( + val number: String, + val sendingCode: AsyncAction, + val eventSink: (EnterNumberEvent) -> Unit, +) { + val numberEntry = Number.createEmpty(Config.VERIFICATION_CODE_LENGTH).fillWith(number) + val isContinueButtonEnabled: Boolean + get() = numberEntry.isComplete() && !sendingCode.isLoading() +} diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberStateProvider.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberStateProvider.kt new file mode 100644 index 00000000000..126bfeee52d --- /dev/null +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberStateProvider.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2025 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.linknewdevice.impl.screens.number + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.api.linknewdevice.ErrorType + +open class EnterNumberStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aEnterNumberState(), + aEnterNumberState(number = "1"), + aEnterNumberState(number = "12"), + aEnterNumberState(number = "12", sendingCode = AsyncAction.Loading), + aEnterNumberState(number = "12", sendingCode = AsyncAction.Failure(ErrorType.InvalidCheckCode("Invalid"))), + aEnterNumberState(number = "12", sendingCode = AsyncAction.Failure(Exception("Failed to send code"))), + ) +} + +fun aEnterNumberState( + number: String = "", + sendingCode: AsyncAction = AsyncAction.Uninitialized, + eventSink: (EnterNumberEvent) -> Unit = {}, +) = EnterNumberState( + number = number, + sendingCode = sendingCode, + eventSink = eventSink, +) 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 new file mode 100644 index 00000000000..240a3143aff --- /dev/null +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberView.kt @@ -0,0 +1,126 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +@file:OptIn(ExperimentalMaterial3Api::class) + +package io.element.android.features.linknewdevice.impl.screens.number + +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.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 +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.compound.tokens.generated.CompoundIcons +import io.element.android.features.linknewdevice.impl.R +import io.element.android.features.linknewdevice.impl.screens.number.component.NumberTextField +import io.element.android.libraries.designsystem.atomic.pages.FlowStepPage +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.Icon +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.matrix.api.linknewdevice.ErrorType +import io.element.android.libraries.ui.strings.CommonStrings + +/** + * Form to enter number: + * https://www.figma.com/design/pDlJZGBsri47FNTXMnEdXB/Compound-Android-Templates?node-id=2076-81604 + */ +@Composable +fun EnterNumberView( + state: EnterNumberState, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + FlowStepPage( + onBackClick = onBackClick, + title = stringResource(R.string.screen_link_new_device_enter_number_title), + subTitle = stringResource(R.string.screen_link_new_device_enter_number_subtitle), + iconStyle = BigIcon.Style.Default(CompoundIcons.Computer()), + modifier = modifier, + isScrollable = true, + buttons = { + Button( + text = stringResource(CommonStrings.action_continue), + onClick = { state.eventSink(EnterNumberEvent.Continue) }, + enabled = state.isContinueButtonEnabled, + showProgress = state.sendingCode.isLoading(), + modifier = Modifier.fillMaxWidth(), + ) + } + ) { + Column( + Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Spacer(modifier = Modifier.height(24.dp)) + Text( + text = stringResource(R.string.screen_link_new_device_enter_number_notice), + textAlign = TextAlign.Center, + style = ElementTheme.typography.fontBodyMdRegular, + color = ElementTheme.colors.textPrimary, + ) + Spacer(modifier = Modifier.height(8.dp)) + NumberTextField( + number = state.numberEntry, + onValueChange = { state.eventSink(EnterNumberEvent.UpdateNumber(it)) }, + onDone = { + if (state.isContinueButtonEnabled) { + state.eventSink(EnterNumberEvent.Continue) + } + }, + ) + val failure = state.sendingCode.errorOrNull() + if (failure != null) { + Spacer(modifier = Modifier.height(4.dp)) + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(4.dp) + ) { + Icon( + modifier = Modifier.size(14.dp), + imageVector = CompoundIcons.ErrorSolid(), + contentDescription = null, + tint = ElementTheme.colors.iconCriticalPrimary, + ) + val errorMessage = when (failure) { + is ErrorType.InvalidCheckCode -> stringResource(R.string.screen_link_new_device_enter_number_error_numbers_do_not_match) + else -> failure.message ?: stringResource(CommonStrings.error_unknown) + } + Text( + text = errorMessage, + style = ElementTheme.typography.fontBodySmRegular, + color = ElementTheme.colors.textCriticalPrimary, + ) + } + } + } + } +} + +@PreviewsDayNight +@Composable +internal fun EnterNumberViewPreview( + @PreviewParameter(EnterNumberStateProvider::class) state: EnterNumberState, +) = ElementPreview { + EnterNumberView( + state = state, + onBackClick = { }, + ) +} diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/component/NumberTextField.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/component/NumberTextField.kt new file mode 100644 index 00000000000..568a729ed6e --- /dev/null +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/component/NumberTextField.kt @@ -0,0 +1,169 @@ +/* + * Copyright (c) 2025 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.linknewdevice.impl.screens.number.component + +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.interaction.collectIsFocusedAsState +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.ExperimentalLayoutApi +import androidx.compose.foundation.layout.FlowRow +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.Text +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.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.features.linknewdevice.impl.screens.number.model.Digit +import io.element.android.features.linknewdevice.impl.screens.number.model.Number +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import kotlinx.coroutines.delay + +@Composable +fun NumberTextField( + number: Number, + onValueChange: (String) -> Unit, + onDone: () -> Unit, + modifier: Modifier = Modifier, +) { + val interactionSource = remember { MutableInteractionSource() } + val isFocused = LocalInspectionMode.current || interactionSource.collectIsFocusedAsState().value + BasicTextField( + modifier = modifier, + value = number.toText(), + onValueChange = { + onValueChange(it) + }, + interactionSource = interactionSource, + maxLines = 1, + keyboardOptions = KeyboardOptions( + keyboardType = KeyboardType.Number, + imeAction = ImeAction.Done, + ), + keyboardActions = KeyboardActions( + onDone = { + onDone() + } + ), + decorationBox = { + NumberRow( + number = number, + hasFocus = isFocused, + ) + } + ) +} + +@OptIn(ExperimentalLayoutApi::class) +@Composable +private fun NumberRow( + number: Number, + hasFocus: Boolean, +) { + FlowRow( + horizontalArrangement = Arrangement.spacedBy(12.dp, alignment = Alignment.CenterHorizontally), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + val length = number.length() + number.digits.forEachIndexed { index, digit -> + DigitView( + digit = digit, + isCurrent = index == length, + drawCursor = hasFocus, + ) + } + } +} + +@Composable +private fun DigitView( + digit: Digit, + isCurrent: Boolean, + drawCursor: Boolean, +) { + val shape = RoundedCornerShape(4.dp) + val appearanceModifier = when (digit) { + Digit.Empty -> { + val color = if (isCurrent) { + ElementTheme.colors.textPrimary + } else { + ElementTheme.colors.borderInteractiveSecondary + } + Modifier.border(1.dp, color, shape) + } + is Digit.Filled -> { + Modifier.background(ElementTheme.colors.bgActionSecondaryPressed, shape) + } + } + Box( + modifier = Modifier + .size(42.dp, 56.dp) + .then(appearanceModifier), + contentAlignment = Alignment.Center, + ) { + if (digit is Digit.Filled) { + Text( + text = digit.value.toString(), + style = ElementTheme.typography.fontHeadingLgBold, + color = ElementTheme.colors.textPrimary, + ) + } else if (drawCursor && isCurrent) { + // Draw a blinking cursor + BlinkingCursor() + } + } +} + +@Composable +private fun BlinkingCursor() { + var isCursorVisible by remember { mutableStateOf(true) } + LaunchedEffect(isCursorVisible) { + delay(500) + // Toggle cursor visibility + isCursorVisible = !isCursorVisible + } + if (isCursorVisible) { + Spacer( + modifier = Modifier + .size(2.dp, 24.dp) + .offset(x = (-5).dp) + .background(ElementTheme.colors.textPrimary, RoundedCornerShape(1.dp)) + ) + } +} + +@PreviewsDayNight +@Composable +internal fun NumberTextFieldPreview() { + ElementPreview { + val number = Number.createEmpty(4).fillWith("12") + NumberTextField( + number = number, + onValueChange = {}, + onDone = {}, + ) + } +} diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/model/Digit.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/model/Digit.kt new file mode 100644 index 00000000000..b8565ea6ca0 --- /dev/null +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/model/Digit.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 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.linknewdevice.impl.screens.number.model + +import androidx.compose.runtime.Immutable + +@Immutable +sealed interface Digit { + data object Empty : Digit + data class Filled(val value: Char) : Digit + + fun toText(): String { + return when (this) { + is Empty -> "" + is Filled -> value.toString() + } + } +} diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/model/Number.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/model/Number.kt new file mode 100644 index 00000000000..be60f27f76f --- /dev/null +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/number/model/Number.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2025 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.linknewdevice.impl.screens.number.model + +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.toImmutableList + +data class Number( + val digits: ImmutableList, +) { + companion object { + fun createEmpty(size: Int): Number { + val digits = List(size) { Digit.Empty } + return Number( + digits = digits.toImmutableList() + ) + } + } + + val size = digits.size + + /** + * Fill the first digits with the given text. + * Can't be more than the size of the NumberEntry + * Keep the Empty digits at the end + * @return the new NumberEntry + */ + fun fillWith(text: String): Number { + val newDigits = MutableList(size) { Digit.Empty } + text.forEachIndexed { index, char -> + if (index < size && char.isDigit()) { + newDigits[index] = Digit.Filled(char) + } + } + return copy(digits = newDigits.toImmutableList()) + } + + fun length(): Int { + return digits.count { it is Digit.Filled } + } + + fun toText(): String { + return digits.joinToString("") { + it.toText() + } + } + + fun isComplete(): Boolean { + return digits.all { it is Digit.Filled } + } +} diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeNode.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeNode.kt new file mode 100644 index 00000000000..a884c3e97f5 --- /dev/null +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeNode.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2025 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.linknewdevice.impl.screens.qrcode + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.libraries.architecture.NodeInputs +import io.element.android.libraries.architecture.callback +import io.element.android.libraries.architecture.inputs +import io.element.android.libraries.di.SessionScope + +@ContributesNode(SessionScope::class) +@AssistedInject +class ShowQrCodeNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, +) : Node(buildContext, plugins = plugins) { + class Inputs( + val data: String, + ) : NodeInputs + + interface Callback : Plugin { + fun navigateBack() + } + + private val inputs: Inputs = inputs() + private val callback: Callback = callback() + + @Composable + override fun View(modifier: Modifier) { + ShowQrCodeView( + data = inputs.data, + modifier = modifier, + onBackClick = callback::navigateBack, + ) + } +} 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 new file mode 100644 index 00000000000..501415f621c --- /dev/null +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeView.kt @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +@file:OptIn(ExperimentalMaterial3Api::class) + +package io.element.android.features.linknewdevice.impl.screens.qrcode + +import androidx.compose.foundation.layout.Column +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.size +import androidx.compose.material3.ExperimentalMaterial3Api +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.text.AnnotatedString +import androidx.compose.ui.unit.dp +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.linknewdevice.impl.R +import io.element.android.libraries.designsystem.atomic.organisms.NumberedListOrganism +import io.element.android.libraries.designsystem.atomic.pages.FlowStepPage +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.LocalBuildMeta +import io.element.android.libraries.designsystem.utils.annotatedTextWithBold +import io.element.android.libraries.qrcode.QrCodeImage +import kotlinx.collections.immutable.persistentListOf + +/** + * QrCode display screen: + * https://www.figma.com/design/pDlJZGBsri47FNTXMnEdXB/Compound-Android-Templates?node-id=2027-23617 + */ +@Composable +fun ShowQrCodeView( + data: String, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val appName = LocalBuildMeta.current.applicationName + FlowStepPage( + onBackClick = onBackClick, + title = stringResource(R.string.screen_link_new_device_mobile_title, appName), + iconStyle = BigIcon.Style.Default(CompoundIcons.TakePhotoSolid()), + modifier = modifier, + ) { + Column( + Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + QrCodeImage( + data = data, + modifier = Modifier + .size(220.dp) + ) + Spacer(modifier = Modifier.height(32.dp)) + NumberedListOrganism( + modifier = Modifier.fillMaxSize(), + items = persistentListOf( + AnnotatedString(stringResource(R.string.screen_link_new_device_mobile_step1, appName)), + annotatedTextWithBold( + text = stringResource( + id = R.string.screen_link_new_device_mobile_step2, + stringResource(R.string.screen_link_new_device_mobile_step2_action), + ), + boldText = stringResource(R.string.screen_link_new_device_mobile_step2_action) + ), + AnnotatedString(stringResource(R.string.screen_link_new_device_mobile_step3)), + ) + ) + } + } +} + +@PreviewsDayNight +@Composable +internal fun ShowQrCodeViewPreview() = ElementPreview { + ShowQrCodeView( + data = "DATA", + onBackClick = { }, + ) +} diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootEvent.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootEvent.kt new file mode 100644 index 00000000000..8ce6af90b01 --- /dev/null +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootEvent.kt @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2025 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.linknewdevice.impl.screens.root + +sealed interface LinkNewDeviceRootEvent { + data object LinkMobileDevice : LinkNewDeviceRootEvent + data object CloseDialog : LinkNewDeviceRootEvent +} diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootNode.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootNode.kt new file mode 100644 index 00000000000..f43ffa64df8 --- /dev/null +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootNode.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2025 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.linknewdevice.impl.screens.root + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.libraries.architecture.callback +import io.element.android.libraries.di.SessionScope + +@ContributesNode(SessionScope::class) +@AssistedInject +class LinkNewDeviceRootNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val presenter: LinkNewDeviceRootPresenter, +) : Node(buildContext, plugins = plugins) { + interface Callback : Plugin { + fun onDone() + fun linkDesktopDevice() + } + + private val callback: Callback = callback() + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + LinkNewDeviceRootView( + state = state, + modifier = modifier, + onBackClick = callback::onDone, + onLinkDesktopDeviceClick = callback::linkDesktopDevice, + ) + } +} diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootPresenter.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootPresenter.kt new file mode 100644 index 00000000000..a17ed88fd3e --- /dev/null +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootPresenter.kt @@ -0,0 +1,85 @@ +/* + * Copyright (c) 2025 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.linknewdevice.impl.screens.root + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import dev.zacsweers.metro.Inject +import io.element.android.features.linknewdevice.impl.LinkNewMobileHandler +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.linknewdevice.LinkMobileStep +import kotlinx.coroutines.launch + +@Inject +class LinkNewDeviceRootPresenter( + private val matrixClient: MatrixClient, + private val linkNewMobileHandler: LinkNewMobileHandler, +) : Presenter { + @Composable + override fun present(): LinkNewDeviceRootState { + val coroutineScope = rememberCoroutineScope() + var isSupported by remember { mutableStateOf>(AsyncData.Uninitialized) } + var qrCodeData by remember { mutableStateOf>(AsyncData.Uninitialized) } + + LaunchedEffect(Unit) { + matrixClient.canLinkNewDevice().fold( + onSuccess = { supported -> + isSupported = AsyncData.Success(supported) + }, + onFailure = { + isSupported = AsyncData.Failure(it) + } + ) + } + + val step by linkNewMobileHandler.stepFlow.collectAsState() + + LaunchedEffect(step) { + when (val finalStep = step) { + is LinkMobileStep.Uninitialized -> { + qrCodeData = AsyncData.Uninitialized + } + is LinkMobileStep.QrReady -> { + qrCodeData = AsyncData.Success(Unit) + } + is LinkMobileStep.Error -> { + qrCodeData = AsyncData.Failure(finalStep.errorType) + } + else -> Unit + } + } + + fun handleEvent(event: LinkNewDeviceRootEvent) { + when (event) { + LinkNewDeviceRootEvent.LinkMobileDevice -> coroutineScope.launch { + qrCodeData = AsyncData.Loading() + // Wait for the QrCode to be ready + linkNewMobileHandler.reset() + linkNewMobileHandler.createAndStartNewHandler() + } + LinkNewDeviceRootEvent.CloseDialog -> coroutineScope.launch { + linkNewMobileHandler.reset() + } + } + } + + return LinkNewDeviceRootState( + isSupported = isSupported, + qrCodeData = qrCodeData, + eventSink = ::handleEvent, + ) + } +} diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootState.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootState.kt new file mode 100644 index 00000000000..6cf6694b2a7 --- /dev/null +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootState.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2025 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.linknewdevice.impl.screens.root + +import io.element.android.libraries.architecture.AsyncData + +data class LinkNewDeviceRootState( + val isSupported: AsyncData, + val qrCodeData: AsyncData, + val eventSink: (LinkNewDeviceRootEvent) -> Unit, +) diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootStateProvider.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootStateProvider.kt new file mode 100644 index 00000000000..f1bb7ad4555 --- /dev/null +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootStateProvider.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2025 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.linknewdevice.impl.screens.root + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.matrix.api.linknewdevice.ErrorType + +open class LinkNewDeviceRootStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aLinkNewDeviceRootState(), + aLinkNewDeviceRootState(isSupported = AsyncData.Success(true)), + aLinkNewDeviceRootState(isSupported = AsyncData.Success(false)), + aLinkNewDeviceRootState(isSupported = AsyncData.Failure(Exception("Should not happen"))), + aLinkNewDeviceRootState( + isSupported = AsyncData.Success(true), + qrCodeData = AsyncData.Loading(), + ), + aLinkNewDeviceRootState( + isSupported = AsyncData.Success(true), + qrCodeData = AsyncData.Failure(ErrorType.NotFound("The rendezvous session was not found and might have expired")), + ), + ) +} + +fun aLinkNewDeviceRootState( + isSupported: AsyncData = AsyncData.Uninitialized, + qrCodeData: AsyncData = AsyncData.Uninitialized, + eventSink: (LinkNewDeviceRootEvent) -> Unit = { }, +) = LinkNewDeviceRootState( + isSupported = isSupported, + qrCodeData = qrCodeData, + eventSink = eventSink, +) 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 new file mode 100644 index 00000000000..d9249d3e89b --- /dev/null +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootView.kt @@ -0,0 +1,152 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +@file:OptIn(ExperimentalMaterial3Api::class) + +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 +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.PreviewParameter +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.linknewdevice.impl.R +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.designsystem.atomic.atoms.LoadingButtonAtom +import io.element.android.libraries.designsystem.atomic.pages.FlowStepPage +import io.element.android.libraries.designsystem.components.BigIcon +import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog +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.Text +import io.element.android.libraries.ui.strings.CommonStrings + +/** + * Device selection screen: + * https://www.figma.com/design/pDlJZGBsri47FNTXMnEdXB/Compound-Android-Templates?node-id=2027-23616 + * Not supported screen: + * https://www.figma.com/design/pDlJZGBsri47FNTXMnEdXB/Compound-Android-Templates?node-id=2186-70004 + */ +@Composable +fun LinkNewDeviceRootView( + state: LinkNewDeviceRootState, + onBackClick: () -> Unit, + onLinkDesktopDeviceClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val (title, subtitle, iconStyle) = if (state.isSupported.dataOrNull() == false) { + Triple( + stringResource(R.string.screen_link_new_device_error_not_supported_title), + stringResource(R.string.screen_link_new_device_error_not_supported_subtitle), + BigIcon.Style.AlertSolid + ) + } else { + Triple( + stringResource(R.string.screen_link_new_device_root_title), + null, + BigIcon.Style.Default(CompoundIcons.Devices()) + ) + } + FlowStepPage( + onBackClick = onBackClick, + title = title, + subTitle = subtitle, + iconStyle = iconStyle, + buttons = { + when (state.isSupported) { + is AsyncData.Uninitialized, + is AsyncData.Loading -> { + LoadingButtonAtom() + } + is AsyncData.Failure -> { + Text( + text = stringResource(id = CommonStrings.error_unknown), + color = ElementTheme.colors.textCriticalPrimary, + style = ElementTheme.typography.fontBodyMdRegular, + textAlign = TextAlign.Center, + ) + Button( + onClick = onBackClick, + text = stringResource(CommonStrings.action_dismiss), + modifier = Modifier.fillMaxWidth(), + ) + } + is AsyncData.Success -> { + if (state.isSupported.data) { + when (state.qrCodeData) { + AsyncData.Uninitialized, + is AsyncData.Failure -> { + Button( + onClick = { state.eventSink(LinkNewDeviceRootEvent.LinkMobileDevice) }, + text = stringResource(id = R.string.screen_link_new_device_root_mobile_device), + modifier = Modifier.fillMaxWidth(), + leadingIcon = IconSource.Vector(CompoundIcons.Mobile()), + ) + Button( + onClick = onLinkDesktopDeviceClick, + text = stringResource(id = R.string.screen_link_new_device_root_desktop_computer), + modifier = Modifier.fillMaxWidth(), + leadingIcon = IconSource.Vector(CompoundIcons.Computer()), + ) + } + is AsyncData.Loading, + is AsyncData.Success -> { + Button( + onClick = { state.eventSink(LinkNewDeviceRootEvent.LinkMobileDevice) }, + text = stringResource(id = R.string.screen_link_new_device_root_loading_qr_code), + showProgress = true, + enabled = false, + modifier = Modifier.fillMaxWidth(), + ) + Button( + onClick = onLinkDesktopDeviceClick, + text = stringResource(id = R.string.screen_link_new_device_root_desktop_computer), + modifier = Modifier.fillMaxWidth(), + enabled = false, + leadingIcon = IconSource.Vector(CompoundIcons.Computer()), + ) + } + } + } else { + Button( + onClick = onBackClick, + text = stringResource(CommonStrings.action_dismiss), + modifier = Modifier.fillMaxWidth(), + ) + } + } + } + }, + modifier = modifier, + ) + + val failure = state.qrCodeData.errorOrNull() + if (failure != null) { + ErrorDialog( + content = failure.message ?: stringResource(CommonStrings.error_unknown), + onSubmit = { state.eventSink(LinkNewDeviceRootEvent.CloseDialog) }, + ) + } +} + +@PreviewsDayNight +@Composable +internal fun LinkNewDeviceRootViewPreview( + @PreviewParameter(LinkNewDeviceRootStateProvider::class) state: LinkNewDeviceRootState +) = ElementPreview { + LinkNewDeviceRootView( + state = state, + onBackClick = { }, + onLinkDesktopDeviceClick = { }, + ) +} diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodeEvent.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodeEvent.kt new file mode 100644 index 00000000000..c17a28649c9 --- /dev/null +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodeEvent.kt @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2025 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.linknewdevice.impl.screens.scan + +sealed interface ScanQrCodeEvent { + data class QrCodeScanned(val data: ByteArray) : ScanQrCodeEvent + data object TryAgain : ScanQrCodeEvent +} diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodeNode.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodeNode.kt new file mode 100644 index 00000000000..ff4f88f9ae1 --- /dev/null +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodeNode.kt @@ -0,0 +1,43 @@ +/* + * Copyright (c) 2025 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.linknewdevice.impl.screens.scan + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.libraries.architecture.callback +import io.element.android.libraries.di.SessionScope + +@ContributesNode(SessionScope::class) +@AssistedInject +class ScanQrCodeNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val presenter: ScanQrCodePresenter, +) : Node(buildContext, plugins = plugins) { + interface Callback : Plugin { + fun cancel() + } + + private val callback: Callback = callback() + + @Composable + override fun View(modifier: Modifier) { + val state = presenter.present() + ScanQrCodeView( + state = state, + onBackClick = callback::cancel, + modifier = modifier + ) + } +} diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodePresenter.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodePresenter.kt new file mode 100644 index 00000000000..5f0131a8dfe --- /dev/null +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodePresenter.kt @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2025 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.linknewdevice.impl.screens.scan + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import dev.zacsweers.metro.Inject +import io.element.android.features.linknewdevice.impl.LinkNewDesktopHandler +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.matrix.api.linknewdevice.LinkDesktopStep +import kotlinx.coroutines.launch + +@Inject +class ScanQrCodePresenter( + private val linkNewDesktopHandler: LinkNewDesktopHandler, +) : Presenter { + @Composable + override fun present(): ScanQrCodeState { + val coroutineScope = rememberCoroutineScope() + var scanAction: AsyncAction by remember { mutableStateOf(AsyncAction.Loading) } + + // Observe the flow to react on LinkDesktopStep.InvalidQrCode + val linkDesktopStep by linkNewDesktopHandler.stepFlow.collectAsState() + + LaunchedEffect(Unit) { + linkNewDesktopHandler.createNewHandler() + } + + LaunchedEffect(linkDesktopStep) { + when (val step = linkDesktopStep) { + is LinkDesktopStep.InvalidQrCode -> { + scanAction = AsyncAction.Failure(Exception(step.error)) + } + else -> Unit + } + } + + fun handleEvent(event: ScanQrCodeEvent) { + when (event) { + ScanQrCodeEvent.TryAgain -> { + scanAction = AsyncAction.Loading + } + is ScanQrCodeEvent.QrCodeScanned -> coroutineScope.launch { + // In this case the scanning will stop and a loader will be shown + scanAction = AsyncAction.Success(Unit) + try { + linkNewDesktopHandler.onScannedCode(event.data) + } catch (e: Exception) { + // Should not happen as errors are handled through the LinkDesktopStep flow + scanAction = AsyncAction.Failure(e) + } + } + } + } + + return ScanQrCodeState( + scanAction = scanAction, + eventSink = ::handleEvent, + ) + } +} diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodeState.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodeState.kt new file mode 100644 index 00000000000..6cb03638364 --- /dev/null +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodeState.kt @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2025 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.linknewdevice.impl.screens.scan + +import io.element.android.libraries.architecture.AsyncAction + +data class ScanQrCodeState( + val scanAction: AsyncAction, + val eventSink: (ScanQrCodeEvent) -> Unit, +) diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodeStateProvider.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodeStateProvider.kt new file mode 100644 index 00000000000..40e7df8884f --- /dev/null +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodeStateProvider.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 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.linknewdevice.impl.screens.scan + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +import io.element.android.libraries.architecture.AsyncAction + +open class ScanQrCodeStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aScanQrCodeState(), + aScanQrCodeState(scanAction = AsyncAction.Loading), + aScanQrCodeState(scanAction = AsyncAction.Success(Unit)), + aScanQrCodeState(scanAction = AsyncAction.Failure(Exception("Scan failed"))), + ) +} + +fun aScanQrCodeState( + scanAction: AsyncAction = AsyncAction.Uninitialized, + eventSink: (ScanQrCodeEvent) -> Unit = {}, +) = ScanQrCodeState( + scanAction = scanAction, + eventSink = eventSink +) diff --git a/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodeView.kt b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodeView.kt new file mode 100644 index 00000000000..937aee08b4b --- /dev/null +++ b/features/linknewdevice/impl/src/main/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodeView.kt @@ -0,0 +1,174 @@ +/* + * Copyright (c) 2025 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.linknewdevice.impl.screens.scan + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +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.fillMaxHeight +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.progressSemantics +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.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.compound.tokens.generated.CompoundIcons +import io.element.android.features.linknewdevice.impl.R +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.designsystem.atomic.pages.FlowStepPage +import io.element.android.libraries.designsystem.components.BigIcon +import io.element.android.libraries.designsystem.modifiers.cornerBorder +import io.element.android.libraries.designsystem.modifiers.squareSize +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.CircularProgressIndicator +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.qrcode.QrCodeCameraView +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun ScanQrCodeView( + state: ScanQrCodeState, + onBackClick: () -> Unit, + modifier: Modifier = Modifier, +) { + FlowStepPage( + modifier = modifier, + onBackClick = onBackClick, + iconStyle = BigIcon.Style.Default(CompoundIcons.Computer()), + title = stringResource(R.string.screen_link_new_device_desktop_scanning_title), + content = { Content(state = state) }, + buttons = { Buttons(state = state) } + ) +} + +@Composable +private fun Content( + state: ScanQrCodeState, +) { + BoxWithConstraints( + modifier = Modifier.fillMaxWidth(), + contentAlignment = Alignment.Center, + ) { + val modifier = if (constraints.maxWidth > constraints.maxHeight) { + Modifier.fillMaxHeight() + } else { + Modifier.fillMaxWidth() + }.then( + Modifier + .padding(start = 20.dp, end = 20.dp, top = 50.dp, bottom = 32.dp) + .squareSize() + .cornerBorder( + strokeWidth = 4.dp, + color = ElementTheme.colors.textPrimary, + cornerSizeDp = 42.dp, + ) + ) + Box( + modifier = modifier, + contentAlignment = Alignment.Center, + ) { + QrCodeCameraView( + modifier = Modifier.fillMaxSize(), + onScanQrCode = { state.eventSink.invoke(ScanQrCodeEvent.QrCodeScanned(it)) }, + isScanning = state.scanAction.isLoading(), + ) + } + } +} + +@Composable +private fun ColumnScope.Buttons( + state: ScanQrCodeState, +) { + Column(Modifier.heightIn(min = 130.dp)) { + when (state.scanAction) { + is AsyncAction.Failure -> { + Button( + text = stringResource(id = CommonStrings.action_try_again), + modifier = Modifier + .fillMaxWidth() + .padding(bottom = 16.dp), + onClick = { + state.eventSink.invoke(ScanQrCodeEvent.TryAgain) + } + ) + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + Icon( + imageVector = CompoundIcons.ErrorSolid(), + tint = ElementTheme.colors.iconCriticalPrimary, + contentDescription = null, + modifier = Modifier.size(24.dp) + ) + Spacer(modifier = Modifier.width(4.dp)) + Text( + text = stringResource(R.string.screen_qr_code_login_invalid_scan_state_subtitle), + textAlign = TextAlign.Center, + color = ElementTheme.colors.textCriticalPrimary, + style = ElementTheme.typography.fontBodySmMedium, + ) + } + Text( + text = stringResource(R.string.screen_qr_code_login_invalid_scan_state_description), + textAlign = TextAlign.Center, + style = ElementTheme.typography.fontBodySmRegular, + color = ElementTheme.colors.textSecondary, + ) + } + } + is AsyncAction.Success -> { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + CircularProgressIndicator( + modifier = Modifier + .progressSemantics() + .size(20.dp), + strokeWidth = 2.dp + ) + } + } + AsyncAction.Loading, + AsyncAction.Uninitialized, + is AsyncAction.Confirming -> Unit + } + } +} + +@PreviewsDayNight +@Composable +internal fun ScanQrCodeViewPreview(@PreviewParameter(ScanQrCodeStateProvider::class) state: ScanQrCodeState) = ElementPreview { + ScanQrCodeView( + state = state, + onBackClick = {}, + ) +} diff --git a/features/linknewdevice/impl/src/main/res/values-be/translations.xml b/features/linknewdevice/impl/src/main/res/values-be/translations.xml new file mode 100644 index 00000000000..16372fa6e4c --- /dev/null +++ b/features/linknewdevice/impl/src/main/res/values-be/translations.xml @@ -0,0 +1,38 @@ + + + "Сканіраваць QR-код" + "Адсканіруйце QR-код з дапамогай гэтай прылады" + "Гатовы да сканіравання" + "Ваш правайдар уліковага запісу не падтрымлівае %1$s." + "%1$s не падтрымліваецца" + "QR-код не падтрымліваецца" + "Уваход быў адменены на іншай прыладзе." + "Запыт на ўваход скасаваны" + "Тэрмін уваходу скончыўся. Калі ласка, паспрабуйце яшчэ раз." + "Уваход у сістэму не быў завершаны своечасова" + "Выберыце %1$s" + "Не атрымалася ўсталяваць бяспечнае злучэнне з новай прыладай. Існуючыя прылады па-ранейшаму ў бяспецы, і вам не трэба турбавацца пра іх." + "Што зараз?" + "Паспрабуйце зноў увайсці ў сістэму з дапамогай QR-кода, калі гэта была сеткавая праблема" + "Калі вы сутыкнуліся з той жа праблемай, паспрабуйце іншую сетку Wi-Fi або скарыстайцеся мабільнымі дадзенымі замест Wi-Fi." + "Калі гэта не дапамагло, увайдзіце ўручную" + "Злучэнне небяспечнае" + "Уваход быў адменены на іншай прыладзе." + "Запыт на ўваход скасаваны" + "Уваход на іншай прыладзе быў адхілены." + "Уваход адхілены" + "Тэрмін уваходу скончыўся. Калі ласка, паспрабуйце яшчэ раз." + "Уваход у сістэму не быў завершаны своечасова" + "Ваша іншая прылада не падтрымлівае ўваход у %s з дапамогай QR-кода. + +Паспрабуйце ўвайсці ў сістэму ўручную або адсканіруйце QR-код з дапамогай іншай прылады." + "QR-код не падтрымліваецца" + "Ваш правайдар уліковага запісу не падтрымлівае %1$s." + "%1$s не падтрымліваецца" + "Выкарыстоўвайце QR-код, паказаны на іншай прыладзе." + "Паўтарыць спробу" + "Няправільны QR-код" + "Каб працягнуць, вам неабходна дазволіць %1$s выкарыстоўваць камеру вашай прылады." + "Дазвольце доступ да камеры для сканіравання QR-кода" + "Адбылася нечаканая памылка. Калі ласка, паспрабуйце яшчэ раз." + diff --git a/features/linknewdevice/impl/src/main/res/values-bg/translations.xml b/features/linknewdevice/impl/src/main/res/values-bg/translations.xml new file mode 100644 index 00000000000..e8a8ea95d36 --- /dev/null +++ b/features/linknewdevice/impl/src/main/res/values-bg/translations.xml @@ -0,0 +1,4 @@ + + + "Повторен опит" + diff --git a/features/linknewdevice/impl/src/main/res/values-cs/translations.xml b/features/linknewdevice/impl/src/main/res/values-cs/translations.xml new file mode 100644 index 00000000000..4b8f230d552 --- /dev/null +++ b/features/linknewdevice/impl/src/main/res/values-cs/translations.xml @@ -0,0 +1,57 @@ + + + "Naskenujte QR kód" + "Otevřete %1$s na notebooku nebo stolním počítači" + "Naskenujte QR kód pomocí tohoto zařízení" + "Připraveno ke skenování" + "Otevřete %1$s na stolním počítači a získejte QR kód" + "Čísla se neshodují" + "Zadejte dvoumístný kód" + "Tím ověříte, zda je připojení k druhému zařízení bezpečné." + "Zadejte číslo zobrazené na druhém zařízení" + "Váš poskytovatel účtu nepodporuje %1$s." + "%1$s není podporováno" + "Poskytovatel vašeho účtu nepodporuje přihlašování do nového zařízení pomocí QR kódu." + "QR kód není podporován" + "Přihlášení bylo na druhém zařízení zrušeno." + "Žádost o přihlášení zrušena" + "Platnost přihlášení vypršela. Zkuste to prosím znovu." + "Přihlášení nebylo dokončeno včas" + "Otevřete %1$s na druhém zařízení" + "Vybrat %1$s" + "„Přihlásit se pomocí QR kódu“" + "Naskenujte zde zobrazený QR kód pomocí jiného zařízení" + "Otevřete %1$s na druhém zařízení" + "Stolní počítač" + "Načítání QR kódu…" + "Mobilní zařízení" + "Jaký typ zařízení chcete propojit?" + "Zkuste to prosím znovu a ujistěte se, že jste zadali dvoumístný kód správně. Pokud se čísla stále neshodují, kontaktujte poskytovatele účtu." + "Čísla se neshodují" + "K novému zařízení se nepodařilo navázat bezpečné připojení. Vaše stávající zařízení jsou stále v bezpečí a nemusíte se o ně obávat." + "Co teď?" + "Zkuste se znovu přihlásit pomocí QR kódu v případě, že se jednalo o problém se sítí" + "Pokud narazíte na stejný problém, zkuste jinou síť wifi nebo použijte mobilní data místo wifi" + "Pokud to nefunguje, přihlaste se ručně" + "Připojení není zabezpečené" + "Přihlášení bylo na druhém zařízení zrušeno." + "Žádost o přihlášení zrušena" + "Přihlášení bylo na druhém zařízení odmítnuto." + "Přihlášení odmítnuto" + "Nemusíte dělat nic jiného." + "Vaše další zařízení je již přihlášeno" + "Platnost přihlášení vypršela. Zkuste to prosím znovu." + "Přihlášení nebylo dokončeno včas" + "Vaše druhé zařízení nepodporuje přihlášení k %su pomocí QR kódu. + +Zkuste se přihlásit ručně nebo naskenujte QR kód pomocí jiného zařízení." + "QR kód není podporován" + "Váš poskytovatel účtu nepodporuje %1$s." + "%1$s není podporováno" + "Použijte QR kód zobrazený na druhém zařízení." + "Zkusit znovu" + "Špatný QR kód" + "Abyste mohli pokračovat, musíte aplikaci %1$s udělit povolení k použití kamery vašeho zařízení." + "Povolte přístup k fotoaparátu a naskenujte QR kód" + "Vyskytla se neočekávaná chyba. Prosím zkuste to znovu." + diff --git a/features/linknewdevice/impl/src/main/res/values-cy/translations.xml b/features/linknewdevice/impl/src/main/res/values-cy/translations.xml new file mode 100644 index 00000000000..b26aed52efc --- /dev/null +++ b/features/linknewdevice/impl/src/main/res/values-cy/translations.xml @@ -0,0 +1,38 @@ + + + "Sganiwch y cod QR" + "Sganiwch y cod QR gyda\'r ddyfais hon" + "Yn barod i sganio" + "Nid yw darparwr eich cyfrif yn cefnogi %1$s." + "%1$s heb ei gefnogi" + "Nid yw\'r cod QR yn cael ei gefnogi" + "Cafodd y mewngofnodi ei ddiddymu ar y ddyfais arall." + "Cais mewngofnodi wedi\'i ddiddymu" + "Mewngofnodi wedi dod i ben. Ceisiwch eto." + "Heb gwblhau\'r mewngofnodi mewn pryd" + "Dewiswch %1$s" + "Nid oedd modd gwneud cysylltiad diogel â\'r ddyfais newydd. Mae eich dyfeisiau presennol yn dal yn ddiogel a does dim angen i chi boeni amdanyn nhw." + "Beth nawr?" + "Ceisiwch fewngofnodi eto gyda chod QR rhag ofn bod hyn yn broblem rhwydwaith" + "Os ydych chi\'n dod ar draws yr un broblem, rhowch gynnig ar rwydwaith wifi gwahanol neu defnyddiwch eich data symudol yn lle wifi" + "Os nad yw hynny\'n gweithio, mewngofnodwch â llaw" + "Nid yw\'r cysylltiad yn ddiogel" + "Cafodd y mewngofnodi ei ddiddymu ar y ddyfais arall." + "Cais mewngofnodi wedi\'i ddiddymu" + "Cafodd y mewngofnodi ar y ddyfais arall ei wrthod." + "Gwrthodwyd y mewngofnodi" + "Mewngofnodi wedi dod i ben. Ceisiwch eto." + "Heb gwblhau\'r mewngofnodi mewn pryd" + "Nid yw eich dyfais arall yn cefnogi mewngofnodi i %s gyda chod QR. + +Ceisiwch fewngofnodi â llaw, neu sganiwch y cod QR gyda dyfais arall." + "Nid yw\'r cod QR yn cael ei gefnogi" + "Nid yw darparwr eich cyfrif yn cefnogi %1$s." + "%1$s heb ei gefnogi" + "Defnyddiwch y cod QR sy\'n cael ei ddangos ar y ddyfais arall." + "Ceisiwch eto" + "Cod QR anghywir" + "Mae angen i chi roi caniatâd i %1$s ddefnyddio camera eich dyfais er mwyn parhau." + "Caniatáu mynediad camera i sganio\'r cod QR" + "Digwyddodd gwall annisgwyl. Ceisiwch eto." + diff --git a/features/linknewdevice/impl/src/main/res/values-da/translations.xml b/features/linknewdevice/impl/src/main/res/values-da/translations.xml new file mode 100644 index 00000000000..a31175c1d5d --- /dev/null +++ b/features/linknewdevice/impl/src/main/res/values-da/translations.xml @@ -0,0 +1,38 @@ + + + "Scan QR-koden" + "Scan QR-koden med denne enhed" + "Klar til at scanne" + "Din kontoudbyder understøtter ikke %1$s." + "%1$s understøttes ikke" + "QR-kode understøttes ikke" + "Login blev annulleret på den anden enhed." + "Anmodning om login annulleret" + "Login er udløbet. Prøv venligst igen." + "Login blev ikke afsluttet i tide" + "Vælg %1$s" + "Der kunne ikke oprettes en sikker forbindelse til den nye enhed. Dine eksisterende enheder er stadig sikre, og du behøver ikke bekymre dig om dem." + "Hvad nu?" + "Prøv at logge ind igen med en QR-kode, hvis dette skyldtes et netværksproblem" + "Hvis du støder på det samme problem, kan du prøve et andet wifi-netværk eller bruge dine mobildata i stedet for wifi" + "Hvis det ikke virker, skal du logge ind manuelt" + "Forbindelsen er ikke sikker" + "Login blev annulleret på den anden enhed." + "Anmodning om login annulleret" + "Login blev afvist på den anden enhed." + "Login afvist" + "Login er udløbet. Prøv venligst igen." + "Login blev ikke afsluttet i tide" + "Din anden enhed understøtter ikke at logge ind på %s med en QR-kode. + +Prøv at logge ind manuelt, eller scan QR-koden med en anden enhed." + "QR-kode understøttes ikke" + "Din kontoudbyder understøtter ikke %1$s." + "%1$s understøttes ikke" + "Brug den QR-kode, der bliver vist på den anden enhed." + "Prøv igen" + "Forkert QR-kode" + "Du skal give tilladelse til at %1$s kan benytte enhedens kamera, for at fortsætte." + "Tillad kameraadgang for at scanne QR-koden" + "Der opstod en uventet fejl. Prøv venligst igen." + diff --git a/features/linknewdevice/impl/src/main/res/values-de/translations.xml b/features/linknewdevice/impl/src/main/res/values-de/translations.xml new file mode 100644 index 00000000000..b8ad8b80efc --- /dev/null +++ b/features/linknewdevice/impl/src/main/res/values-de/translations.xml @@ -0,0 +1,57 @@ + + + "QR-Code scannen" + "Öffne %1$s auf einem Laptop oder Desktop-Computer" + "Scanne den QR-Code mit diesem Gerät" + "Bereit zum Scannen" + "Öffne %1$s auf einem Desktop-Computer, um den QR-Code zu erhalten" + "Die Zahlen stimmen nicht überein" + "Gib den 2-stelligen Code ein" + "Dadurch wird überprüft, ob die Verbindung zu deinem anderen Gerät sicher ist." + "Gib die Nummer ein, die auf deinem anderen Gerät angezeigt wird" + "Dein Kontoanbieter unterstützt %1$s nicht." + "%1$s wird nicht unterstützt" + "Dein Kontoanbieter unterstützt die Anmeldung auf einem neuen Gerät mit einem QR-Code nicht." + "QR-Code wird nicht unterstützt" + "Die Anmeldung wurde auf dem anderen Gerät abgebrochen." + "Anmeldeanfrage abgebrochen" + "Die Anmeldung ist abgelaufen. Bitte versuche es erneut." + "Die Anmeldung wurde nicht rechtzeitig abgeschlossen" + "Öffne %1$s auf dem anderen Gerät" + "Wähle %1$s" + "„Mit QR-Code anmelden”" + "Scanne den hier gezeigten QR-Code mit dem anderen Gerät." + "Öffne %1$s auf dem anderen Gerät" + "Desktop-Computer" + "QR-Code wird geladen…" + "Mobilgerät" + "Welchen Gerätetyp möchtest du verknüpfen?" + "Versuch\' es bitte noch mal und stell sicher, dass du den zweistelligen Code richtig eingegeben hast. Wenn die Zahlen immer noch nicht übereinstimmen, wende dich an deinen Kontoanbieter." + "Die Zahlen stimmen nicht überein" + "Es konnte keine sichere Verbindung zu dem neuen Gerät hergestellt werden." + "Und jetzt?" + "Versuche, dich erneut mit einem QR-Code anzumelden, falls dies ein Netzwerkproblem war." + "Wenn das Problem bestehen bleibt, versuche es mit einem anderen WLAN-Netzwerk oder verwende deine mobilen Daten statt WLAN." + "Wenn das nicht funktioniert, melde dich manuell an" + "Die Verbindung ist nicht sicher" + "Die Anmeldung wurde auf dem anderen Gerät abgebrochen." + "Anmeldeanfrage abgebrochen" + "Die Anmeldung auf dem anderen Gerät wurde abgelehnt." + "Anmelden abgelehnt" + "Du musst nichts weiter tun." + "Dein anderes Gerät ist schon angemeldet." + "Die Anmeldung ist abgelaufen. Bitte versuche es erneut." + "Die Anmeldung wurde nicht rechtzeitig abgeschlossen" + "Dein anderes Gerät unterstützt die Anmeldung bei %s mit einem QR-Code nicht. + +Versuche, dich manuell anzumelden, oder scanne den QR-Code mit einem anderen Gerät." + "QR-Code wird nicht unterstützt" + "Dein Kontoanbieter unterstützt %1$s nicht." + "%1$s wird nicht unterstützt" + "Verwende den QR-Code, der auf dem anderen Gerät angezeigt wird." + "Erneut versuchen" + "Falscher QR-Code" + "Du musst %1$s die Berechtigung erteilen, die Kamera deines Geräts zu verwenden, um fortzufahren." + "Erlaube Zugriff auf die Kamera zum Scannen des QR-Codes" + "Ein unerwarteter Fehler ist aufgetreten. Bitte versuche es erneut." + diff --git a/features/linknewdevice/impl/src/main/res/values-el/translations.xml b/features/linknewdevice/impl/src/main/res/values-el/translations.xml new file mode 100644 index 00000000000..2133bb352c9 --- /dev/null +++ b/features/linknewdevice/impl/src/main/res/values-el/translations.xml @@ -0,0 +1,38 @@ + + + "Σάρωση κωδικού QR" + "Σάρωσε τον κωδικό QR με αυτήν τη συσκευή" + "Έτοιμο για σάρωση" + "Ο πάροχος λογαριασμού σου δεν υποστηρίζει το %1$s." + "Το %1$s δεν υποστηρίζεται" + "Ο κωδικός QR δεν υποστηρίζεται" + "Η σύνδεση ακυρώθηκε στην άλλη συσκευή." + "Το αίτημα σύνδεσης ακυρώθηκε" + "Η είσοδος έληξε. Παρακαλώ προσπάθησε ξανά." + "Η σύνδεση δεν ολοκληρώθηκε εγκαίρως" + "Επιλογή %1$s" + "Δεν ήταν δυνατή η πραγματοποίηση ασφαλούς σύνδεσης στη νέα συσκευή. Οι υπάρχουσες συσκευές σας εξακολουθούν να είναι ασφαλείς και δεν χρειάζεται να ανησυχείς για αυτές." + "Τί είναι πάλι;" + "Δοκίμασε να συνδεθείς ξανά με έναν κωδικό QR σε περίπτωση που ήταν πρόβλημα του δικτύου" + "Εάν αντιμετωπίσεις το ίδιο πρόβλημα, δοκίμασε ένα διαφορετικό δίκτυο wifi ή χρησιμοποίησε τα δεδομένα του κινητού σου αντί για wifi" + "Εάν δεν λειτουργήσει, συνδέσου χειροκίνητα" + "Η σύνδεση δεν είναι ασφαλής" + "Η σύνδεση ακυρώθηκε στην άλλη συσκευή." + "Το αίτημα σύνδεσης ακυρώθηκε" + "Η σύνδεση απορρίφθηκε στην άλλη συσκευή." + "Η σύνδεση απορρίφθηκε" + "Η είσοδος έληξε. Παρακαλώ προσπάθησε ξανά." + "Η σύνδεση δεν ολοκληρώθηκε εγκαίρως" + "Η άλλη σου συσκευή δεν υποστηρίζει σύνδεση στο %s με κωδικό QR. + +Δοκίμασε να συνδεθείς χειροκίνητα ή σάρωσε τον κωδικό QR με άλλη συσκευή." + "Ο κωδικός QR δεν υποστηρίζεται" + "Ο πάροχος λογαριασμού σου δεν υποστηρίζει το %1$s." + "Το %1$s δεν υποστηρίζεται" + "Χρησιμοποίησε τον κωδικό QR που εμφανίζεται στην άλλη συσκευή." + "Προσπάθησε ξανά" + "Λάθος κωδικός QR" + "Πρέπει να δώσεις άδεια για %1$s για να χρησιμοποιήσεις την κάμερα της συσκευής σου και να συνεχίσεις." + "Επέτρεψε την πρόσβαση της κάμερας για σάρωση του κωδικού QR" + "Παρουσιάστηκε ένα απροσδόκητο σφάλμα. Παρακαλώ προσπάθησε ξανά." + diff --git a/features/linknewdevice/impl/src/main/res/values-en-rUS/translations.xml b/features/linknewdevice/impl/src/main/res/values-en-rUS/translations.xml new file mode 100644 index 00000000000..6395ce9e540 --- /dev/null +++ b/features/linknewdevice/impl/src/main/res/values-en-rUS/translations.xml @@ -0,0 +1,4 @@ + + + "If you encounter the same problem, try a different Wi-Fi network or use your mobile data instead of Wi-Fi" + diff --git a/features/linknewdevice/impl/src/main/res/values-es/translations.xml b/features/linknewdevice/impl/src/main/res/values-es/translations.xml new file mode 100644 index 00000000000..032813a2c4a --- /dev/null +++ b/features/linknewdevice/impl/src/main/res/values-es/translations.xml @@ -0,0 +1,38 @@ + + + "Escanea el código QR" + "Escanea el código QR con este dispositivo" + "Listo para escanear" + "Tu proveedor de cuentas no es compatible con %1$s." + "%1$s no admitido" + "Código QR no admitido" + "El inicio de sesión se canceló en el otro dispositivo." + "Solicitud de inicio de sesión cancelada" + "El inicio de sesión ha caducado. Inténtalo de nuevo." + "El inicio de sesión no se completó a tiempo" + "Selecciona %1$s" + "No se pudo establecer una conexión segura con el nuevo dispositivo. Tus dispositivos actuales siguen siendo seguros y no tienes que preocuparte por ellos." + "¿Y ahora qué?" + "Intenta iniciar sesión de nuevo con un código QR en caso de que se trate de un problema de red" + "Si te encuentras con el mismo problema, prueba con una red wifi diferente o usa tus datos móviles en lugar de wifi" + "Si eso no funciona, inicia sesión manualmente" + "La conexión no es segura" + "El inicio de sesión se canceló en el otro dispositivo." + "Solicitud de inicio de sesión cancelada" + "El inicio de sesión se rechazó en el otro dispositivo." + "Inicio de sesión rechazado" + "El inicio de sesión ha caducado. Inténtalo de nuevo." + "El inicio de sesión no se completó a tiempo" + "Tu otro dispositivo no admite el inicio de sesión en %s con un código QR. + +Intenta iniciar sesión manualmente o escanea el código QR con otro dispositivo." + "Código QR no admitido" + "Tu proveedor de cuentas no es compatible con %1$s." + "%1$s no admitido" + "Usa el código QR que se muestra en el otro dispositivo." + "Intentar de nuevo" + "Código QR incorrecto" + "Tienes que dar permiso a %1$s para que utilice la cámara de tu dispositivo y así poder continuar." + "Permite el acceso a la cámara para escanear el código QR" + "Se ha producido un error inesperado. Vuelve a intentarlo." + diff --git a/features/linknewdevice/impl/src/main/res/values-et/translations.xml b/features/linknewdevice/impl/src/main/res/values-et/translations.xml new file mode 100644 index 00000000000..6aa1398e0a2 --- /dev/null +++ b/features/linknewdevice/impl/src/main/res/values-et/translations.xml @@ -0,0 +1,57 @@ + + + "Skaneeri QR-koodi" + "Ava %1$s kas oma süle- või lauaarvutis" + "Skaneeri QR-koodi selle seadmega" + "Skaneerimiseks valmis" + "QR-koodi laadimiseks ava %1$s süle- või lauaarvutis" + "Numbrid ei klapi" + "Sisesta kahekohaline kood" + "Sellega verifitseerime, et ühendus sinu teise seadmega on turvaline." + "Sisesta teises seadmes kuvatud number" + "Sinu teenusepakkuja ei toeta rakendust %1$s." + "%1$s pole toetatud" + "Sinu kasutajakonto teenusepakkuja ei toeta võimalust logida sisse QR-koodi abil." + "QR-kood pole toetatud" + "Sisselogimine katkestati teises seadmes." + "Sisselogimispäring on tühistatud" + "Sisselogimine aegus. Palun proovi uuesti." + "Sisselogimine jäi etteantud aja jooksul tegemata" + "Ava %1$s teises seadmes" + "Vali %1$s" + "„Logi sisse QR-koodiga“" + "Skaneeri siin näidatud QR-koodi teise seadmega" + "Ava %1$s teises seadmes" + "Lauaarvuti" + "Laadin QR-koodi…" + "Nutiseade" + "Mis tüüpi seadet soovid siduda?" + "Palun proovi uuesti ja veendu, et sisestasid kahekohalise koodi õigesti. Kui numbrid ikka ei klapi, võta ühendust oma kasutajakonto teenusepakkujaga." + "Numbrid ei klapi" + "Turvalise ühenduse loomine uue seadmega ei õnnestunud. Sinu olemasolevad seadmed on jätkuvalt turvatud ja sa ei pea nende pärast muretsema." + "Mida järgmiseks teeme?" + "Kui see juhtumisi oli võrguühenduse viga, siis proovi uuesti QR-koodiga sisse logida" + "Kui sama probleem kordub, siis kasuta mõnda muud WiFi- või mobiilset andmedsideühendust" + "Kui see ka ei aita, siis logi sisse käsitsi" + "Ühendus pole turvaline" + "Sisselogimine katkestati teises seadmes." + "Sisselogimispäring on tühistatud" + "Sisselogimisest on teises seadmes keeldutud." + "Sisselogimisest on keeldutud" + "Sa ei pea enam midagi muud tegema." + "Sinu muu seade on juba sisse logitud" + "Sisselogimine aegus. Palun proovi uuesti." + "Sisselogimine jäi etteantud aja jooksul tegemata" + "Sinu teine seade ei toeta %s sisselogimist QR-koodiga. + +Proovi käsitsi sisselogimist või skaneeri QR-koodi mõne muu seadmega." + "QR-kood pole toetatud" + "Sinu teenusepakkuja ei toeta rakendust %1$s." + "%1$s pole toetatud" + "Kasuta teises seadmes näidatavat QR-koodi" + "Proovi uuesti" + "Vale QR-kood" + "Jätkamiseks pead lubama, et %1$s saab kasutada sinu nutiseadme kaamerat" + "QR-koodi lugemiseks luba kaamerat kasutada" + "Tekkis ootamatu viga. Palun proovi uuesti." + diff --git a/features/linknewdevice/impl/src/main/res/values-eu/translations.xml b/features/linknewdevice/impl/src/main/res/values-eu/translations.xml new file mode 100644 index 00000000000..06cc0fd8571 --- /dev/null +++ b/features/linknewdevice/impl/src/main/res/values-eu/translations.xml @@ -0,0 +1,36 @@ + + + "Eskaneatu QR kodea" + "Eskaneatu QR kodea gailu honekin" + "Eskaneatzeko prest" + "Zure kontu-hornitzailea ez da %1$s-ekin bateragarria." + "%1$s ez da bateragarria" + "QR kodea ez da bateragarria" + "Saioa hasteko eskaera bertan behera utzi da beste gailuan" + "Saioa hasteko eskaera bertan behera utzi da" + "Saio-hasiera iraungi da. Saiatu berriro." + "Saio-hasiera ez da garaiz gauzatu." + "Hautatu %1$s" + "Ezin izan da konexio segururik ezarri gailu berriarekin. Lehendik dauden gailuak seguru daude oraindik ere eta ez duzu haietaz kezkatu beharrik." + "Orain zer?" + "Saiatu berriro QR kodearekin saioa hasten sare-arazo bat izan bada" + "Horrek ez badu funtzionatzen, hasi saioa eskuz" + "Konexioa ez da segurua" + "Saioa hasteko eskaera bertan behera utzi da beste gailuan" + "Saioa hasteko eskaera bertan behera utzi da" + "Saioa hasteari uko egin zaio beste dispositiboan." + "Saio-hasiera ukatu da" + "Saio-hasiera iraungi da. Saiatu berriro." + "Saio-hasiera ez da garaiz gauzatu." + "Beste gailua ez da bateragarria QR kodeak erabiliz %s(e)n saioa hastearekin. + +Saiatu saioa eskuz hasten, edo eskaneatu QR kodea beste gailu batean." + "QR kodea ez da bateragarria" + "Zure kontu-hornitzailea ez da %1$s-ekin bateragarria." + "%1$s ez da bateragarria" + "Erabili beste gailuan agertzen den QR kodea." + "Saiatu berriro" + "QR kode okerra" + "Baimendu kameraren sarbidea QR kodea eskaneatzeko" + "Ustekabeko errore bat gertatu da. Saiatu berriro." + diff --git a/features/linknewdevice/impl/src/main/res/values-fa/translations.xml b/features/linknewdevice/impl/src/main/res/values-fa/translations.xml new file mode 100644 index 00000000000..804fa653ade --- /dev/null +++ b/features/linknewdevice/impl/src/main/res/values-fa/translations.xml @@ -0,0 +1,36 @@ + + + "پویش کد پاس" + "پویش کد پاس با این افزاره" + "آمادهٔ پویش" + "فراهم کنندهٔ حسابتان از %1$s پشتیبانی نمی‌کند." + "%1$s پشتیبانی نمی‌شود" + "کد پاس پشتیبانی نمی‌شود" + "ورود روی افزارهٔ دیگر لغو شد." + "درخواست ورد لغو شد" + "ورود منقضی شد. لطفاً دوباره تلاش کنید." + "ورود در زمان معیّن کامل نشد" + "گزینش %1$s" + "نتوانست اتّصالی امن به افزارهٔ جدید بسازد. افزاره‌های موجودتان هنوز امنند و نیازی نیست نگرانشان باشید." + "اکنون چه؟" + "ورود دستی در صورت کار نکردنش" + "اتّصال ناامن" + "ورود روی افزارهٔ دیگر لغو شد." + "درخواست ورد لغو شد" + "ورود به دست افزارهٔ دیگر رد شد." + "ورود رد شد" + "ورود منقضی شد. لطفاً دوباره تلاش کنید." + "ورود در زمان معیّن کامل نشد" + "افزارهٔ دیگرتان از ورود به %s با کد پاس پشتیبانی نمی‌کند. + +آزمودن ورود دستی یا پویش کد پاس با افزاره‌ای دیگر." + "کد پاس پشتیبانی نمی‌شود" + "فراهم کنندهٔ حسابتان از %1$s پشتیبانی نمی‌کند." + "%1$s پشتیبانی نمی‌شود" + "استفاده از کد پاس نشان داده روی افزارهٔ دیگر." + "تلاش دوباره" + "کد پاس اشتباه" + "برای ادامه باید اجازهٔ استفادهٔ %1$s از دوربین افزاره‌تان را بدهید." + "اجازهٔ دسترسی دوربین برای پویش کد پاس" + "خطایی غیرمنتظره رخ داد. لطفاً دوباره تلاش کنید." + diff --git a/features/linknewdevice/impl/src/main/res/values-fi/translations.xml b/features/linknewdevice/impl/src/main/res/values-fi/translations.xml new file mode 100644 index 00000000000..3ed954a842a --- /dev/null +++ b/features/linknewdevice/impl/src/main/res/values-fi/translations.xml @@ -0,0 +1,38 @@ + + + "Skannaa QR-koodi" + "Skannaa QR-koodi tällä laitteella" + "Valmis skannaamaan" + "Palveluntarjoajasi ei tue %1$s -sovellusta" + "%1$s -sovellusta ei tueta" + "QR-koodia ei tueta" + "Kirjautuminen peruutettiin toisella laitteella." + "Kirjautumispyyntö peruutettu" + "Kirjautuminen vanhentui. Yritä uudelleen." + "Kirjautumista ei suoritettu ajoissa" + "Valitse %1$s" + "Turvallista yhteyttä uuteen laitteeseen ei voitu muodostaa. Olemassa olevat laitteesi ovat edelleen turvassa, eikä sinun tarvitse huolehtia niistä." + "Mitä nyt?" + "Yritä kirjautua sisään uudelleen QR-koodilla, jos kyseessä oli verkko-ongelma" + "Jos kohtaat saman ongelman, kokeile toista wifi-verkkoa tai käytä mobiilidataa wifi-yhteyden sijaan" + "Jos tämä ei auta, kirjaudu sisään manuaalisesti" + "Yhteys ei ole turvallinen" + "Kirjautuminen peruutettiin toisella laitteella." + "Kirjautumispyyntö peruutettu" + "Kirjautuminen hylättiin toisella laitteella." + "Kirjautuminen hylätty" + "Kirjautuminen vanhentui. Yritä uudelleen." + "Kirjautumista ei suoritettu ajoissa" + "Toinen laitteesi ei tue kirjautumista %s -sovellukseen QR-koodilla. + +Yritä kirjautua sisään manuaalisesti tai skannaa QR-koodi toisella laitteella." + "QR-koodia ei tueta" + "Palveluntarjoajasi ei tue %1$s -sovellusta" + "%1$s -sovellusta ei tueta" + "Käytä toisessa laitteessa näkyvää QR-koodia." + "Yritä uudelleen" + "Väärä QR-koodi" + "Jatkaaksesi sinun on annettava lupa %1$s -sovellukselle käyttää laitteesi kameraa." + "Salli lupa kameraan QR-koodin skannaamiseksi" + "Tapahtui odottamaton virhe. Yritä uudelleen." + diff --git a/features/linknewdevice/impl/src/main/res/values-fr/translations.xml b/features/linknewdevice/impl/src/main/res/values-fr/translations.xml new file mode 100644 index 00000000000..0c91dca7a1c --- /dev/null +++ b/features/linknewdevice/impl/src/main/res/values-fr/translations.xml @@ -0,0 +1,55 @@ + + + "Scannez le code QR" + "Ouvrir %1$s sur un ordinateur" + "Scanner le code QR avec cet appareil" + "Prêt à scanner" + "Ouvrir %1$s sur un ordinateur pour obtenir le code QR" + "Les nombres ne correspondent pas" + "Saisissez le code à 2 chiffres" + "Cela permettra de vérifier que la connexion à votre autre appareil est sécurisée." + "Saisir le nombre affiché sur votre autre appareil" + "Votre fournisseur de compte ne supporte pas %1$s." + "%1$s n’est pas supporté" + "Votre fournisseur de compte ne prend pas en charge la connexion à un nouvel appareil à l’aide d’un code QR." + "Code QR non supporté" + "La connexion a été annulée sur l’autre appareil." + "Demande de connexion annulée" + "Connexion expirée. Veuillez essayer à nouveau." + "La connexion a pris trop de temps." + "Ouvrez %1$s sur l’autre appareil" + "Choisissez %1$s" + "« Se connecter avec un code QR »" + "Scannez ce code QR avec l’autre appareil." + "Ouvrez %1$s sur l’autre appareil" + "Ordinateur de bureau" + "Chargement du code QR…" + "Appareil mobile" + "Quel type d’appareil souhaitez-vous associer ?" + "Veuillez réessayer et assurez-vous d’avoir saisi correctement le code à 2 chiffres. Si les chiffres ne correspondent toujours pas, veuillez contacter votre fournisseur de compte." + "Les nombres ne correspondent pas" + "Aucune connexion sécurisée n’a pu être établie avec la nouvelle session. Vos sessions existantes sont toujours en sécurité et vous n’avez pas à vous en soucier." + "Et maintenant ?" + "Essayez de vous connecter à nouveau à l’aide du code QR au cas où il s’agirait d’un problème réseau" + "Si vous rencontrez le même problème, essayez un autre réseau wifi ou utilisez vos données mobiles au lieu du wifi" + "Si cela ne fonctionne pas, connectez-vous manuellement" + "La connexion n’est pas sécurisée" + "La connexion a été annulée sur l’autre appareil." + "Demande de connexion annulée" + "La connexion a été refusée sur l’autre appareil." + "Connexion refusée" + "Vous n’avez rien d’autre à faire." + "Votre autre appareil est déjà connecté" + "Connexion expirée. Veuillez essayer à nouveau." + "La connexion a pris trop de temps." + "Votre autre appareil ne supporte pas la connexion à %s avec un code QR. Essayer de vous connecter manuellement, ou scanner le code QR avec un autre appareil." + "Code QR non supporté" + "Votre fournisseur de compte ne supporte pas %1$s." + "%1$s n’est pas supporté" + "Scannez le code QR affiché sur l’autre appareil." + "Essayer à nouveau" + "Code QR erroné" + "Vous devez autoriser %1$s à utiliser la camera de votre appareil pour continuer." + "Autoriser l’usage de la caméra pour scanner le code QR" + "Une erreur inattendue s’est produite. Veuillez réessayer." + diff --git a/features/linknewdevice/impl/src/main/res/values-hr/translations.xml b/features/linknewdevice/impl/src/main/res/values-hr/translations.xml new file mode 100644 index 00000000000..20c194ef934 --- /dev/null +++ b/features/linknewdevice/impl/src/main/res/values-hr/translations.xml @@ -0,0 +1,57 @@ + + + "Skeniraj QR kod" + "Otvorite %1$s na prijenosnom ili stolnom računalu" + "Skenirajte QR kod ovim uređajem" + "Spremno za skeniranje" + "Otvorite %1$s na stolnom računalu kako biste dobili QR kod" + "Brojevi se ne podudaraju" + "Unesite dvoznamenkasti kod" + "Time ćete potvrditi da je veza s vašim drugim uređajem sigurna." + "Unesite broj prikazan na vašem drugom uređaju" + "Vaš davatelj usluga računa ne podržava %1$s ." + "%1$s nije podržan" + "Vaš davatelj usluga računa ne podržava prijavu na novi uređaj pomoću QR koda." + "QR kod nije podržan" + "Prijava je otkazana na drugom uređaju." + "Zahtjev za prijavu je otkazan" + "Prijava je istekla. Pokušajte ponovno." + "Prijava nije dovršena na vrijeme" + "Otvorite %1$s na drugom uređaju" + "Odaberite %1$s" + "“Prijavi se pomoću QR koda”" + "Skenirajte ovdje prikazani QR kod drugim uređajem" + "Otvorite %1$s na drugom uređaju" + "Stolno računalo" + "Učitavanje QR koda…" + "Mobilni uređaj" + "Koju vrstu uređaja želite povezati?" + "Pokušajte ponovno i provjerite jeste li ispravno unijeli dvoznamenkasti kod. Ako se brojevi i dalje ne podudaraju, obratite se davatelju usluge računa." + "Brojevi se ne podudaraju" + "Nije moguće uspostaviti sigurnu vezu s novim uređajem. Vaši postojeći uređaji i dalje su sigurni i ne morate se brinuti zbog njih." + "Što sad?" + "Pokušajte se ponovno prijaviti pomoću QR koda u slučaju da se radilo o problemu s mrežom" + "Ako se problem ponovi, pokušajte s drugom Wi-Fi mrežom ili mobilnim podatcima umjesto Wi-Fi-ja." + "Ako to ne uspije, prijavite se ručno" + "Veza nije sigurna" + "Prijava je otkazana na drugom uređaju." + "Zahtjev za prijavu je otkazan" + "Prijava je odbijena na drugom uređaju." + "Prijava je odbijena" + "Ne morate ništa drugo napraviti." + "Vaš drugi uređaj već je prijavljen" + "Prijava je istekla. Pokušajte ponovno." + "Prijava nije dovršena na vrijeme" + "Vaš drugi uređaj ne podržava prijavu na %s pomoću QR koda. + +Pokušajte se prijaviti ručno ili skenirajte QR kod drugim uređajem." + "QR kod nije podržan" + "Vaš davatelj usluga računa ne podržava %1$s ." + "%1$s nije podržan" + "Upotrijebite QR kod prikazan na drugom uređaju." + "Pokušajte ponovno" + "Pogrešan QR kod" + "Za nastavak morate dati dopuštenje za %1$s da biste se mogli služiti kamerom svog uređaja." + "Dopustite pristup kameri kako biste mogli skenirati QR kod" + "Došlo je do neočekivane pogreške. Pokušajte ponovno." + diff --git a/features/linknewdevice/impl/src/main/res/values-hu/translations.xml b/features/linknewdevice/impl/src/main/res/values-hu/translations.xml new file mode 100644 index 00000000000..b06d7e2fd6d --- /dev/null +++ b/features/linknewdevice/impl/src/main/res/values-hu/translations.xml @@ -0,0 +1,38 @@ + + + "Olvassa be a QR-kódot" + "Olvassa be a QR-kódot ezzel az eszközzel" + "Készen áll a beolvasásra" + "A fiókszolgáltatója nem támogatja az %1$s-et." + "Az %1$s nem támogatott" + "A QR-kód nem támogatott" + "A bejelentkezést megszakították a másik eszközön." + "Bejelentkezési kérés törölve" + "A bejelentkezés lejárt. Próbálja újra." + "A bejelentkezés nem fejeződött be időben" + "Válassza ezt: %1$s" + "Nem sikerült biztonságos kapcsolatot létesíteni az új eszközzel. A meglévő eszközei továbbra is biztonságban vannak, és nem kell aggódnia miattuk." + "Most mi lesz?" + "Próbáljon meg újra bejelentkezni egy QR-kóddal, ha ez hálózati probléma volt." + "Ha ugyanezzel a problémával találkozik, próbálkozzon másik Wi-Fi-hálózattal, vagy a Wi-Fi helyett használja a mobil-adatkapcsolatát" + "Ha ez nem működik, jelentkezzen be kézileg" + "A kapcsolat nem biztonságos" + "A bejelentkezést megszakították a másik eszközön." + "Bejelentkezési kérés törölve" + "A bejelentkezést elutasították a másik eszközön." + "A bejelentkezés elutasítva" + "A bejelentkezés lejárt. Próbálja újra." + "A bejelentkezés nem fejeződött be időben" + "A másik eszköz nem támogatja QR-kóddal történő bejelentkezést az %sbe. + +Próbáljon meg kézileg bejelentkezni, vagy olvassa be a QR-kódot egy másik eszközzel." + "A QR-kód nem támogatott" + "A fiókszolgáltatója nem támogatja az %1$s-et." + "Az %1$s nem támogatott" + "Használja a másik eszközön látható QR-kódot." + "Próbálja újra" + "Hibás QR-kód" + "A folytatáshoz engedélyeznie kell, hogy az %1$s használhassa az eszköz kameráját." + "Engedélyezze a kamera elérését a QR-kód beolvasásához" + "Váratlan hiba történt. Próbálja meg újra." + diff --git a/features/linknewdevice/impl/src/main/res/values-in/translations.xml b/features/linknewdevice/impl/src/main/res/values-in/translations.xml new file mode 100644 index 00000000000..20badba9ba1 --- /dev/null +++ b/features/linknewdevice/impl/src/main/res/values-in/translations.xml @@ -0,0 +1,38 @@ + + + "Pindai kode QR" + "Pindai kode QR dengan perangkat ini" + "Siap untuk memindai" + "Penyedia akun Anda tidak mendukung %1$s." + "%1$s tidak didukung" + "Kode QR tidak didukung" + "Proses masuk dibatalkan di perangkat lain." + "Permintaan masuk dibatalkan" + "Masa masuk kedaluwarsa. Silakan coba lagi." + "Proses masuk tidak selesai tepat waktu" + "Pilih %1$s" + "Koneksi aman tidak dapat dibuat ke perangkat baru. Perangkat Anda yang ada masih aman dan Anda tidak perlu khawatir tentang mereka." + "Apa sekarang?" + "Coba masuk lagi dengan kode QR jika ini adalah masalah jaringan" + "Jika Anda mengalami masalah yang sama, coba jaringan Wi-Fi yang berbeda atau gunakan data seluler Anda daripada Wi-Fi" + "Jika tidak berhasil, masuk secara manual" + "Koneksi tidak aman" + "Proses masuk dibatalkan di perangkat lain." + "Permintaan masuk dibatalkan" + "Proses masuk ditolak di perangkat lain." + "Proses masuk ditolak" + "Masa masuk kedaluwarsa. Silakan coba lagi." + "Proses masuk tidak selesai tepat waktu" + "Perangkat Anda yang lain tidak mendukung masuk ke %s dengan kode QR. + +Coba masuk secara manual, atau pindai kode QR dengan perangkat lain." + "Kode QR tidak didukung" + "Penyedia akun Anda tidak mendukung %1$s." + "%1$s tidak didukung" + "Gunakan kode QR yang ditampilkan di perangkat lain." + "Coba lagi" + "Kode QR salah" + "Anda perlu memberikan izin ke %1$s untuk menggunakan kamera perangkat Anda untuk melanjutkan." + "Izinkan akses kamera untuk memindai kode QR" + "Terjadi kesalahan tak terduga. Silakan coba lagi." + diff --git a/features/linknewdevice/impl/src/main/res/values-it/translations.xml b/features/linknewdevice/impl/src/main/res/values-it/translations.xml new file mode 100644 index 00000000000..0cf548c19bd --- /dev/null +++ b/features/linknewdevice/impl/src/main/res/values-it/translations.xml @@ -0,0 +1,38 @@ + + + "Scansiona il codice QR" + "Scansiona il codice QR con questo dispositivo" + "Pronto per la scansione" + "Il tuo fornitore di account non supporta %1$s." + "%1$s non supportato" + "Codice QR non supportato" + "L\'accesso è stato annullato sull\'altro dispositivo." + "Richiesta di accesso annullata" + "L\'accesso è scaduto. Riprova." + "L\'accesso non è stato completato in tempo" + "Seleziona %1$s" + "Non è stato possibile stabilire una connessione sicura con il nuovo dispositivo. I tuoi dispositivi esistenti sono ancora al sicuro e non devi preoccuparti di loro." + "E adesso?" + "Prova ad accedere di nuovo con un codice QR nel caso si sia verificato un problema di rete." + "Se riscontri lo stesso problema, prova con un altra rete wifi o usa i dati mobili al posto del wifi." + "Se il problema persiste, accedi manualmente" + "La connessione non è sicura" + "L\'accesso è stato annullato sull\'altro dispositivo." + "Richiesta di accesso annullata" + "L\'accesso è stato rifiutato sull\'altro dispositivo." + "Accesso rifiutato" + "L\'accesso è scaduto. Riprova." + "L\'accesso non è stato completato in tempo" + "L\'altro dispositivo non supporta l\'accesso a %s con un codice QR. + +Prova ad accedere manualmente o scansiona il codice QR con un altro dispositivo." + "Codice QR non supportato" + "Il tuo fornitore di account non supporta %1$s." + "%1$s non supportato" + "Usa il codice QR mostrato sull\'altro dispositivo." + "Riprova" + "Codice QR sbagliato" + "Per continuare, è necessario fornire l\'autorizzazione a %1$s per utilizzare la fotocamera del dispositivo." + "Consenti l\'accesso alla fotocamera per la scansione del codice QR" + "Si è verificato un errore inatteso. Riprova." + diff --git a/features/linknewdevice/impl/src/main/res/values-ka/translations.xml b/features/linknewdevice/impl/src/main/res/values-ka/translations.xml new file mode 100644 index 00000000000..f378e585ceb --- /dev/null +++ b/features/linknewdevice/impl/src/main/res/values-ka/translations.xml @@ -0,0 +1,4 @@ + + + "ხელახლა ცდა" + diff --git a/features/linknewdevice/impl/src/main/res/values-ko/translations.xml b/features/linknewdevice/impl/src/main/res/values-ko/translations.xml new file mode 100644 index 00000000000..6af49fd3cf6 --- /dev/null +++ b/features/linknewdevice/impl/src/main/res/values-ko/translations.xml @@ -0,0 +1,38 @@ + + + "QR 코드를 스캔하세요" + "이 기기로 QR 코드를 스캔하세요." + "스캔 준비 완료" + "귀하의 계정 제공자는 지원하지 않습니다 %1$s ." + "%1$s 지원되지 않습니다" + "QR 코드는 지원되지 않습니다" + "다른 기기에서 로그인이 취소되었습니다." + "로그인 요청이 취소되었습니다" + "로그인이 만료되었습니다. 다시 시도해 주세요." + "로그인 시간이 초과되었습니다." + "선택 %1$s" + "새 장치에 안전하게 연결할 수 없습니다. 기존 장치는 여전히 안전하므로 걱정할 필요가 없습니다." + "이제 어떻게 해야 할까?" + "네트워크 문제로 인해 로그인에 실패한 경우 QR 코드로 다시 로그인해 보세요." + "동일한 문제를 겪으신 경우 다른 Wi-Fi 네트워크를 사용해 보거나 Wi-Fi 대신 모바일 데이터를 사용해 보세요." + "만약 작동하지 않는 경우, 수동으로 로그인하세요." + "연결이 안전하지 않습니다" + "다른 기기에서 로그인이 취소되었습니다." + "로그인 요청이 취소되었습니다" + "다른 기기에서 로그인이 거부되었습니다." + "로그인 거부됨" + "로그인이 만료되었습니다. 다시 시도해 주세요." + "로그인 시간이 초과되었습니다." + "다른 기기에서는 QR 코드로 %s 에 로그인할 수 없습니다. + +수동으로 로그인하거나 다른 기기로 QR 코드를 스캔해 보세요." + "QR 코드는 지원되지 않습니다" + "귀하의 계정 제공자는 지원하지 않습니다 %1$s ." + "%1$s 지원되지 않습니다" + "다른 기기에 표시된 QR 코드를 사용하세요." + "다시 시도하기" + "잘못된 QR 코드" + "계속하려면 %1$s 가 기기의 카메라를 사용할 수 있도록 권한을 부여해야 합니다." + "카메라 액세스를 허용하여 QR 코드를 스캔하세요" + "예기치 않은 오류가 발생했습니다. 다시 시도해 주세요." + diff --git a/features/linknewdevice/impl/src/main/res/values-nb/translations.xml b/features/linknewdevice/impl/src/main/res/values-nb/translations.xml new file mode 100644 index 00000000000..af3559d3e8a --- /dev/null +++ b/features/linknewdevice/impl/src/main/res/values-nb/translations.xml @@ -0,0 +1,38 @@ + + + "Skann QR-koden" + "Skann QR-koden med denne enheten" + "Klar til å skanne" + "Kontotilbyderen din støtter ikke %1$s." + "%1$s støttes ikke" + "QR-kode støttes ikke" + "Påloggingen ble kansellert på den andre enheten." + "Påloggingsforespørsel kansellert" + "Påloggingen er utløpt. Vennligst prøv igjen." + "Påloggingen ble ikke fullført i tide" + "Velg %1$s" + "En sikker tilkobling kunne ikke opprettes til den nye enheten. Dine eksisterende enheter er fortsatt trygge, og du trenger ikke å bekymre deg for dem." + "Hva nå?" + "Prøv å logge på igjen med en QR-kode i tilfelle dette var et nettverksproblem" + "Hvis du støter på det samme problemet, kan du prøve et annet wifi-nettverk eller bruke mobildata i stedet for wifi" + "Hvis det ikke fungerer, kan du logge på manuelt" + "Forbindelsen er ikke sikker" + "Påloggingen ble kansellert på den andre enheten." + "Påloggingsforespørsel kansellert" + "Påloggingen ble avvist på den andre enheten." + "Pålogging avslått" + "Påloggingen er utløpt. Vennligst prøv igjen." + "Påloggingen ble ikke fullført i tide" + "Den andre enheten din støtter ikke pålogging på %s med en QR-kode. + +Prøv å logge på manuelt, eller skann QR-koden med en annen enhet." + "QR-kode støttes ikke" + "Kontotilbyderen din støtter ikke %1$s." + "%1$s støttes ikke" + "Bruk QR-koden som vises på den andre enheten." + "Prøv igjen" + "Feil QR-kode" + "Du må gi tillatelse til at %1$s kan bruke enhetens kamera for å fortsette." + "Tillat kameratilgang for å skanne QR-koden" + "Det oppstod en uventet feil. Prøv igjen." + diff --git a/features/linknewdevice/impl/src/main/res/values-nl/translations.xml b/features/linknewdevice/impl/src/main/res/values-nl/translations.xml new file mode 100644 index 00000000000..407a470e489 --- /dev/null +++ b/features/linknewdevice/impl/src/main/res/values-nl/translations.xml @@ -0,0 +1,38 @@ + + + "Scan de QR-code" + "Scan de QR-code met dit apparaat" + "Klaar om te scannen" + "Je accountprovider ondersteunt geen %1$s." + "%1$s wordt niet ondersteund" + "QR-code wordt niet ondersteund" + "De aanmelding is geannuleerd op het andere apparaat." + "Login verzoek geannuleerd" + "Aanmelden is verlopen. Probeer het opnieuw." + "De aanmelding was niet op tijd voltooid" + "Selecteer %1$s" + "Er kon geen beveiligde verbinding worden gemaakt met het nieuwe apparaat. Je bestaande apparaten zijn nog steeds veilig en je hoeft je daarover geen zorgen te maken." + "Wat nu?" + "Probeer opnieuw in te loggen met een QR-code voor het geval dit een netwerkprobleem was" + "Als je hetzelfde probleem ondervindt, probeer dan een ander wifi-netwerk of gebruik je mobiele data in plaats van wifi." + "Als dat niet werkt, log dan handmatig in" + "Verbinding niet veilig" + "De aanmelding is geannuleerd op het andere apparaat." + "Login verzoek geannuleerd" + "De aanmelding is geweigerd op het andere apparaat." + "Aanmelden geweigerd" + "Aanmelden is verlopen. Probeer het opnieuw." + "De aanmelding was niet op tijd voltooid" + "Jouw andere apparaat ondersteunt geen inloggen op %s met een QR code. + +Probeer handmatig in te loggen, of scan de QR code met een ander apparaat." + "QR-code wordt niet ondersteund" + "Je accountprovider ondersteunt geen %1$s." + "%1$s wordt niet ondersteund" + "Gebruik de QR-code die op het andere apparaat wordt weergegeven." + "Probeer het opnieuw" + "Verkeerde QR-code" + "Je moet %1$s toestemming geven om de camera van je apparaat te gebruiken om verder te gaan." + "Cameratoegang toestaan om de QR-code te scannen" + "Er is een onverwachte fout opgetreden. Probeer het opnieuw." + diff --git a/features/linknewdevice/impl/src/main/res/values-pl/translations.xml b/features/linknewdevice/impl/src/main/res/values-pl/translations.xml new file mode 100644 index 00000000000..4db42a2a498 --- /dev/null +++ b/features/linknewdevice/impl/src/main/res/values-pl/translations.xml @@ -0,0 +1,38 @@ + + + "Skanuj kod QR" + "Zeskanuj kod QR za pomocą tego urządzenia" + "Gotowy do skanowania" + "Twój dostawca konta nie obsługuje %1$s." + "%1$s nie jest wspierany" + "Kod QR nie jest wspierany" + "Logowanie zostało anulowane na drugim urządzeniu." + "Prośba o logowanie została anulowana" + "Logowanie wygasło. Spróbuj ponownie." + "Logowanie nie zostało ukończone na czas" + "Wybierz %1$s" + "Nie udało się nawiązać bezpiecznego połączenia z nowym urządzeniem. Twoje istniejące urządzenia są nadal bezpieczne i nie musisz się o nie martwić." + "Co teraz?" + "Spróbuj zalogować się ponownie za pomocą kodu QR, jeśli byłby to problem z siecią" + "Jeśli napotkasz ten sam problem, użyj innej sieci Wi-FI lub danych mobilnych" + "Jeśli to nie zadziała, zaloguj się ręcznie" + "Połączenie nie jest bezpieczne" + "Logowanie zostało anulowane na drugim urządzeniu." + "Prośba o logowanie została anulowana" + "Logowanie zostało odrzucone na drugim urządzeniu." + "Logowanie odrzucone" + "Logowanie wygasło. Spróbuj ponownie." + "Logowanie nie zostało ukończone na czas" + "Twoje drugie urządzenie nie wspiera logowania się do %s za pomocą kodu QR. + +Spróbuj zalogować się ręcznie lub zeskanuj kod QR na innym urządzeniu." + "Kod QR nie jest wspierany" + "Twój dostawca konta nie obsługuje %1$s." + "%1$s nie jest wspierany" + "Użyj kodu QR widocznego na drugim urządzeniu." + "Spróbuj ponownie" + "Błędny kod QR" + "Musisz przyznać uprawnienia %1$s do korzystania z kamery, aby kontynuować." + "Zezwól na dostęp do kamery, aby zeskanować kod QR" + "Wystąpił nieoczekiwany błąd. Spróbuj ponownie." + diff --git a/features/linknewdevice/impl/src/main/res/values-pt-rBR/translations.xml b/features/linknewdevice/impl/src/main/res/values-pt-rBR/translations.xml new file mode 100644 index 00000000000..f11bdc6e6d6 --- /dev/null +++ b/features/linknewdevice/impl/src/main/res/values-pt-rBR/translations.xml @@ -0,0 +1,57 @@ + + + "Leia o código QR" + "Abra o %1$s em um computador" + "Leia o código QR com este dispositivo" + "Pronto para ler" + "Abra o %1$s em um computador para receber o código QR" + "Os números não conferem" + "Digite o código de 2 dígitos" + "Isso verificará que a conexão com o seu outro dispositivo é segura." + "Digite o número exibido no outro dispositivo" + "Seu provedor de conta não tem suporte ao %1$s." + "%1$s não suportado" + "O seu provedor de conta não tem suporte a autenticação de dispositivos novos com um código QR." + "Código QR não suportado" + "A entrada foi cancelada no outro dispositivo." + "Solicitação de entrada foi cancelada" + "O processo de entrada expirou. Tente novamente." + "A entrada não foi concluída a tempo" + "Abra o %1$s no outro dispositivo" + "Selecione %1$s" + "\"Entrar com código QR\"" + "Leia o código QR exibido aqui com o outro dispositivo" + "Abra o %1$s no outro dispositivo" + "Computador" + "Carregando código QR…" + "Dispositivo móvel" + "Que tipo de dispositivo você deseja vincular?" + "Tente novamente e certifique-se que digitou o código de 2 dígitos corretamente. Se os números ainda não conferirem, entre em contato com o provedor da sua conta." + "Os números não conferem" + "Não foi possível estabelecer uma conexão segura com o novo dispositivo. Seus dispositivos existentes ainda estão seguros e você não precisa se preocupar com eles." + "E agora?" + "Tente entrar novamente com um código QR caso seja um problema de rede" + "Se o problema persistir, tente uma rede Wi-Fi diferente ou use seus dados móveis em vez de Wi-Fi" + "Se isso não funcionar, entre manualmente" + "Conexão insegura" + "A entrada foi cancelada no outro dispositivo." + "Solicitação de entrada foi cancelada" + "A entrada foi recusada no outro dispositivo." + "Entrada recusada" + "Você não precisa fazer mais nada." + "O seu outro dispositivo já está conectado" + "O processo de entrada expirou. Tente novamente." + "A entrada não foi concluída a tempo" + "Seu outro dispositivo não tem suporte a entrar no %s com um código QR. + +Tente entrar manualmente ou ler o código QR com outro dispositivo." + "Código QR não suportado" + "Seu provedor de conta não tem suporte ao %1$s." + "%1$s não suportado" + "Use o código QR exibido no outro dispositivo." + "Tente novamente" + "Código QR errado" + "Você deve permitir que o %1$s use a câmera do seu dispositivo para continuar." + "Permita o acesso à câmera para ler o código QR" + "Ocorreu um erro inesperado. Tente novamente." + diff --git a/features/linknewdevice/impl/src/main/res/values-pt/translations.xml b/features/linknewdevice/impl/src/main/res/values-pt/translations.xml new file mode 100644 index 00000000000..da6da08f383 --- /dev/null +++ b/features/linknewdevice/impl/src/main/res/values-pt/translations.xml @@ -0,0 +1,38 @@ + + + "Ler o código QR" + "Lê o código QR com este dispositivo" + "Pronto para ler" + "O teu operador de conta não suporta %1$s." + "%1$s não suportado" + "Código QR não suportado" + "O início de sessão foi cancelado no outro dispositivo." + "Pedido de início de sessão cancelado" + "O início de sessão expirou. Por favor, tenta novamente." + "O início de sessão não foi concluído a tempo" + "Seleciona %1$s" + "Não foi possível estabelecer uma ligação segura com o novo dispositivo. Os teus outros dispositivos continuam seguros, não precisas de te preocupar com eles." + "E agora?" + "Tenta iniciar sessão novamente com um código QR, caso se trate de um problema de rede" + "Se tiveres o mesmo problema, experimenta uma rede Wi-Fi diferente ou utiliza os teus dados móveis." + "Se isso não funcionar, inicia sessão manualmente" + "Ligação insegura" + "O início de sessão foi cancelado no outro dispositivo." + "Pedido de início de sessão cancelado" + "O início de sessão foi rejeitado no outro dispositivo." + "Início de sessão rejeitado" + "O início de sessão expirou. Por favor, tenta novamente." + "O início de sessão não foi concluído a tempo" + "O teu outro dispositivo não suporta o início de sessão na %s com um código QR. + +Tenta iniciar a sessão manualmente ou digitaliza o código QR com outro dispositivo." + "Código QR não suportado" + "O teu operador de conta não suporta %1$s." + "%1$s não suportado" + "Lê o código QR apresentado no outro dispositivo." + "Tentar novamente" + "Código QR inválido" + "Para continuar, tens que dar permissão à %1$s para aceder à câmara do teu dispositivo." + "Permitir o acesso à câmara para ler o código QR" + "Ocorreu um erro inesperado. Tenta novamente." + diff --git a/features/linknewdevice/impl/src/main/res/values-ro/translations.xml b/features/linknewdevice/impl/src/main/res/values-ro/translations.xml new file mode 100644 index 00000000000..f1a4f3db592 --- /dev/null +++ b/features/linknewdevice/impl/src/main/res/values-ro/translations.xml @@ -0,0 +1,54 @@ + + + "Scanați codul QR" + "Deschide %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" + "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." + "%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" + "Deschideți %1$s pe celălalt dispozitiv" + "Selectați %1$s" + "“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" + "Se încarcă codul QR…" + "Dispozitiv mobil" + "Ce tip de dispozitiv doriți să conectați?" + "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." + "Ș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." + "Dacă nu funcționează, conectați-vă manual" + "Conexiunea nu este sigură" + "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ă" + "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. + +Î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." + "%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." + diff --git a/features/linknewdevice/impl/src/main/res/values-ru/translations.xml b/features/linknewdevice/impl/src/main/res/values-ru/translations.xml new file mode 100644 index 00000000000..16ed8eeb37c --- /dev/null +++ b/features/linknewdevice/impl/src/main/res/values-ru/translations.xml @@ -0,0 +1,38 @@ + + + "Сканировать QR-код" + "Отсканируйте QR-код с помощью этого устройства" + "Готово к сканированию" + "Поставщик учетной записи не поддерживает %1$s." + "%1$s не поддерживается" + "QR-код не поддерживается" + "Вход на другом устройстве был отменен." + "Запрос на вход отменен" + "Срок действия входа истек. Пожалуйста, попробуйте еще раз." + "Вход в систему не был выполнен вовремя" + "Выберите %1$s" + "Не удалось установить безопасное соединение с новым устройством. Существующие устройства по-прежнему в безопасности, и вам не нужно беспокоиться о них." + "Что теперь?" + "Попробуйте снова войти в систему с помощью QR-кода, если это была проблема с соединением" + "Если вы столкнулись с той же проблемой, попробуйте сменить точку доступа Wi-Fi или используйте мобильные данные" + "Если это не помогло, войдите вручную" + "Соединение не защищено" + "Вход на другом устройстве был отменен." + "Запрос на вход отменен" + "Вход в систему был отклонен на другом устройстве." + "Вход отклонен" + "Срок действия входа истек. Пожалуйста, попробуйте еще раз." + "Вход в систему не был выполнен вовремя" + "Другое устройство не поддерживает вход в %s с помощью QR-кода. + +Попробуйте войти вручную или отсканируйте QR-код на другом устройстве." + "QR-код не поддерживается" + "Поставщик учетной записи не поддерживает %1$s." + "%1$s не поддерживается" + "Используйте QR-код, показанный на другом устройстве." + "Повторить попытку" + "Неверный QR-код" + "Чтобы продолжить, вам необходимо разрешить %1$s использовать камеру вашего устройства." + "Разрешите доступ к камере для сканирования QR-кода" + "Произошла непредвиденная ошибка. Пожалуйста, попробуйте еще раз." + diff --git a/features/linknewdevice/impl/src/main/res/values-sk/translations.xml b/features/linknewdevice/impl/src/main/res/values-sk/translations.xml new file mode 100644 index 00000000000..9617df3acc0 --- /dev/null +++ b/features/linknewdevice/impl/src/main/res/values-sk/translations.xml @@ -0,0 +1,38 @@ + + + "Naskenovať QR kód" + "Naskenujte QR kód pomocou tohto zariadenia" + "Pripravené na skenovanie" + "Poskytovateľ vášho účtu nepodporuje %1$s." + "%1$s nie je podporovaný" + "QR kód nie je podporovaný" + "Prihlásenie bolo zrušené na druhom zariadení." + "Žiadosť o prihlásenie bola zrušená" + "Platnosť prihlásenia vypršala. Skúste to prosím znova." + "Prihlásenie nebolo včas dokončené" + "Vyberte %1$s" + "K novému zariadeniu sa nepodarilo vytvoriť bezpečné pripojenie. Vaše existujúce zariadenia sú stále v bezpečí a nemusíte sa o ne obávať." + "Čo teraz?" + "Skúste sa znova prihlásiť pomocou QR kódu v prípade, že ide o problém so sieťou" + "Ak narazíte na rovnaký problém, vyskúšajte inú sieť Wi-Fi alebo namiesto siete Wi-Fi použite mobilné dáta" + "Ak to nefunguje, prihláste sa manuálne" + "Pripojenie nie je bezpečené" + "Prihlásenie bolo zrušené na druhom zariadení." + "Žiadosť o prihlásenie bola zrušená" + "Prihlásenie bolo zamietnuté na druhom zariadení." + "Prihlásenie bolo odmietnuté" + "Platnosť prihlásenia vypršala. Skúste to prosím znova." + "Prihlásenie nebolo včas dokončené" + "Vaše druhé zariadenie nepodporuje prihlásenie do aplikácie %s pomocou QR kódu. + +Skúste sa prihlásiť manuálne alebo naskenujte QR kód pomocou iného zariadenia." + "QR kód nie je podporovaný" + "Poskytovateľ vášho účtu nepodporuje %1$s." + "%1$s nie je podporovaný" + "Použite QR kód zobrazený na druhom zariadení." + "Skúste to znova" + "Nesprávny QR kód" + "Ak chcete pokračovať, musíte udeliť povolenie aplikácii %1$s používať fotoaparát vášho zariadenia." + "Povoľte prístup k fotoaparátu na naskenovanie QR kódu" + "Vyskytla sa neočakávaná chyba. Prosím, skúste to znova." + diff --git a/features/linknewdevice/impl/src/main/res/values-sv/translations.xml b/features/linknewdevice/impl/src/main/res/values-sv/translations.xml new file mode 100644 index 00000000000..8a1bef434ae --- /dev/null +++ b/features/linknewdevice/impl/src/main/res/values-sv/translations.xml @@ -0,0 +1,38 @@ + + + "Skanna QR-koden" + "Skanna QR-koden med den här enheten" + "Redo att skanna" + "Din kontoleverantör stöder inte %1$s." + "%1$s stöds inte" + "QR-kod stöds inte" + "Inloggningen avbröts på den andra enheten." + "Inloggningsförfrågan avbröts" + "Inloggningen har löpt ut. Vänligen försök igen." + "Inloggningen slutfördes inte i tid" + "Välj %1$s" + "En säker anslutning kunde inte göras till den nya enheten. Dina befintliga enheter är fortfarande säkra och du behöver inte oroa dig för dem." + "Nu då?" + "Pröva att logga in igen med en QR-kod ifall detta skulle vara ett nätverksproblem" + "Om du stöter på samma problem, prova ett annat wifi-nätverk eller använd din mobildata istället för wifi" + "Om det inte fungerar, logga in manuellt" + "Anslutningen är inte säker" + "Inloggningen avbröts på den andra enheten." + "Inloggningsförfrågan avbröts" + "Inloggningen avvisades på den andra enheten." + "Inloggning avvisad" + "Inloggningen har löpt ut. Vänligen försök igen." + "Inloggningen slutfördes inte i tid" + "Din andra enhet stöder inte inloggning i %s med en QR-kod. + +Prova att logga in manuellt eller skanna QR-koden med en annan enhet." + "QR-kod stöds inte" + "Din kontoleverantör stöder inte %1$s." + "%1$s stöds inte" + "Använd QR-koden som visas på den andra enheten." + "Försök igen" + "Fel QR-kod" + "Du måste ge tillstånd för %1$s att använda enhetens kamera för att kunna fortsätta." + "Tillåt kameraåtkomst för att skanna QR-koden" + "Ett oväntat fel inträffade. Vänligen försök igen." + diff --git a/features/linknewdevice/impl/src/main/res/values-tr/translations.xml b/features/linknewdevice/impl/src/main/res/values-tr/translations.xml new file mode 100644 index 00000000000..2d3bebcad8d --- /dev/null +++ b/features/linknewdevice/impl/src/main/res/values-tr/translations.xml @@ -0,0 +1,38 @@ + + + "QR kodunu tara" + "QR kodunu bu cihazla tarayın" + "Taramaya hazır" + "Hesap sağlayıcınız %1$s desteklemiyor." + "%1$s desteklenmiyor" + "QR kodu desteklenmiyor" + "Oturum açma işlemi diğer cihazda iptal edildi." + "Oturum açma isteği iptal edildi" + "Oturum açma süresi doldu. Lütfen tekrar deneyin." + "Oturum açma işlemi zamanında tamamlanmadı" + "Seç %1$s" + "Yeni cihaza güvenli bir bağlantı kurulamadı. Mevcut cihazlarınız hala güvende ve onlar için endişelenmenize gerek yok." + "Şimdi ne olacak?" + "Bunun bir ağ sorunu olması ihtimaline karşı bir QR koduyla tekrar oturum açmayı deneyin" + "Aynı sorunla karşılaşırsanız, farklı bir wifi ağı deneyin veya wifi yerine mobil verinizi kullanın" + "Bu işe yaramazsa, manuel olarak oturum açın" + "Bağlantı güvenli değil" + "Oturum açma işlemi diğer cihazda iptal edildi." + "Oturum açma isteği iptal edildi" + "Diğer cihazda oturum açma işlemi reddedildi." + "Oturum açma reddedildi" + "Oturum açma süresi doldu. Lütfen tekrar deneyin." + "Oturum açma işlemi zamanında tamamlanmadı" + "Diğer cihazınız %s QR koduyla oturum açmayı desteklemiyor. + +Manuel olarak oturum açmayı deneyin veya QR kodunu başka bir cihazla tarayın." + "QR kodu desteklenmiyor" + "Hesap sağlayıcınız %1$s desteklemiyor." + "%1$s desteklenmiyor" + "Diğer cihazda gösterilen QR kodunu kullan." + "Tekrar deneyin" + "Yanlış QR kodu" + "Devam etmek için %1$s cihazınızın kamerasını kullanmasına izin vermeniz gerekir." + "QR kodunu taramak için kamera erişimine izin verin" + "Beklenmeyen bir hata oluştu. Lütfen tekrar deneyin." + diff --git a/features/linknewdevice/impl/src/main/res/values-uk/translations.xml b/features/linknewdevice/impl/src/main/res/values-uk/translations.xml new file mode 100644 index 00000000000..e7104a914da --- /dev/null +++ b/features/linknewdevice/impl/src/main/res/values-uk/translations.xml @@ -0,0 +1,38 @@ + + + "Зіскануйте QR-код" + "Зіскануйте QR-код цим пристроєм" + "Готовий до сканування" + "Постачальник вашого облікового запису не підтримує %1$s." + "%1$s не підтримується" + "QR-код не підтримується" + "Вхід було скасовано на іншому пристрої." + "Запит на вхід скасовано" + "Термін входу сплив. Будь ласка, спробуйте ще раз." + "Вхід не було завершено вчасно" + "Виберіть %1$s" + "Не вдалося встановити безпечне з\'єднання з новим пристроєм. Ваші наявні пристрої досі в безпеці, і вам не потрібно про них турбуватися." + "Що тепер?" + "Спробуйте увійти ще раз за допомогою QR-коду, якщо це була проблема з мережею" + "Якщо ви зіткнулися з тією ж проблемою, спробуйте іншу мережу Wi-Fi або використовуйте мобільний інтернет замість Wi-Fi" + "Якщо це не спрацює, увійдіть вручну" + "З\'єднання не безпечне" + "Вхід було скасовано на іншому пристрої." + "Запит на вхід скасовано" + "Вхід був відхилений на іншому пристрої." + "Вхід відхилено" + "Термін входу сплив. Будь ласка, спробуйте ще раз." + "Вхід не було завершено вчасно" + "Ваш інший пристрій не підтримує вхід у %s за допомогою QR-коду. + +Спробуйте ввійти вручну або відскануйте QR-код за допомогою іншого пристрою." + "QR-код не підтримується" + "Постачальник вашого облікового запису не підтримує %1$s." + "%1$s не підтримується" + "Використовуйте QR-код, показаний на іншому пристрої." + "Спробуйте ще раз" + "Неправильний QR-код" + "Вам потрібно дати дозвіл %1$s на використання камери вашого пристрою, щоб продовжити." + "Надайте доступ до камери, щоб сканувати QR-код" + "Сталася несподівана помилка. Будь ласка, спробуйте ще раз." + diff --git a/features/linknewdevice/impl/src/main/res/values-ur/translations.xml b/features/linknewdevice/impl/src/main/res/values-ur/translations.xml new file mode 100644 index 00000000000..54d2c2e401b --- /dev/null +++ b/features/linknewdevice/impl/src/main/res/values-ur/translations.xml @@ -0,0 +1,38 @@ + + + "کیو آر رمز مسح ضوئی کریں" + "اس آلے کے ساتھ کیو آر رمز مسح ضوئی کریں" + "مسح ضوئی کیلئے تیار" + "آپ کا کھاتہ فراہم کنندہ %1$s کا تعاون نہیں کرتا۔" + "%1$s تعاون یافتہ نہیں" + "کر رمز غیر تعاون یافتہ" + "دوسرے آلے پر دخول منسوخ کر دیا گیا تھا۔" + "دخول کی درخواست منسوخ" + "دخول کی میعاد ختم۔ برائے مہربانی دوبارہ کوشش کریں۔" + "دخول وقت پر مکمل نہیں ہوا تھا" + "%1$s منتخب کریں" + "نئے آلے سے محفوظ اتصال نہیں بنایا جا سکا۔ آپ کے موجودہ آلات اب بھی محفوظ ہیں اور آپ کو ان کے بارے میں فکر کرنے کی ضرورت نہیں ہے۔" + "اب کیا؟" + "اگر یہ شبکہ کا مسئلہ تھا تو کیو آر رمز کے ساتھ دوبارہ داخل ہونے کی کوشش کریں۔" + "اگر آپ کو بھی یہی مسئلہ درپیش ہو، تو کوئی دوسرا وائی فائی شبکہ آزمائیں یا وائی فائی کے بجائے اپنے محمول بیانات استعمال کریں۔" + "اگر یہ کام نہ کرے، تو دستی طور پر داخل ہوں" + "اتصال محفوظ نہیں" + "دوسرے آلے پر دخول منسوخ کر دیا گیا تھا۔" + "دخول کی درخواست منسوخ" + "دوسرے آلہ پر دخول کو مسترد کر دیا گیا تھا۔" + "دخول مسترد کیا گیا" + "دخول کی میعاد ختم۔ برائے مہربانی دوبارہ کوشش کریں۔" + "دخول وقت پر مکمل نہیں ہوا تھا" + "آپ کا دوسرا آلہ کیو آر رمز کے ساتھ %s میں دخول کا تعاون نہیں کرتا۔ + +دستی طور پر داخل ہونے کی کوشش کریں ، یا کسی دوسرے آلے سے کیو آر رمز مسح ضوئی کریں۔" + "کر رمز غیر تعاون یافتہ" + "آپ کا کھاتہ فراہم کنندہ %1$s کا تعاون نہیں کرتا۔" + "%1$s تعاون یافتہ نہیں" + "دوسرے آلے پر دکھایا گیا کیو آر رمز استعمال کریں۔" + "دوبارہ کوشش کریں" + "غلط کیو آر رمز" + "جاری رکھنے کے لیے آپ %1$s کو اپنے آلے کا تصویرگر استعمال کرنے کی اجازت دینے کی ضرورت ہے۔" + "کیو آر رمز کو مسح ضوئی کرنے کے لئے تصویرگر تک رسائی کی اجازت دیں" + "ایک غیر متوقع نقص واقع ہوا۔ برائے مہربانی دوبارہ کوشش کریں۔" + diff --git a/features/linknewdevice/impl/src/main/res/values-uz/translations.xml b/features/linknewdevice/impl/src/main/res/values-uz/translations.xml new file mode 100644 index 00000000000..d2f3b7a8650 --- /dev/null +++ b/features/linknewdevice/impl/src/main/res/values-uz/translations.xml @@ -0,0 +1,38 @@ + + + "QR kodni skanerlash" + "Bu qurilma bilan QR kodni skanerlang" + "Skanerlashga tayyor" + "Hisob provayderingiz %1$s bilan ishlamaydi." + "%1$s qoʻllab-quvvatlanmaydi" + "QR kod qoʻllab-quvvatlanmaydi" + "Boshqa qurilmadan hisobga kirish bekor qilindi." + "Tizimga kirish soʻrovi bekor qilindi" + "Kirish muddati tugagan. Iltimos, qayta urinib koʻring." + "Kirish oʻz vaqtida tugallanmagan" + "%1$sʼni tanlang" + "Yangi qurilmaga xavfsiz ulanish amalga oshirilmadi. Mavjud qurilmalaringiz hali ham xavfsiz va ular haqida qaygʻurishingiz shart emas." + "Endi nima?" + "Agar bu tarmoq muammosi boʻlsa, QR kod bilan qayta kiring" + "Xuddi shu muammoga duch kelsangiz, boshqa wifi tarmogʻini sinang yoki wifi oʻrniga mobil internetdan foydalaning" + "Agar bunisi ishlamasa, oddiy usulda kiring" + "Ulanish xavfsiz emas" + "Boshqa qurilmadan hisobga kirish bekor qilindi." + "Tizimga kirish soʻrovi bekor qilindi" + "Boshqa qurilmadan hisobga kirish bekor qilindi." + "Tizimga kirish rad etildi" + "Kirish muddati tugagan. Iltimos, qayta urinib koʻring." + "Kirish oʻz vaqtida tugallanmagan" + "Boshqa qurilmangiz %s hisobiga QR kod orqali kirishni qoʻllab-quvvatlamaydi. + +Oddiy usulda kiring yoki boshqa qurilma bilan QR kodni skanerlang." + "QR kod qoʻllab-quvvatlanmaydi" + "Hisob provayderingiz %1$s bilan ishlamaydi." + "%1$s qoʻllab-quvvatlanmaydi" + "Narigi qurilmada koʻrsatilgan QR koddan foydalaning." + "Qayta urinib ko\'ring" + "QR kod notoʻgʻri" + "Davom etish uchun %1$s qurilmangiz kamerasidan foydalanishiga ruxsat berishingiz kerak." + "QR kodni skanerlash uchun kameraga ruxsat bering" + "Kutilmagan xatolik yuz berdi. Qayta urining." + diff --git a/features/linknewdevice/impl/src/main/res/values-zh-rTW/translations.xml b/features/linknewdevice/impl/src/main/res/values-zh-rTW/translations.xml new file mode 100644 index 00000000000..9b3cd5ead5e --- /dev/null +++ b/features/linknewdevice/impl/src/main/res/values-zh-rTW/translations.xml @@ -0,0 +1,38 @@ + + + "掃描 QR code" + "使用此裝置掃描 QR code" + "準備掃描" + "您的帳號提供者不支援 %1$s。" + "不支援 %1$s" + "不支援 QR code" + "已在其他裝置上取消登入。" + "已取消登入請求" + "登入已過期。請再試一次。" + "未及時完成登入" + "選取 %1$s" + "無法與新裝置建立安全連線。您現有的裝置仍然安全,您不必擔心它們。" + "現在怎麼辦?" + "嘗試再次使用 QR code 登入以確認不是網路問題" + "如果遇到相同的問題,請嘗試使用其他 wifi 網路或您的行動數據" + "若無法運作,請手動登入" + "連線不安全" + "已在其他裝置上取消登入。" + "已取消登入請求" + "其他裝置拒絕登入。" + "已拒絕登入" + "登入已過期。請再試一次。" + "未及時完成登入" + "您的其他裝置不支援使用 QR cpde 登入 %s。 + +嘗試手動登入,或是使用其他裝置掃描 QR code。" + "不支援 QR code" + "您的帳號提供者不支援 %1$s。" + "不支援 %1$s" + "使用其他裝置上顯示的 QR code。" + "再試一次" + "錯誤的 QR code" + "您必須授予 %1$s 權限以使用裝置相機才能繼續。" + "允許相機權限以掃描 QR code" + "發生意外錯誤。請再試一次。" + diff --git a/features/linknewdevice/impl/src/main/res/values-zh/translations.xml b/features/linknewdevice/impl/src/main/res/values-zh/translations.xml new file mode 100644 index 00000000000..99359cc6955 --- /dev/null +++ b/features/linknewdevice/impl/src/main/res/values-zh/translations.xml @@ -0,0 +1,38 @@ + + + "扫描二维码" + "使用此设备扫描二维码" + "准备进行扫描" + "账户提供方不支持 %1$s." + "不支持 %1$s." + "不支持二维码" + "登录被另一台设备取消" + "登录请求已取消" + "登录已过期. 请重试." + "登录未及时完成" + "选择 %1$s" + "无法与新设备建立安全连接。您现有的设备仍然安全,无需担心。" + "现在怎么办?" + "如果这是网络问题,请尝试使用二维码再次登录" + "如果遇到同样的问题,请尝试使用不同的 WiFi 网络或使用移动数据代替 WiFi" + "如果不起作用,请手动登录" + "连接不安全" + "登录被另一台设备取消" + "登录请求已取消" + "其它设备未接受请求" + "登录被拒绝" + "登录已过期. 请重试." + "登录未及时完成" + "另一个设备不支持使用二维码登录 %s. + +尝试手动或使用另一个设备扫描二维码." + "不支持二维码" + "账户提供方不支持 %1$s." + "不支持 %1$s." + "使用其他设备上显示的二维码。" + "再试一次" + "二维码错误" + "您需要授予 %1$s 使用设备摄像头的权限才能继续。" + "允许摄像头权限以扫描 QR 码" + "发生了意外错误。请再试一次。" + diff --git a/features/linknewdevice/impl/src/main/res/values/localazy.xml b/features/linknewdevice/impl/src/main/res/values/localazy.xml new file mode 100644 index 00000000000..321b1687518 --- /dev/null +++ b/features/linknewdevice/impl/src/main/res/values/localazy.xml @@ -0,0 +1,57 @@ + + + "Scan the QR code" + "Open %1$s on a laptop or desktop computer" + "Scan the QR code with this device" + "Ready to scan" + "Open %1$s on a desktop computer to get the QR code" + "The numbers don’t match" + "Enter 2-digit code" + "This will verify that the connection to your other device is secure." + "Enter the number shown on your other device" + "Your account provider does not support %1$s." + "%1$s not supported" + "Your account provider doesn’t support signing into a new device with a QR code." + "QR code not supported" + "The sign in was cancelled on the other device." + "Sign in request cancelled" + "Sign in expired. Please try again." + "The sign in was not completed in time" + "Open %1$s on the other device" + "Select %1$s" + "“Sign in with QR code”" + "Scan the QR code shown here with the other device" + "Open %1$s on the other device" + "Desktop computer" + "Loading QR code…" + "Mobile device" + "What type of device do you want to link?" + "Please try again and make sure that you’ve entered the 2-digit code correctly. If the numbers still don’t match then contact your account provider." + "The numbers don’t match" + "A secure connection could not be made to the new device. Your existing devices are still safe and you don\'t need to worry about them." + "What now?" + "Try signing in again with a QR code in case this was a network problem" + "If you encounter the same problem, try a different wifi network or use your mobile data instead of wifi" + "If that doesn’t work, sign in manually" + "Connection not secure" + "The sign in was cancelled on the other device." + "Sign in request cancelled" + "The sign in was declined on the other device." + "Sign in declined" + "You don’t need to do anything else." + "Your other device is already signed in" + "Sign in expired. Please try again." + "The sign in was not completed in time" + "Your other device does not support signing in to %s with a QR code. + +Try signing in manually, or scan the QR code with another device." + "QR code not supported" + "Your account provider does not support %1$s." + "%1$s not supported" + "Use the QR code shown on the other device." + "Try again" + "Wrong QR code" + "You need to give permission for %1$s to use your device’s camera in order to continue." + "Allow camera access to scan the QR code" + "An unexpected error occurred. Please try again." + diff --git a/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/DefaultLinkNewDeviceEntryPointTest.kt b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/DefaultLinkNewDeviceEntryPointTest.kt new file mode 100644 index 00000000000..2957a89495a --- /dev/null +++ b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/DefaultLinkNewDeviceEntryPointTest.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2025 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.linknewdevice.impl + +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.linknewdevice.api.LinkNewDeviceEntryPoint +import io.element.android.libraries.matrix.test.FakeMatrixClient +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 DefaultLinkNewDeviceEntryPointTest { + @get:Rule + val instantTaskExecutorRule = InstantTaskExecutorRule() + + @get:Rule + val mainDispatcherRule = MainDispatcherRule() + + @Test + fun `test node creation`() = runTest { + val entryPoint = DefaultLinkNewDeviceEntryPoint() + val client = FakeMatrixClient() + val parentNode = TestParentNode.create { buildContext, plugins -> + LinkNewDeviceFlowNode( + buildContext = buildContext, + plugins = plugins, + sessionCoroutineScope = backgroundScope, + linkNewMobileHandler = LinkNewMobileHandler(client), + linkNewDesktopHandler = LinkNewDesktopHandler(client), + ) + } + val callback: LinkNewDeviceEntryPoint.Callback = object : LinkNewDeviceEntryPoint.Callback { + override fun onDone() = lambdaError() + } + val result = entryPoint.createNode(parentNode, BuildContext.root(null), callback) + assertThat(result).isInstanceOf(LinkNewDeviceFlowNode::class.java) + } +} diff --git a/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticePresenterTest.kt b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticePresenterTest.kt new file mode 100644 index 00000000000..b6b9769b644 --- /dev/null +++ b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticePresenterTest.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2025 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.linknewdevice.impl.screens.desktop + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.permissions.test.FakePermissionsPresenter +import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory +import io.element.android.tests.testutils.test +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class DesktopNoticePresenterTest { + @Test + fun `present - initial state`() = runTest { + val presenter = createPresenter() + presenter.test { + awaitItem().run { + assertThat(cameraPermissionState.permission).isEqualTo("android.permission.POST_NOTIFICATIONS") + assertThat(canContinue).isFalse() + } + } + } + + @Test + fun `present - Continue with camera permissions can continue`() = runTest { + val permissionsPresenter = FakePermissionsPresenter().apply { setPermissionGranted() } + val permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter) + val presenter = createPresenter(permissionsPresenterFactory = permissionsPresenterFactory) + presenter.test { + awaitItem().eventSink(DesktopNoticeEvent.Continue) + assertThat(awaitItem().canContinue).isTrue() + } + } + + @Test + fun `present - Continue with unknown camera permissions opens permission dialog`() = runTest { + val permissionsPresenter = FakePermissionsPresenter() + val permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter) + val presenter = createPresenter(permissionsPresenterFactory = permissionsPresenterFactory) + presenter.test { + awaitItem().eventSink(DesktopNoticeEvent.Continue) + assertThat(awaitItem().cameraPermissionState.showDialog).isTrue() + } + } +} + +private fun createPresenter( + permissionsPresenterFactory: FakePermissionsPresenterFactory = FakePermissionsPresenterFactory(), +) = DesktopNoticePresenter( + permissionsPresenterFactory = permissionsPresenterFactory, +) diff --git a/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeViewTest.kt b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeViewTest.kt new file mode 100644 index 00000000000..ac0a129f491 --- /dev/null +++ b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/desktop/DesktopNoticeViewTest.kt @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2025 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.linknewdevice.impl.screens.desktop + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.features.linknewdevice.impl.R +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.pressBack +import io.element.android.tests.testutils.pressBackKey +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class DesktopNoticeViewTest { + @get:Rule + val rule = createAndroidComposeRule() + + @Test + fun `on back pressed - calls the expected callback`() { + ensureCalledOnce { callback -> + rule.setView( + state = aDesktopNoticeState(), + onBackClicked = callback, + ) + rule.pressBackKey() + } + } + + @Test + fun `on back button clicked - calls the expected callback`() { + ensureCalledOnce { callback -> + rule.setView( + state = aDesktopNoticeState(), + onBackClicked = callback, + ) + rule.pressBack() + } + } + + @Test + fun `when can continue - calls the expected callback`() { + ensureCalledOnce { callback -> + rule.setView( + state = aDesktopNoticeState(canContinue = true), + onReadyToScanClick = callback, + ) + } + } + + @Test + fun `on submit button clicked - emits the Continue event`() { + val eventRecorder = EventsRecorder() + rule.setView( + state = aDesktopNoticeState(eventSink = eventRecorder), + ) + rule.clickOn(R.string.screen_link_new_device_desktop_submit) + eventRecorder.assertSingle(DesktopNoticeEvent.Continue) + } + + private fun AndroidComposeTestRule.setView( + state: DesktopNoticeState, + onBackClicked: () -> Unit = EnsureNeverCalled(), + onReadyToScanClick: () -> Unit = EnsureNeverCalled(), + ) { + setContent { + DesktopNoticeView( + state = state, + onBackClick = onBackClicked, + onReadyToScanClick = onReadyToScanClick, + ) + } + } +} diff --git a/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/error/ErrorViewTest.kt b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/error/ErrorViewTest.kt new file mode 100644 index 00000000000..8f44182dd4e --- /dev/null +++ b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/error/ErrorViewTest.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2025 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.linknewdevice.impl.screens.error + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.tests.testutils.clickOn +import io.element.android.tests.testutils.ensureCalledOnce +import io.element.android.tests.testutils.pressBackKey +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ErrorViewTest { + @get:Rule + val rule = createAndroidComposeRule() + + @Test + fun `on back pressed - calls the onRetry callback`() { + ensureCalledOnce { callback -> + rule.setErrorView( + onRetry = callback + ) + rule.pressBackKey() + } + } + + @Test + fun `on start over button clicked - calls the expected callback`() { + ensureCalledOnce { callback -> + rule.setErrorView( + onRetry = callback + ) + rule.clickOn(CommonStrings.action_start_over) + } + } + + private fun AndroidComposeTestRule.setErrorView( + onRetry: () -> Unit, + errorScreenType: ErrorScreenType = ErrorScreenType.UnknownError, + ) { + setContent { + ErrorView( + errorScreenType = errorScreenType, + onRetry = onRetry, + ) + } + } +} diff --git a/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberPresenterTest.kt b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberPresenterTest.kt new file mode 100644 index 00000000000..fe2e11e95cf --- /dev/null +++ b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberPresenterTest.kt @@ -0,0 +1,191 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package io.element.android.features.linknewdevice.impl.screens.number + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.linknewdevice.impl.LinkNewMobileHandler +import io.element.android.libraries.matrix.api.linknewdevice.LinkMobileStep +import io.element.android.libraries.matrix.test.AN_EXCEPTION +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.matrix.test.linknewdevice.FakeCheckCodeSender +import io.element.android.libraries.matrix.test.linknewdevice.FakeLinkMobileHandler +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.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class EnterNumberPresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `present - initial state`() = runTest { + createPresenter().test { + val initialState = awaitItem() + assertThat(initialState.number).isEmpty() + assertThat(initialState.sendingCode.isUninitialized()).isTrue() + } + } + + @Test + fun `present - enter numbers`() = runTest { + createPresenter().test { + val initialState = awaitItem() + assertThat(initialState.number).isEmpty() + initialState.eventSink(EnterNumberEvent.UpdateNumber("12")) + val state2 = awaitItem() + assertThat(state2.number).isEqualTo("12") + // Non numeric characters are ignored + state2.eventSink(EnterNumberEvent.UpdateNumber("1a")) + val state3 = awaitItem() + assertThat(state3.number).isEqualTo("1") + } + } + + @Test + fun `present - continue in wrong state generates an error`() = runTest { + createPresenter().test { + val initialState = awaitItem() + initialState.eventSink(EnterNumberEvent.Continue) + val state2 = awaitItem() + assertThat(state2.sendingCode.isFailure()).isTrue() + } + } + + @Test + fun `present - continue when number is not valid invokes the navigator`() = runTest { + val linkMobileHandler = FakeLinkMobileHandler( + startResult = {}, + ) + val validateResult = lambdaRecorder { false } + val checkCodeSender = FakeCheckCodeSender( + validateResult = validateResult, + ) + val matrixClient = FakeMatrixClient( + sessionCoroutineScope = backgroundScope, + createLinkMobileHandlerResult = { Result.success(linkMobileHandler) } + ) + val linkNewMobileHandler = LinkNewMobileHandler(matrixClient) + linkNewMobileHandler.createAndStartNewHandler() + val navigateToWrongNumberErrorLambda = lambdaRecorder { } + val navigator = FakeEnterNumberNavigator( + navigateToWrongNumberErrorLambda = navigateToWrongNumberErrorLambda, + ) + createPresenter( + navigator = navigator, + linkNewMobileHandler = linkNewMobileHandler, + ).test { + val initialState = awaitItem() + linkMobileHandler.emitStep( + LinkMobileStep.QrScanned(checkCodeSender) + ) + runCurrent() + initialState.eventSink(EnterNumberEvent.UpdateNumber("88")) + skipItems(1) + initialState.eventSink(EnterNumberEvent.Continue) + skipItems(1) + val finalState = awaitItem() + assertThat(finalState.sendingCode.isLoading()).isTrue() + advanceUntilIdle() + validateResult.assertions().isCalledOnce().with(value(88.toUByte())) + navigateToWrongNumberErrorLambda.assertions().isCalledOnce() + } + } + + @Test + fun `present - continue when the number is valid but sending fails`() = runTest { + val linkMobileHandler = FakeLinkMobileHandler( + startResult = {}, + ) + val validateResult = lambdaRecorder { true } + val sendResult = lambdaRecorder> { Result.failure(AN_EXCEPTION) } + val checkCodeSender = FakeCheckCodeSender( + validateResult = validateResult, + sendResult = sendResult, + ) + val matrixClient = FakeMatrixClient( + sessionCoroutineScope = backgroundScope, + createLinkMobileHandlerResult = { Result.success(linkMobileHandler) } + ) + val linkNewMobileHandler = LinkNewMobileHandler(matrixClient) + linkNewMobileHandler.createAndStartNewHandler() + createPresenter( + linkNewMobileHandler = linkNewMobileHandler, + ).test { + val initialState = awaitItem() + linkMobileHandler.emitStep( + LinkMobileStep.QrScanned(checkCodeSender) + ) + runCurrent() + initialState.eventSink(EnterNumberEvent.UpdateNumber("88")) + skipItems(1) + initialState.eventSink(EnterNumberEvent.Continue) + skipItems(1) + val loadingState = awaitItem() + assertThat(loadingState.sendingCode.isLoading()).isTrue() + val finalState = awaitItem() + assertThat(finalState.sendingCode.isFailure()).isTrue() + validateResult.assertions().isCalledOnce().with(value(88.toUByte())) + sendResult.assertions().isCalledOnce().with(value(88.toUByte())) + } + } + + @Test + fun `present - continue when the number is valid and sending is successful`() = runTest { + val linkMobileHandler = FakeLinkMobileHandler( + startResult = {}, + ) + val validateResult = lambdaRecorder { true } + val sendResult = lambdaRecorder> { Result.success(Unit) } + val checkCodeSender = FakeCheckCodeSender( + validateResult = validateResult, + sendResult = sendResult, + ) + val matrixClient = FakeMatrixClient( + sessionCoroutineScope = backgroundScope, + createLinkMobileHandlerResult = { Result.success(linkMobileHandler) } + ) + val linkNewMobileHandler = LinkNewMobileHandler(matrixClient) + linkNewMobileHandler.createAndStartNewHandler() + createPresenter( + linkNewMobileHandler = linkNewMobileHandler, + ).test { + val initialState = awaitItem() + linkMobileHandler.emitStep( + LinkMobileStep.QrScanned(checkCodeSender) + ) + runCurrent() + initialState.eventSink(EnterNumberEvent.UpdateNumber("88")) + skipItems(1) + initialState.eventSink(EnterNumberEvent.Continue) + skipItems(1) + val loadingState = awaitItem() + assertThat(loadingState.sendingCode.isLoading()).isTrue() + expectNoEvents() + advanceUntilIdle() + validateResult.assertions().isCalledOnce().with(value(88.toUByte())) + sendResult.assertions().isCalledOnce().with(value(88.toUByte())) + } + } + + private fun createPresenter( + navigator: EnterNumberNavigator = FakeEnterNumberNavigator(), + linkNewMobileHandler: LinkNewMobileHandler = LinkNewMobileHandler(FakeMatrixClient()), + ) = EnterNumberPresenter( + navigator = navigator, + linkNewMobileHandler = linkNewMobileHandler, + ) +} diff --git a/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberStateTest.kt b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberStateTest.kt new file mode 100644 index 00000000000..e7466a1b215 --- /dev/null +++ b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberStateTest.kt @@ -0,0 +1,94 @@ +/* + * Copyright (c) 2025 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.linknewdevice.impl.screens.number + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.linknewdevice.impl.screens.number.model.Digit +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.test.AN_EXCEPTION +import org.junit.Test + +class EnterNumberStateTest { + @Test + fun `isContinueButtonEnabled is false if number is not complete`() { + val sut = aEnterNumberState( + number = "", + sendingCode = AsyncAction.Uninitialized, + ) + assertThat(sut.copy(number = "1").isContinueButtonEnabled).isFalse() + } + + @Test + fun `isContinueButtonEnabled is true if number is complete`() { + val sut = aEnterNumberState( + number = "12", + sendingCode = AsyncAction.Uninitialized, + ) + assertThat(sut.isContinueButtonEnabled).isTrue() + } + + @Test + fun `isContinueButtonEnabled is false if number is complete and sending is loading`() { + val sut = aEnterNumberState( + number = "12", + sendingCode = AsyncAction.Loading, + ) + assertThat(sut.isContinueButtonEnabled).isFalse() + } + + @Test + fun `isContinueButtonEnabled is true if number is complete and sending is not loading`() { + listOf( + AsyncAction.Uninitialized, + AsyncAction.Failure(AN_EXCEPTION), + AsyncAction.Success(Unit), + ).forEach { action -> + val sut = aEnterNumberState( + number = "12", + sendingCode = action, + ) + assertThat(sut.isContinueButtonEnabled).isTrue() + } + } + + @Test + fun `numberEntry is computed from number - case empty`() { + val sut = aEnterNumberState( + number = "", + ) + assertThat(sut.numberEntry.size).isEqualTo(2) + assertThat(sut.numberEntry.digits).containsExactly( + Digit.Empty, + Digit.Empty, + ) + } + + @Test + fun `numberEntry is computed from number - case half filled`() { + val sut = aEnterNumberState( + number = "1", + ) + assertThat(sut.numberEntry.size).isEqualTo(2) + assertThat(sut.numberEntry.digits).containsExactly( + Digit.Filled('1'), + Digit.Empty, + ) + } + + @Test + fun `numberEntry is computed from number - case filled`() { + val sut = aEnterNumberState( + number = "12", + ) + assertThat(sut.numberEntry.size).isEqualTo(2) + assertThat(sut.numberEntry.digits).containsExactly( + Digit.Filled('1'), + Digit.Filled('2'), + ) + } +} diff --git a/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberViewTest.kt b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberViewTest.kt new file mode 100644 index 00000000000..20e1d898ddc --- /dev/null +++ b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/number/EnterNumberViewTest.kt @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2025 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.linknewdevice.impl.screens.number + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.assertIsNotEnabled +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.test.ext.junit.runners.AndroidJUnit4 +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.pressBack +import io.element.android.tests.testutils.pressBackKey +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class EnterNumberViewTest { + @get:Rule + val rule = createAndroidComposeRule() + + @Test + fun `on back pressed - calls the expected callback`() { + ensureCalledOnce { callback -> + rule.setView( + state = aEnterNumberState(), + onBackClicked = callback, + ) + rule.pressBackKey() + } + } + + @Test + fun `on back button clicked - calls the expected callback`() { + ensureCalledOnce { callback -> + rule.setView( + state = aEnterNumberState(), + onBackClicked = callback, + ) + rule.pressBack() + } + } + + @Test + fun `on continue button clicked - emits the Continue event`() { + val eventRecorder = EventsRecorder() + rule.setView( + state = aEnterNumberState( + number = "12", + eventSink = eventRecorder, + ), + ) + rule.clickOn(CommonStrings.action_continue) + eventRecorder.assertSingle(EnterNumberEvent.Continue) + } + + @Test + fun `when the number is not complete, continue button is disabled`() { + val eventRecorder = EventsRecorder(expectEvents = false) + rule.setView( + state = aEnterNumberState( + number = "1", + eventSink = eventRecorder, + ), + ) + val continueStr = rule.activity.getString(CommonStrings.action_continue) + rule.onNodeWithText(continueStr).assertIsNotEnabled() + } + + private fun AndroidComposeTestRule.setView( + state: EnterNumberState, + onBackClicked: () -> Unit = EnsureNeverCalled(), + ) { + setContent { + EnterNumberView( + state = state, + onBackClick = onBackClicked, + ) + } + } +} diff --git a/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/number/FakeEnterNumberNavigator.kt b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/number/FakeEnterNumberNavigator.kt new file mode 100644 index 00000000000..a96ab7fe303 --- /dev/null +++ b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/number/FakeEnterNumberNavigator.kt @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2025 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.linknewdevice.impl.screens.number + +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeEnterNumberNavigator( + private val navigateToWrongNumberErrorLambda: () -> Unit = { lambdaError() }, +) : EnterNumberNavigator { + override fun navigateToWrongNumberError() { + navigateToWrongNumberErrorLambda() + } +} diff --git a/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeViewTest.kt b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeViewTest.kt new file mode 100644 index 00000000000..c6c89ba8189 --- /dev/null +++ b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/qrcode/ShowQrCodeViewTest.kt @@ -0,0 +1,47 @@ +/* + * Copyright (c) 2025 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.linknewdevice.impl.screens.qrcode + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.tests.testutils.EnsureNeverCalled +import io.element.android.tests.testutils.ensureCalledOnce +import io.element.android.tests.testutils.pressBackKey +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ShowQrCodeViewTest { + @get:Rule + val rule = createAndroidComposeRule() + + @Test + fun `on back pressed - calls the expected callback`() { + ensureCalledOnce { callback -> + rule.setView( + onBackClick = callback + ) + rule.pressBackKey() + } + } + + private fun AndroidComposeTestRule.setView( + onBackClick: () -> Unit = EnsureNeverCalled(), + ) { + setContent { + ShowQrCodeView( + data = "DATA", + onBackClick = onBackClick, + ) + } + } +} diff --git a/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootPresenterTest.kt b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootPresenterTest.kt new file mode 100644 index 00000000000..88a68786f3c --- /dev/null +++ b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootPresenterTest.kt @@ -0,0 +1,97 @@ +/* + * Copyright (c) 2025 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.linknewdevice.impl.screens.root + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.linknewdevice.impl.LinkNewMobileHandler +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.test.AN_EXCEPTION +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.matrix.test.linknewdevice.FakeLinkMobileHandler +import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.test +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class LinkNewDeviceRootPresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `present - initial state`() = runTest { + val matrixClient = FakeMatrixClient( + canLinkNewDeviceResult = { Result.success(true) }, + ) + createPresenter( + matrixClient = matrixClient, + ).test { + val initialState = awaitItem() + assertThat(initialState.isSupported.isUninitialized()).isTrue() + assertThat(awaitItem().isSupported.dataOrNull()).isTrue() + } + } + + @Test + fun `present - new login device not supported`() = runTest { + val matrixClient = FakeMatrixClient( + canLinkNewDeviceResult = { Result.success(false) }, + ) + createPresenter( + matrixClient = matrixClient, + ).test { + val initialState = awaitItem() + assertThat(initialState.isSupported.isUninitialized()).isTrue() + assertThat(awaitItem().isSupported.dataOrNull()).isFalse() + } + } + + @Test + fun `present - error`() = runTest { + val matrixClient = FakeMatrixClient( + canLinkNewDeviceResult = { Result.failure(AN_EXCEPTION) }, + ) + createPresenter( + matrixClient = matrixClient, + ).test { + val initialState = awaitItem() + assertThat(initialState.isSupported.isUninitialized()).isTrue() + assertThat(awaitItem().isSupported.isFailure()).isTrue() + } + } + + @Test + fun `present - link new mobile device`() = runTest { + val linkMobileHandler = FakeLinkMobileHandler( + startResult = {}, + ) + val matrixClient = FakeMatrixClient( + canLinkNewDeviceResult = { Result.success(true) }, + sessionCoroutineScope = backgroundScope, + createLinkMobileHandlerResult = { Result.success(linkMobileHandler) } + ) + createPresenter( + matrixClient = matrixClient, + ).test { + skipItems(1) + val initialState = awaitItem() + assertThat(initialState.isSupported.dataOrNull()).isTrue() + initialState.eventSink(LinkNewDeviceRootEvent.LinkMobileDevice) + val loadingState = awaitItem() + assertThat(loadingState.qrCodeData.isLoading()).isTrue() + } + } + + private fun createPresenter( + matrixClient: MatrixClient = FakeMatrixClient(), + linkNewMobileHandler: LinkNewMobileHandler = LinkNewMobileHandler(matrixClient), + ) = LinkNewDeviceRootPresenter( + matrixClient = matrixClient, + linkNewMobileHandler = linkNewMobileHandler, + ) +} diff --git a/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootViewTest.kt b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootViewTest.kt new file mode 100644 index 00000000000..e352debfb0f --- /dev/null +++ b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/root/LinkNewDeviceRootViewTest.kt @@ -0,0 +1,102 @@ +/* + * Copyright (c) 2025 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.linknewdevice.impl.screens.root + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.features.linknewdevice.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.pressBackKey +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class LinkNewDeviceRootViewTest { + @get:Rule + val rule = createAndroidComposeRule() + + @Test + fun `on back pressed - calls the onRetry callback`() { + val eventRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnce { callback -> + rule.setLinkNewDeviceRootView( + state = aLinkNewDeviceRootState( + eventSink = eventRecorder, + ), + onBackClick = callback + ) + rule.pressBackKey() + } + } + + @Test + fun `link desktop button clicked - calls the expected callback`() { + val eventRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnce { callback -> + rule.setLinkNewDeviceRootView( + state = aLinkNewDeviceRootState( + isSupported = AsyncData.Success(true), + eventSink = eventRecorder, + ), + onLinkDesktopDeviceClick = callback, + ) + rule.clickOn(R.string.screen_link_new_device_root_desktop_computer) + } + } + + @Test + fun `link mobile button clicked - emits the expected event`() { + val eventRecorder = EventsRecorder() + rule.setLinkNewDeviceRootView( + state = aLinkNewDeviceRootState( + isSupported = AsyncData.Success(true), + eventSink = eventRecorder, + ) + ) + rule.clickOn(R.string.screen_link_new_device_root_mobile_device) + eventRecorder.assertSingle(LinkNewDeviceRootEvent.LinkMobileDevice) + } + + @Test + fun `not supported - dismiss click - invokes the expected callback`() { + val eventRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnce { callback -> + rule.setLinkNewDeviceRootView( + state = aLinkNewDeviceRootState( + isSupported = AsyncData.Success(false), + eventSink = eventRecorder, + ), + onBackClick = callback, + ) + rule.clickOn(CommonStrings.action_dismiss) + } + } + + private fun AndroidComposeTestRule.setLinkNewDeviceRootView( + state: LinkNewDeviceRootState = aLinkNewDeviceRootState(), + onBackClick: () -> Unit = EnsureNeverCalled(), + onLinkDesktopDeviceClick: () -> Unit = EnsureNeverCalled(), + ) { + setContent { + LinkNewDeviceRootView( + state = state, + onBackClick = onBackClick, + onLinkDesktopDeviceClick = onLinkDesktopDeviceClick, + ) + } + } +} diff --git a/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodePresenterTest.kt b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodePresenterTest.kt new file mode 100644 index 00000000000..50c3ce767bf --- /dev/null +++ b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodePresenterTest.kt @@ -0,0 +1,110 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package io.element.android.features.linknewdevice.impl.screens.scan + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.linknewdevice.impl.LinkNewDesktopHandler +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeDecodeException +import io.element.android.libraries.matrix.api.linknewdevice.LinkDesktopStep +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.matrix.test.QR_CODE_DATA_RECIPROCATE +import io.element.android.libraries.matrix.test.linknewdevice.FakeLinkDesktopHandler +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.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class ScanQrCodePresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `present - initial state`() = runTest { + val matrixClient = FakeMatrixClient( + createLinkDesktopHandlerResult = { Result.success(FakeLinkDesktopHandler()) } + ) + createPresenter( + matrixClient = matrixClient, + ).test { + val initialState = awaitItem() + assertThat(initialState.scanAction.isLoading()).isTrue() + } + } + + @Test + fun `present - handle scanned event - success`() = runTest { + val handleScannedQrCodeResult = lambdaRecorder { } + val matrixClient = FakeMatrixClient( + sessionCoroutineScope = backgroundScope, + createLinkDesktopHandlerResult = { + Result.success( + FakeLinkDesktopHandler( + handleScannedQrCodeResult = handleScannedQrCodeResult, + ) + ) + } + ) + createPresenter( + matrixClient = matrixClient, + ).test { + val initialState = awaitItem() + assertThat(initialState.scanAction.isLoading()).isTrue() + initialState.eventSink(ScanQrCodeEvent.QrCodeScanned(QR_CODE_DATA_RECIPROCATE)) + val scannedState = awaitItem() + assertThat(scannedState.scanAction.isSuccess()).isTrue() + runCurrent() + handleScannedQrCodeResult.assertions().isCalledOnce().with(value(QR_CODE_DATA_RECIPROCATE)) + } + } + + @Test + fun `present - handle scanned event - failure`() = runTest { + val handleScannedQrCodeResult = lambdaRecorder { } + val handler = FakeLinkDesktopHandler( + handleScannedQrCodeResult = handleScannedQrCodeResult, + ) + val matrixClient = FakeMatrixClient( + sessionCoroutineScope = backgroundScope, + createLinkDesktopHandlerResult = { + Result.success(handler) + } + ) + createPresenter( + matrixClient = matrixClient, + ).test { + val initialState = awaitItem() + assertThat(initialState.scanAction.isLoading()).isTrue() + initialState.eventSink(ScanQrCodeEvent.QrCodeScanned(QR_CODE_DATA_RECIPROCATE)) + val scannedState = awaitItem() + assertThat(scannedState.scanAction.isSuccess()).isTrue() + handler.emitStep(LinkDesktopStep.InvalidQrCode(QrCodeDecodeException.Crypto("Invalid QR Code"))) + skipItems(1) + val errorState = awaitItem() + assertThat(errorState.scanAction.isFailure()).isTrue() + handleScannedQrCodeResult.assertions().isCalledOnce().with(value(QR_CODE_DATA_RECIPROCATE)) + // Reset by trying again + errorState.eventSink(ScanQrCodeEvent.TryAgain) + val resetState = awaitItem() + assertThat(resetState.scanAction.isLoading()).isTrue() + } + } +} + +private fun createPresenter( + matrixClient: MatrixClient, +) = ScanQrCodePresenter( + linkNewDesktopHandler = LinkNewDesktopHandler(matrixClient), +) diff --git a/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodeViewTest.kt b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodeViewTest.kt new file mode 100644 index 00000000000..fcc3afeb7d2 --- /dev/null +++ b/features/linknewdevice/impl/src/test/kotlin/io/element/android/features/linknewdevice/impl/screens/scan/ScanQrCodeViewTest.kt @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2025 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.linknewdevice.impl.screens.scan + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.test.AN_EXCEPTION +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.pressBackKey +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ScanQrCodeViewTest { + @get:Rule + val rule = createAndroidComposeRule() + + @Test + fun `on back pressed - calls the expected callback`() { + val eventRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnce { callback -> + rule.setView( + state = aScanQrCodeState( + eventSink = eventRecorder, + ), + onBackClick = callback + ) + rule.pressBackKey() + } + } + + @Test + fun `try again button clicked - emits the expected event`() { + val eventRecorder = EventsRecorder() + rule.setView( + state = aScanQrCodeState( + scanAction = AsyncAction.Failure(AN_EXCEPTION), + eventSink = eventRecorder, + ) + ) + rule.clickOn(CommonStrings.action_try_again) + eventRecorder.assertSingle(ScanQrCodeEvent.TryAgain) + } + + private fun AndroidComposeTestRule.setView( + state: ScanQrCodeState = aScanQrCodeState(), + onBackClick: () -> Unit = EnsureNeverCalled(), + ) { + setContent { + ScanQrCodeView( + state = state, + onBackClick = onBackClick, + ) + } + } +} diff --git a/features/linknewdevice/test/build.gradle.kts b/features/linknewdevice/test/build.gradle.kts new file mode 100644 index 00000000000..388612f9202 --- /dev/null +++ b/features/linknewdevice/test/build.gradle.kts @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 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.linknewdevice.test" +} + +dependencies { + implementation(projects.features.linknewdevice.api) + implementation(projects.tests.testutils) +} diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/pin/DefaultPinCodeManager.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/pin/DefaultPinCodeManager.kt index 1ebf00dd106..091432044a1 100644 --- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/pin/DefaultPinCodeManager.kt +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/pin/DefaultPinCodeManager.kt @@ -71,7 +71,7 @@ class DefaultPinCodeManager( lockScreenStore.onWrongPin() } } - } catch (failure: Throwable) { + } catch (_: Throwable) { false } } diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/keypad/PinKeypad.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/keypad/PinKeypad.kt index 2131853e3ee..093ad2f2b45 100644 --- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/keypad/PinKeypad.kt +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/keypad/PinKeypad.kt @@ -21,8 +21,6 @@ import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.automirrored.filled.Backspace import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -40,6 +38,7 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.times import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.text.toSp @@ -206,7 +205,8 @@ private fun PinKeypadBackButton( onClick = onClick, ) { Icon( - imageVector = Icons.AutoMirrored.Filled.Backspace, + modifier = Modifier.size(28.dp), + imageVector = CompoundIcons.BackspaceSolid(), contentDescription = stringResource(CommonStrings.a11y_delete), ) } diff --git a/features/lockscreen/impl/src/main/res/values-hr/translations.xml b/features/lockscreen/impl/src/main/res/values-hr/translations.xml new file mode 100644 index 00000000000..1a81bcc6cb4 --- /dev/null +++ b/features/lockscreen/impl/src/main/res/values-hr/translations.xml @@ -0,0 +1,40 @@ + + + "biometrijska provjera autentičnosti" + "biometrijsko otključavanje" + "Otključavanje biometrijom" + "Potvrdi biometriju" + "Zaboravili ste PIN?" + "Promijeni PIN kod" + "Omogući biometrijsko otključavanje" + "Ukloni PIN" + "Jeste li sigurni da želite ukloniti PIN?" + "Želite li ukloniti PIN?" + "Dopusti %1$s" + "Radije bih upotrijebio/la PIN" + "Uštedite si malo vremena i iskoristite %1$s kako biste svaki put otključali aplikaciju" + "Odaberite PIN" + "Potvrdite PIN" + "Zaključajte %1$s kako biste dodatno osigurali svoje razgovore. + +Odaberite nešto nezaboravno. Ako zaboravite ovaj PIN, bit ćete odjavljeni iz aplikacije." + "Ovo ne možete iz sigurnosnih razloga odabrati kao svoj PIN kod" + "Odaberite drugi PIN" + "Unesite dvaput isti PIN" + "PIN-ovi se ne podudaraju" + "Morat ćete se ponovno prijaviti i izraditi novi PIN da biste mogli nastaviti" + "Odjavit ćete se" + + "Imate %1$d pokušaj otključavanja" + "Imate %1$d pokušaja otključavanja" + "Imate %1$d pokušaja otključavanja" + + + "Pogrešan PIN. Imate još %1$d pokušaj" + "Pogrešan PIN. Imate još %1$d pokušaja" + "Pogrešan PIN. Imate još %1$d pokušaja" + + "Upotrijebi biometriju" + "Upotrijebi PIN" + "Odjavljivanje…" + diff --git a/features/login/impl/src/main/AndroidManifest.xml b/features/login/impl/src/main/AndroidManifest.xml index 453cf051320..f2d84131a73 100644 --- a/features/login/impl/src/main/AndroidManifest.xml +++ b/features/login/impl/src/main/AndroidManifest.xml @@ -15,4 +15,6 @@ + + diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/di/LoginModule.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/di/LoginModule.kt index 4523e6f45e8..12b9106b711 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/di/LoginModule.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/di/LoginModule.kt @@ -14,6 +14,8 @@ import dev.zacsweers.metro.Binds import dev.zacsweers.metro.ContributesTo import io.element.android.features.login.impl.changeserver.ChangeServerPresenter import io.element.android.features.login.impl.changeserver.ChangeServerState +import io.element.android.features.login.impl.screens.onboarding.classic.LoginWithClassicPresenter +import io.element.android.features.login.impl.screens.onboarding.classic.LoginWithClassicState import io.element.android.libraries.architecture.Presenter @ContributesTo(AppScope::class) @@ -21,4 +23,7 @@ import io.element.android.libraries.architecture.Presenter interface LoginModule { @Binds fun bindChangeServerPresenter(presenter: ChangeServerPresenter): Presenter + + @Binds + fun bindLoginWithClassicPresenter(presenter: LoginWithClassicPresenter): Presenter } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/CreateAccountPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/CreateAccountPresenter.kt index ffa1f044d3d..24c7042bc6d 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/CreateAccountPresenter.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/createaccount/CreateAccountPresenter.kt @@ -21,25 +21,19 @@ import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedInject import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.Presenter -import io.element.android.libraries.core.data.tryOrNull import io.element.android.libraries.core.extensions.flatMap import io.element.android.libraries.core.extensions.runCatchingExceptions import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlags -import io.element.android.libraries.matrix.api.MatrixClientProvider import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService import io.element.android.libraries.matrix.api.core.SessionId import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch -import kotlinx.coroutines.withTimeout -import kotlin.time.Duration.Companion.seconds @AssistedInject class CreateAccountPresenter( @Assisted private val url: String, private val authenticationService: MatrixAuthenticationService, - private val clientProvider: MatrixClientProvider, private val messageParser: MessageParser, private val featureFlagService: FeatureFlagService, ) : Presenter { @@ -87,12 +81,6 @@ class CreateAccountPresenter( }.flatMap { externalSession -> authenticationService.importCreatedSession(externalSession) }.onSuccess { sessionId -> - tryOrNull { - // Wait until the session is verified - val client = clientProvider.getOrRestore(sessionId).getOrThrow() - val sessionVerificationService = client.sessionVerificationService - withTimeout(10.seconds) { sessionVerificationService.sessionVerifiedStatus.first { it.isVerified() } } - } loggedInState.value = AsyncAction.Success(sessionId) }.onFailure { failure -> loggedInState.value = AsyncAction.Failure(failure) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenter.kt index 6c8c7915264..c3ec3c0b939 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenter.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenter.kt @@ -26,6 +26,7 @@ import io.element.android.features.enterprise.api.canConnectToAnyHomeserver import io.element.android.features.login.impl.accesscontrol.DefaultAccountProviderAccessControl import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource import io.element.android.features.login.impl.login.LoginHelper +import io.element.android.features.login.impl.screens.onboarding.classic.LoginWithClassicState import io.element.android.features.rageshake.api.RageshakeFeatureAvailability import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.core.meta.BuildMeta @@ -44,6 +45,7 @@ class OnBoardingPresenter( private val onBoardingLogoResIdProvider: OnBoardingLogoResIdProvider, private val sessionStore: SessionStore, private val accountProviderDataSource: AccountProviderDataSource, + private val loginWithClassicPresenter: Presenter, ) : Presenter { @AssistedFactory interface Factory { @@ -100,6 +102,8 @@ class OnBoardingPresenter( val loginMode by loginHelper.collectLoginMode() + val loginWithClassicState = loginWithClassicPresenter.present() + fun handleEvent(event: OnBoardingEvents) { when (event) { is OnBoardingEvents.OnSignIn -> localCoroutineScope.launch { @@ -133,6 +137,7 @@ class OnBoardingPresenter( loginMode = loginMode, version = buildMeta.versionName, onBoardingLogoResId = onBoardingLogoResId, + loginWithClassicState = loginWithClassicState, eventSink = ::handleEvent, ) } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingState.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingState.kt index db6c3573f92..703120b260e 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingState.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingState.kt @@ -10,6 +10,7 @@ package io.element.android.features.login.impl.screens.onboarding import androidx.annotation.DrawableRes import io.element.android.features.login.impl.login.LoginMode +import io.element.android.features.login.impl.screens.onboarding.classic.LoginWithClassicState import io.element.android.libraries.architecture.AsyncData data class OnBoardingState( @@ -24,6 +25,7 @@ data class OnBoardingState( @DrawableRes val onBoardingLogoResId: Int?, val loginMode: AsyncData, + val loginWithClassicState: LoginWithClassicState, val eventSink: (OnBoardingEvents) -> Unit, ) { val submitEnabled: Boolean diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingStateProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingStateProvider.kt index 7764c25c0bc..445b17f82e9 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingStateProvider.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingStateProvider.kt @@ -11,6 +11,8 @@ package io.element.android.features.login.impl.screens.onboarding import androidx.annotation.DrawableRes import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.features.login.impl.login.LoginMode +import io.element.android.features.login.impl.screens.onboarding.classic.LoginWithClassicState +import io.element.android.features.login.impl.screens.onboarding.classic.aLoginWithClassicState import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.designsystem.R @@ -44,6 +46,7 @@ fun anOnBoardingState( @DrawableRes customLogoResId: Int? = null, loginMode: AsyncData = AsyncData.Uninitialized, + loginWithClassicState: LoginWithClassicState = aLoginWithClassicState(), eventSink: (OnBoardingEvents) -> Unit = {}, ) = OnBoardingState( isAddingAccount = isAddingAccount, @@ -56,5 +59,6 @@ fun anOnBoardingState( version = version, loginMode = loginMode, onBoardingLogoResId = customLogoResId, + loginWithClassicState = loginWithClassicState, eventSink = eventSink, ) 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 2bd628460e3..6174a1a029b 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 @@ -31,15 +31,22 @@ import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LifecycleEventEffect import io.element.android.compound.theme.ElementTheme import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.features.login.impl.R import io.element.android.features.login.impl.login.LoginModeView +import io.element.android.features.login.impl.screens.onboarding.classic.ConfirmingLoginWithElementClassic +import io.element.android.features.login.impl.screens.onboarding.classic.LoginWithClassicEvent +import io.element.android.features.login.impl.screens.onboarding.classic.LoginWithClassicState import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule import io.element.android.libraries.designsystem.atomic.pages.FlowStepPage import io.element.android.libraries.designsystem.atomic.pages.OnBoardingPage import io.element.android.libraries.designsystem.components.BigIcon +import io.element.android.libraries.designsystem.components.async.AsyncActionView +import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog 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 @@ -109,6 +116,43 @@ fun OnBoardingView( buttons = buttons, ) } + + LoginWithElementClassicView( + state = state.loginWithClassicState, + ) +} + +@Composable +private fun LoginWithElementClassicView( + state: LoginWithClassicState, +) { + LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { + state.eventSink(LoginWithClassicEvent.RefreshData) + } + AsyncActionView( + async = state.loginWithClassicAction, + confirmationDialog = { confirming -> + when (confirming) { + is ConfirmingLoginWithElementClassic -> { + // TODO i18n + ConfirmationDialog( + title = "Sign in with Element Classic", + content = "You are signing in as ${confirming.userId} on Element Classic." + + " Your existing session on Element Classic will not be signed out. Do you want to continue?", + submitText = stringResource(CommonStrings.action_continue), + onSubmitClick = { state.eventSink(LoginWithClassicEvent.DoLoginWithClassic) }, + onDismiss = { state.eventSink(LoginWithClassicEvent.CloseDialog) }, + ) + } + } + }, + onErrorDismiss = { + state.eventSink(LoginWithClassicEvent.CloseDialog) + }, + onSuccess = { + // noop, the view will be closed + } + ) } @Composable @@ -239,6 +283,18 @@ private fun OnBoardingButtons( } else { CommonStrings.action_continue } + if (state.loginWithClassicState.canLoginWithClassic) { + Button( + text = "Sign in with Element Classic", + leadingIcon = IconSource.Vector(CompoundIcons.Mobile()), + onClick = { + state.loginWithClassicState.eventSink( + LoginWithClassicEvent.StartLoginWithClassic + ) + }, + modifier = Modifier.fillMaxWidth(), + ) + } if (state.canLoginWithQrCode) { Button( text = stringResource(id = R.string.screen_onboarding_sign_in_with_qr_code), diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/ConfirmingLoginWithElementClassic.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/ConfirmingLoginWithElementClassic.kt new file mode 100644 index 00000000000..5fae0afdd59 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/ConfirmingLoginWithElementClassic.kt @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.screens.onboarding.classic + +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.api.core.UserId + +class ConfirmingLoginWithElementClassic( + val userId: UserId, +) : AsyncAction.Confirming diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/ElementClassicConnection.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/ElementClassicConnection.kt new file mode 100644 index 00000000000..c983ea04bab --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/ElementClassicConnection.kt @@ -0,0 +1,249 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.screens.onboarding.classic + +import android.content.ComponentName +import android.content.Context +import android.content.Context.BIND_AUTO_CREATE +import android.content.Intent +import android.content.ServiceConnection +import android.os.Bundle +import android.os.Handler +import android.os.IBinder +import android.os.Message +import android.os.Messenger +import android.os.RemoteException +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.core.log.logger.LoggerTag +import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.core.meta.BuildType +import io.element.android.libraries.di.annotations.AppCoroutineScope +import io.element.android.libraries.di.annotations.ApplicationContext +import io.element.android.libraries.matrix.api.core.UserId +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import timber.log.Timber + +interface ElementClassicConnection { + fun start() + fun stop() + fun requestData() + val stateFlow: StateFlow +} + +sealed interface ElementClassicConnectionState { + object Idle : ElementClassicConnectionState + object ElementClassicNotFound : ElementClassicConnectionState + object ElementClassicReadyNoSession : ElementClassicConnectionState + data class ElementClassicReady(val userId: UserId) : ElementClassicConnectionState + data class Error(val error: String) : ElementClassicConnectionState +} + +private val loggerTag = LoggerTag("ECConnection") + +@ContributesBinding(AppScope::class) +class DefaultElementClassicConnection( + @ApplicationContext + private val context: Context, + @AppCoroutineScope + private val coroutineScope: CoroutineScope, + private val buildMeta: BuildMeta, +) : ElementClassicConnection { + // Messenger for communicating with the service. + private var messenger: Messenger? = null + + // Target we publish for external service to send messages to IncomingHandler. + private val incomingMessenger: Messenger = Messenger(IncomingHandler()) + + // Flag indicating whether we have called bind on the service. + private var bound: Boolean = false + + /** + * Class for interacting with the main interface of the service. + */ + private val serviceConnection = object : ServiceConnection { + override fun onServiceConnected(className: ComponentName, service: IBinder) { + Timber.tag(loggerTag.value).d("onServiceConnected") + // This is called when the connection with the service has been + // established, giving us the object we can use to + // interact with the service. We are communicating with the + // service using a Messenger, so here we get a client-side + // representation of that from the raw IBinder object. + messenger = Messenger(service) + bound = true + // Request the data as soon as possible + requestData() + } + + override fun onServiceDisconnected(className: ComponentName) { + Timber.tag(loggerTag.value).d("onServiceDisconnected") + // This is called when the connection with the service has been + // unexpectedly disconnected—that is, its process crashed. + messenger = null + bound = false + } + } + + override fun start() { + Timber.tag(loggerTag.value).w("start()") + coroutineScope.launch { + // Establish a connection with the service. We use an explicit + // class name because there is no reason to be able to let other + // applications replace our component. + try { + val intentService = Intent() + intentService.setComponent(getElementClassicComponent(buildMeta)) + if (context.bindService(intentService, serviceConnection, BIND_AUTO_CREATE)) { + Timber.tag(loggerTag.value).d("Binding returned true") + } else { + // This happen when the app is not installed + Timber.tag(loggerTag.value).d("Binding returned false") + mutableStateFlow.emit(ElementClassicConnectionState.ElementClassicNotFound) + } + } catch (e: SecurityException) { + Timber.tag(loggerTag.value).e(e, "Can't bind to Service") + mutableStateFlow.emit(ElementClassicConnectionState.Error(e.localizedMessage.orEmpty())) + } + } + } + + override fun stop() { + Timber.tag(loggerTag.value).w("stop(): Unbinding (bound=$bound)") + if (bound) { + // Detach our existing connection. + context.unbindService(serviceConnection) + bound = false + } + coroutineScope.launch { + mutableStateFlow.emit(ElementClassicConnectionState.Idle) + } + } + + override fun requestData() { + Timber.tag(loggerTag.value).w("requestData()") + coroutineScope.launch { + val finalMessenger = messenger + if (finalMessenger == null) { + Timber.tag(loggerTag.value).w("The messenger is null, can't request data") + mutableStateFlow.emit(ElementClassicConnectionState.Error("The messenger is null, can't request data")) + } else { + try { + // Get the data + val msg = Message.obtain(null, MSG_GET_DATA) + msg.replyTo = incomingMessenger + finalMessenger.send(msg) + } catch (e: RemoteException) { + // In this case the service has crashed before we could even + // do anything with it; we can count on soon being + // disconnected (and then reconnected if it can be restarted) + // so there is no need to do anything here. + Timber.tag(loggerTag.value).e(e, "RemoteException") + mutableStateFlow.emit(ElementClassicConnectionState.Error(e.localizedMessage.orEmpty())) + } + } + } + } + + private val mutableStateFlow = MutableStateFlow(ElementClassicConnectionState.Idle) + override val stateFlow = mutableStateFlow.asStateFlow() + + /** + * Handler of incoming messages from service. + */ + @Suppress("DEPRECATION") + inner class IncomingHandler : Handler() { + override fun handleMessage(msg: Message) { + Timber.tag(loggerTag.value).d("IncomingHandler handling message ${msg.what}") + when (msg.what) { + MSG_GET_DATA -> { + // The data must be extracted from the bundle before we launch the coroutine, else the bundle will be emptied + val state = msg.data.toElementClassicConnectionState() + emitElementClassicState(state) + } + else -> { + super.handleMessage(msg) + } + } + } + } + + private fun emitElementClassicState(state: ElementClassicConnectionState) = coroutineScope.launch { + when (state) { + is ElementClassicConnectionState.Error -> { + Timber.tag(loggerTag.value).w("Received error from Element Classic: %s", state.error) + mutableStateFlow.emit(state) + } + is ElementClassicConnectionState.ElementClassicReady -> { + Timber.tag(loggerTag.value).d("Received userId from Element Classic: %s", state.userId) + mutableStateFlow.emit(state) + } + ElementClassicConnectionState.ElementClassicReadyNoSession -> { + Timber.tag(loggerTag.value).d("Received no session from Element Classic") + mutableStateFlow.emit(state) + } + else -> { + // Should not happen + Timber.tag(loggerTag.value).w("Received unexpected state from Element Classic: %s", state) + mutableStateFlow.emit(ElementClassicConnectionState.Idle) + } + } + } + + private fun getElementClassicComponent(buildMeta: BuildMeta) = ComponentName( + buildString { + append(ELEMENT_CLASSIC_APP_ID) + append( + when (buildMeta.buildType) { + BuildType.DEBUG -> ELEMENT_CLASSIC_APP_ID_DEBUG_SUFFIX + BuildType.NIGHTLY -> ELEMENT_CLASSIC_APP_ID_NIGHTLY_SUFFIX + BuildType.RELEASE -> ELEMENT_CLASSIC_APP_ID_RELEASE_SUFFIX + } + ) + }, + ELEMENT_CLASSIC_SERVICE_FULL_CLASS_NAME, + ) + + private fun Bundle?.toElementClassicConnectionState(): ElementClassicConnectionState { + return if (this == null) { + ElementClassicConnectionState.Error("No data received from Element Classic") + } else { + val error = getString(KEY_ERROR_STR) + if (error != null) { + ElementClassicConnectionState.Error(error) + } else { + val userId = getString(KEY_USER_ID_STR)?.let(::UserId) + if (userId != null) { + ElementClassicConnectionState.ElementClassicReady(userId) + } else { + ElementClassicConnectionState.ElementClassicReadyNoSession + } + } + } + } + + // Everything in this companion object must match what is defined in Element Classic + private companion object { + // Command to the service to get the data. + const val MSG_GET_DATA = 1 + + const val ELEMENT_CLASSIC_APP_ID = "im.vector.app" + const val ELEMENT_CLASSIC_APP_ID_DEBUG_SUFFIX = ".debug" + const val ELEMENT_CLASSIC_APP_ID_NIGHTLY_SUFFIX = ".nightly" + const val ELEMENT_CLASSIC_APP_ID_RELEASE_SUFFIX = "" + + const val ELEMENT_CLASSIC_SERVICE_FULL_CLASS_NAME = "im.vector.app.features.importer.ImporterService" + + // Keys for the bundle returned from the service + const val KEY_ERROR_STR = "error" + const val KEY_USER_ID_STR = "userId" + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicEvent.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicEvent.kt new file mode 100644 index 00000000000..75a9496a027 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicEvent.kt @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.login.impl.screens.onboarding.classic + +sealed interface LoginWithClassicEvent { + data object RefreshData : LoginWithClassicEvent + data object StartLoginWithClassic : LoginWithClassicEvent + data object DoLoginWithClassic : LoginWithClassicEvent + data object CloseDialog : LoginWithClassicEvent +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicPresenter.kt new file mode 100644 index 00000000000..ef352794cbd --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicPresenter.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.login.impl.screens.onboarding.classic + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf +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.libraries.architecture.AsyncAction +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.sessionstorage.api.SessionStore +import io.element.android.libraries.sessionstorage.api.toUserListFlow +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch + +@Inject +class LoginWithClassicPresenter( + private val elementClassicConnection: ElementClassicConnection, + private val sessionStore: SessionStore, + private val featureFlagService: FeatureFlagService, +) : Presenter { + @Composable + override fun present(): LoginWithClassicState { + val coroutineScope = rememberCoroutineScope() + + val isSignInWithClassicEnabled by remember { + featureFlagService.isFeatureEnabledFlow(FeatureFlags.SignInWithClassic) + }.collectAsState(initial = false) + + if (isSignInWithClassicEnabled) { + DisposableEffect(Unit) { + elementClassicConnection.start() + onDispose { + elementClassicConnection.stop() + } + } + } + + val state by elementClassicConnection.stateFlow.collectAsState() + val loginWithClassicAction = remember { mutableStateOf>(AsyncAction.Uninitialized) } + + val existingSession by remember { + sessionStore.sessionsFlow().toUserListFlow() + }.collectAsState(emptyList()) + + val canLoginWithClassic by remember { + derivedStateOf { + when (val finalState = state) { + is ElementClassicConnectionState.ElementClassicReady -> { + // Ensure there is no existing session with the same Id. + finalState.userId.value !in existingSession && isSignInWithClassicEnabled + } + else -> false + } + } + } + + fun handleEvent(event: LoginWithClassicEvent) { + when (event) { + LoginWithClassicEvent.RefreshData -> { + elementClassicConnection.requestData() + } + LoginWithClassicEvent.StartLoginWithClassic -> { + val currentState = elementClassicConnection.stateFlow.value + if (currentState is ElementClassicConnectionState.ElementClassicReady) { + loginWithClassicAction.value = ConfirmingLoginWithElementClassic( + userId = currentState.userId, + ) + } else { + loginWithClassicAction.value = AsyncAction.Failure(IllegalStateException("Element Classic is not ready")) + } + } + LoginWithClassicEvent.DoLoginWithClassic -> coroutineScope.launch { + // TODO Implement real login logic here + loginWithClassicAction.value = AsyncAction.Loading + delay(1000) + loginWithClassicAction.value = AsyncAction.Success(Unit) + } + LoginWithClassicEvent.CloseDialog -> { + loginWithClassicAction.value = AsyncAction.Uninitialized + } + } + } + + return LoginWithClassicState( + canLoginWithClassic = canLoginWithClassic, + loginWithClassicAction = loginWithClassicAction.value, + eventSink = ::handleEvent, + ) + } +} diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicState.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicState.kt new file mode 100644 index 00000000000..d2706fc24a5 --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicState.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.features.login.impl.screens.onboarding.classic + +import io.element.android.libraries.architecture.AsyncAction + +data class LoginWithClassicState( + val canLoginWithClassic: Boolean, + val loginWithClassicAction: AsyncAction, + val eventSink: (LoginWithClassicEvent) -> Unit, +) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicStateProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicStateProvider.kt new file mode 100644 index 00000000000..73f68e5d61b --- /dev/null +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicStateProvider.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.features.login.impl.screens.onboarding.classic + +import io.element.android.libraries.architecture.AsyncAction + +fun aLoginWithClassicState( + canLoginWithClassic: Boolean = false, + loginWithClassicAction: AsyncAction = AsyncAction.Uninitialized, + eventSink: (LoginWithClassicEvent) -> Unit = {}, +) = LoginWithClassicState( + canLoginWithClassic = canLoginWithClassic, + loginWithClassicAction = loginWithClassicAction, + eventSink = eventSink, +) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/intro/QrCodeIntroPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/intro/QrCodeIntroPresenter.kt index 4da64480271..13888fe23fc 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/intro/QrCodeIntroPresenter.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/intro/QrCodeIntroPresenter.kt @@ -18,7 +18,7 @@ import androidx.compose.runtime.setValue import dev.zacsweers.metro.Inject import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.core.meta.BuildMeta -import io.element.android.libraries.permissions.api.PermissionsEvents +import io.element.android.libraries.permissions.api.PermissionsEvent import io.element.android.libraries.permissions.api.PermissionsPresenter @Inject @@ -46,7 +46,7 @@ class QrCodeIntroPresenter( canContinue = true } else { pendingPermissionRequest = true - cameraPermissionState.eventSink(PermissionsEvents.RequestPermissions) + cameraPermissionState.eventSink(PermissionsEvent.RequestPermissions) } } } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/scan/QrCodeScanView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/scan/QrCodeScanView.kt index 4f444b14bcd..6fa3d1b0c7f 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/scan/QrCodeScanView.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/qrcode/scan/QrCodeScanView.kt @@ -106,7 +106,7 @@ private fun Content( QrCodeCameraView( modifier = Modifier.fillMaxSize(), onScanQrCode = { state.eventSink.invoke(QrCodeScanEvents.QrCodeScanned(it)) }, - renderPreview = state.isScanning, + isScanning = state.isScanning, ) } } 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 6ffd9a84d4a..73f0dd51cce 100644 --- a/features/login/impl/src/main/res/values-cs/translations.xml +++ b/features/login/impl/src/main/res/values-cs/translations.xml @@ -60,6 +60,8 @@ "Žádost o přihlášení zrušena" "Přihlášení bylo na druhém zařízení odmítnuto." "Přihlášení odmítnuto" + "Nemusíte dělat nic jiného." + "Vaše další zařízení je již přihlášeno" "Platnost přihlášení vypršela. Zkuste to prosím znovu." "Přihlášení nebylo dokončeno včas" "Vaše druhé zařízení nepodporuje přihlášení k %su pomocí QR kódu. diff --git a/features/login/impl/src/main/res/values-de/translations.xml b/features/login/impl/src/main/res/values-de/translations.xml index cb3459f62b0..f1d426e1344 100644 --- a/features/login/impl/src/main/res/values-de/translations.xml +++ b/features/login/impl/src/main/res/values-de/translations.xml @@ -60,6 +60,8 @@ "Anmeldeanfrage abgebrochen" "Die Anmeldung auf dem anderen Gerät wurde abgelehnt." "Anmelden abgelehnt" + "Du musst nichts weiter tun." + "Dein anderes Gerät ist schon angemeldet." "Die Anmeldung ist abgelaufen. Bitte versuche es erneut." "Die Anmeldung wurde nicht rechtzeitig abgeschlossen" "Dein anderes Gerät unterstützt die Anmeldung bei %s mit einem QR-Code nicht. diff --git a/features/login/impl/src/main/res/values-et/translations.xml b/features/login/impl/src/main/res/values-et/translations.xml index 3e3e6add375..0a1a99383d9 100644 --- a/features/login/impl/src/main/res/values-et/translations.xml +++ b/features/login/impl/src/main/res/values-et/translations.xml @@ -60,6 +60,8 @@ "Sisselogimispäring on tühistatud" "Sisselogimisest on teises seadmes keeldutud." "Sisselogimisest on keeldutud" + "Sa ei pea enam midagi muud tegema." + "Sinu muu seade on juba sisse logitud" "Sisselogimine aegus. Palun proovi uuesti." "Sisselogimine jäi etteantud aja jooksul tegemata" "Sinu teine seade ei toeta %s sisselogimist QR-koodiga. diff --git a/features/login/impl/src/main/res/values-fr/translations.xml b/features/login/impl/src/main/res/values-fr/translations.xml index 946fec802ee..9846feec382 100644 --- a/features/login/impl/src/main/res/values-fr/translations.xml +++ b/features/login/impl/src/main/res/values-fr/translations.xml @@ -40,7 +40,7 @@ "Version %1$s" "Se connecter manuellement" "Connectez-vous à %1$s" - "Se connecter avec un QR code" + "Se connecter avec un code QR" "Créer un compte" "Bienvenue dans l’application %1$s la plus rapide de tous les temps. Boosté pour plus de rapidité et de simplicité." "Bienvenue sur %1$s. Boosté, pour plus de rapidité et de simplicité." @@ -60,6 +60,8 @@ "Demande de connexion annulée" "La connexion a été refusée sur l’autre appareil." "Connexion refusée" + "Vous n’avez rien d’autre à faire." + "Votre autre appareil est déjà connecté" "Connexion expirée. Veuillez essayer à nouveau." "La connexion a pris trop de temps." "Votre autre appareil ne supporte pas la connexion à %s avec un code QR. Essayer de vous connecter manuellement, ou scanner le code QR avec un autre appareil." @@ -73,14 +75,14 @@ "“Associer une nouvelle session”" "Scanner le code QR avec cet appareil" "Disponible uniquement si votre fournisseur de compte le supporte." - "Ouvrez %1$s sur un autre appareil pour obtenir le QR code" - "Scannez le QR code affiché sur l’autre appareil." + "Ouvrez %1$s sur un autre appareil pour obtenir le code QR" + "Scannez le code QR affiché sur l’autre appareil." "Essayer à nouveau" - "QR code erroné" + "Code QR erroné" "Accéder aux paramètres de l’appareil photo" "Vous devez autoriser %1$s à utiliser la camera de votre appareil pour continuer." "Autoriser l’usage de la caméra pour scanner le code QR" - "Scannez le QR code" + "Scannez le code QR" "Recommencer" "Une erreur inattendue s’est produite. Veuillez réessayer." "En attente de votre autre session" diff --git a/features/login/impl/src/main/res/values-hr/translations.xml b/features/login/impl/src/main/res/values-hr/translations.xml new file mode 100644 index 00000000000..ced196f87ee --- /dev/null +++ b/features/login/impl/src/main/res/values-hr/translations.xml @@ -0,0 +1,100 @@ + + + "Promijeni davatelja usluga računa" + "Adresa matičnog poslužitelja" + "Unesite pojam za pretraživanje ili adresu domene." + "Potražite tvrtku, zajednicu ili privatni poslužitelj." + "Pronađite davatelja usluga računa" + "Ovdje će se čuvati vaši razgovori – baš kao što biste koristili davatelja usluga e-pošte za čuvanje svojih e-poruka." + "Prijavit ćete se na %s" + "Ovdje će se čuvati vaši razgovori – baš kao što biste koristili davatelja usluga e-pošte za čuvanje svojih e-poruka." + "Izradit ćete račun na %s" + "Matrix.org velik je, besplatni poslužitelj na javnoj Matrixovoj mreži koji pruža sigurnu, decentraliziranu komunikaciju, a kojim upravlja zaklada Matrix.org." + "Ostalo" + "Koristite drugog davatelja računa, kao što je vlastiti privatni poslužitelj ili poslovni račun." + "Promijeni davatelja usluga računa" + "Google Play" + "Potrebna je aplikacija Element Pro na %1$s. Molimo vas da je preuzmete iz trgovine." + "Potreban je Element Pro" + "Nismo mogli pristupiti ovom matičnom poslužitelju. Provjerite jeste li ispravno unijeli URL matičnog poslužitelja. Ako je URL ispravan, obratite se administratoru matičnog poslužitelja za daljnju pomoć." + "Poslužitelj nije dostupan zbog problema u .well-known datoteci: +%1$s" + "Odabrani davatelj usluga računa ne podržava sliding sync. Za korištenje je potrebna nadogradnja poslužitelja %1$ssliding sync." + "%1$s nije dopušteno povezivanje s %2$s." + "Ova je aplikacija konfigurirana tako da dopušta: %1$s." + "Davatelj usluga računa %1$s nije dopušten." + "URL matičnog poslužitelja" + "Unesite adresu domene." + "Koja je adresa vašeg poslužitelja?" + "Odaberite svoj poslužitelj" + "Izradi račun" + "Ovaj je račun deaktiviran." + "Netočno korisničko ime i/ili zaporka" + "To nije valjani identifikator korisnika. Očekivani oblik: ‘@korisnik:matičniposlužitelj.org’" + "Ovaj je poslužitelj konfiguriran za korištenje tokena za osvježavanje. Oni nisu podržani kada se upotrebljava prijava temeljena na zaporki." + "Odabrani matični poslužitelj ne podržava zaporku ili OIDC prijavu. Obratite se administratoru ili odaberite drugi matični poslužitelj." + "Unesite svoje podatke" + "Matrix je otvorena mreža za sigurnu, decentraliziranu komunikaciju." + "Dobro došli natrag!" + "Prijavi se na poslužitelj %1$s" + "Inačica %1$s" + "Prijavi se ručno" + "Prijavi se na poslužitelj %1$s" + "Prijavi se pomoću QR koda" + "Izradi račun" + "Dobro došli u nikad brži %1$s. Snažniji no ikad za postizanje brzine i jednostavnosti." + "Dobro došli u %1$s. Snažniji no ikad – za brzinu i jednostavnost." + "Budi u elementu" + "Uspostavljanje sigurne veze" + "Nije moguće uspostaviti sigurnu vezu s novim uređajem. Vaši postojeći uređaji i dalje su sigurni i ne morate se brinuti zbog njih." + "Što sad?" + "Pokušajte se ponovno prijaviti pomoću QR koda u slučaju da se radilo o problemu s mrežom" + "Ako se problem ponovi, pokušajte s drugom Wi-Fi mrežom ili mobilnim podatcima umjesto Wi-Fi-ja." + "Ako to ne uspije, prijavite se ručno" + "Veza nije sigurna" + "Od vas će se zatražiti da unesete dvije znamenke prikazane na ovom uređaju." + "Unesite ispod navedeni broj u svoj drugi uređaj" + "Prijavite se na drugi uređaj i pokušajte ponovno ili upotrijebite drugi uređaj na kojem ste već prijavljeni." + "Niste prijavljeni na drugom uređaju" + "Prijava je otkazana na drugom uređaju." + "Zahtjev za prijavu je otkazan" + "Prijava je odbijena na drugom uređaju." + "Prijava je odbijena" + "Ne morate ništa drugo napraviti." + "Vaš drugi uređaj već je prijavljen" + "Prijava je istekla. Pokušajte ponovno." + "Prijava nije dovršena na vrijeme" + "Vaš drugi uređaj ne podržava prijavu na %s pomoću QR koda. + +Pokušajte se prijaviti ručno ili skenirajte QR kod drugim uređajem." + "QR kod nije podržan" + "Vaš davatelj usluga računa ne podržava %1$s ." + "%1$s nije podržan" + "Spremno za skeniranje" + "Otvorite %1$s na stolnom uređaju" + "Kliknite na svoj avatar" + "Odaberite %1$s" + "“Poveži novi uređaj”" + "Skenirajte QR kod ovim uređajem" + "Dostupno samo ako vaš davatelj usluge računa to podržava." + "Otvorite %1$s na drugom uređaju kako biste dobili QR kod" + "Upotrijebite QR kod prikazan na drugom uređaju." + "Pokušajte ponovno" + "Pogrešan QR kod" + "Idi na postavke kamere" + "Za nastavak morate dati dopuštenje za %1$s da biste se mogli služiti kamerom svog uređaja." + "Dopustite pristup kameri kako biste mogli skenirati QR kod" + "Skeniraj QR kod" + "Kreni ispočetka" + "Došlo je do neočekivane pogreške. Pokušajte ponovno." + "Čekanje na vaš drugi uređaj" + "Davatelj usluge računa može zatražiti sljedeći kod za potvrdu prijave." + "Vaš verifikacijski kod" + "Promijeni davatelja usluga računa" + "Privatni poslužitelj za zaposlenike aplikacije Element." + "Matrix je otvorena mreža za sigurnu, decentraliziranu komunikaciju." + "Ovdje će se čuvati vaši razgovori – baš kao što biste koristili davatelja usluga e-pošte za čuvanje svojih e-poruka." + "Prijavit ćete se na poslužitelj %1$s" + "Odaberite davatelja usluga računa" + "Izradit ćete račun na poslužitelju %1$s" + diff --git a/features/login/impl/src/main/res/values-pt-rBR/translations.xml b/features/login/impl/src/main/res/values-pt-rBR/translations.xml index ce67264ae84..afca14d201a 100644 --- a/features/login/impl/src/main/res/values-pt-rBR/translations.xml +++ b/features/login/impl/src/main/res/values-pt-rBR/translations.xml @@ -60,6 +60,8 @@ "Solicitação de entrada foi cancelada" "A entrada foi recusada no outro dispositivo." "Entrada recusada" + "Você não precisa fazer mais nada." + "O seu outro dispositivo já está conectado" "O processo de entrada expirou. Tente novamente." "A entrada não foi concluída a tempo" "Seu outro dispositivo não tem suporte a entrar no %s com um código QR. diff --git a/features/login/impl/src/main/res/values/localazy.xml b/features/login/impl/src/main/res/values/localazy.xml index 9b235558c84..832c3b7f71d 100644 --- a/features/login/impl/src/main/res/values/localazy.xml +++ b/features/login/impl/src/main/res/values/localazy.xml @@ -60,6 +60,8 @@ "Sign in request cancelled" "The sign in was declined on the other device." "Sign in declined" + "You don’t need to do anything else." + "Your other device is already signed in" "Sign in expired. Please try again." "The sign in was not completed in time" "Your other device does not support signing in to %s with a QR code. diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/createaccount/CreateAccountPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/createaccount/CreateAccountPresenterTest.kt index 202400a8668..600cf338d72 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/createaccount/CreateAccountPresenterTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/createaccount/CreateAccountPresenterTest.kt @@ -16,8 +16,6 @@ import io.element.android.libraries.matrix.api.auth.external.ExternalSession import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus import io.element.android.libraries.matrix.test.AN_EXCEPTION import io.element.android.libraries.matrix.test.A_SESSION_ID -import io.element.android.libraries.matrix.test.FakeMatrixClient -import io.element.android.libraries.matrix.test.FakeMatrixClientProvider import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService @@ -80,14 +78,11 @@ class CreateAccountPresenterTest { fun `present - receiving a message able to be parsed change the state to success`() = runTest { val lambda = lambdaRecorder { _ -> anExternalSession() } val sessionVerificationService = FakeSessionVerificationService() - val client = FakeMatrixClient(sessionVerificationService = sessionVerificationService) - val clientProvider = FakeMatrixClientProvider(getClient = { Result.success(client) }) val presenter = createPresenter( authenticationService = FakeMatrixAuthenticationService( importCreatedSessionLambda = { Result.success(A_SESSION_ID) } ), messageParser = FakeMessageParser(lambda), - clientProvider = clientProvider, ) presenter.test { val initialState = awaitItem() @@ -120,12 +115,10 @@ class CreateAccountPresenterTest { authenticationService: MatrixAuthenticationService = FakeMatrixAuthenticationService(), messageParser: MessageParser = FakeMessageParser(), buildMeta: BuildMeta = aBuildMeta(), - clientProvider: FakeMatrixClientProvider = FakeMatrixClientProvider(), ) = CreateAccountPresenter( url = url, authenticationService = authenticationService, messageParser = messageParser, buildMeta = buildMeta, - clientProvider = clientProvider, ) } diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenterTest.kt index 1d434997ca4..1e971ef2656 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenterTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingPresenterTest.kt @@ -16,6 +16,7 @@ import io.element.android.features.enterprise.test.FakeEnterpriseService import io.element.android.features.login.impl.accesscontrol.DefaultAccountProviderAccessControl import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource import io.element.android.features.login.impl.login.LoginHelper +import io.element.android.features.login.impl.screens.onboarding.classic.aLoginWithClassicState import io.element.android.features.login.impl.web.FakeWebClientUrlForAuthenticationRetriever import io.element.android.features.login.impl.web.WebClientUrlForAuthenticationRetriever import io.element.android.features.wellknown.test.FakeWellknownRetriever @@ -88,7 +89,10 @@ class OnBoardingPresenterTest { assertThat(initialState.canCreateAccount).isEqualTo(OnBoardingConfig.CAN_CREATE_ACCOUNT) assertThat(initialState.canReportBug).isFalse() assertThat(initialState.isAddingAccount).isFalse() - assertThat(awaitItem().canLoginWithQrCode).isTrue() + assertThat(initialState.loginWithClassicState.canLoginWithClassic).isFalse() + val finalState = awaitItem() + assertThat(finalState.canLoginWithQrCode).isTrue() + assertThat(finalState.loginWithClassicState.canLoginWithClassic).isFalse() } } @@ -283,6 +287,7 @@ private fun createPresenter( onBoardingLogoResIdProvider = onBoardingLogoResIdProvider, sessionStore = sessionStore, accountProviderDataSource = accountProviderDataSource, + loginWithClassicPresenter = { aLoginWithClassicState() }, ) fun createLoginHelper( diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/FakeElementClassicConnection.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/FakeElementClassicConnection.kt new file mode 100644 index 00000000000..2c41d2ed0f5 --- /dev/null +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/FakeElementClassicConnection.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.features.login.impl.screens.onboarding.classic + +import io.element.android.tests.testutils.lambda.lambdaError +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +class FakeElementClassicConnection( + private val startResult: () -> Unit = { lambdaError() }, + private val stopResult: () -> Unit = { lambdaError() }, + private val requestDataResult: () -> Unit = { lambdaError() }, + initialState: ElementClassicConnectionState = ElementClassicConnectionState.Idle +) : ElementClassicConnection { + override fun start() = startResult() + override fun stop() = stopResult() + override fun requestData() = requestDataResult() + private val mutableStateFlow = MutableStateFlow(initialState) + override val stateFlow: StateFlow = mutableStateFlow.asStateFlow() + suspend fun emitState(state: ElementClassicConnectionState) { + mutableStateFlow.emit(state) + } +} diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicPresenterTest.kt new file mode 100644 index 00000000000..8a8e4985c9a --- /dev/null +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/onboarding/classic/LoginWithClassicPresenterTest.kt @@ -0,0 +1,213 @@ +/* + * Copyright (c) 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package io.element.android.features.login.impl.screens.onboarding.classic + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.featureflag.api.FeatureFlagService +import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.featureflag.test.FakeFeatureFlagService +import io.element.android.libraries.matrix.test.A_USER_ID +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.test +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class LoginWithClassicPresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `present - initial state - feature disabled - start is not invoked`() = runTest { + val presenter = createPresenter( + elementClassicConnection = FakeElementClassicConnection( + startResult = { + error("start should not be invoked when feature is disabled") + }, + ) + ) + presenter.test { + val initialState = awaitItem() + assertThat(initialState.canLoginWithClassic).isFalse() + assertThat(initialState.loginWithClassicAction.isUninitialized()).isTrue() + } + } + + @Test + fun `present - feature enabled - start is invoked`() = runTest { + val startResult = lambdaRecorder {} + val presenter = createPresenter( + elementClassicConnection = FakeElementClassicConnection( + startResult = startResult, + ), + isFeatureEnabled = true, + ) + presenter.test { + val initialState = awaitItem() + assertThat(initialState.canLoginWithClassic).isFalse() + assertThat(initialState.loginWithClassicAction.isUninitialized()).isTrue() + val finalState = awaitItem() + assertThat(finalState.canLoginWithClassic).isFalse() + } + startResult.assertions().isCalledOnce() + } + + @Test + fun `present - emit request data invokes the expected method`() = runTest { + val requestDataResult = lambdaRecorder {} + val presenter = createPresenter( + elementClassicConnection = FakeElementClassicConnection( + startResult = {}, + requestDataResult = requestDataResult, + ), + isFeatureEnabled = true, + ) + presenter.test { + val initialState = awaitItem() + assertThat(initialState.canLoginWithClassic).isFalse() + assertThat(initialState.loginWithClassicAction.isUninitialized()).isTrue() + val nextState = awaitItem() + assertThat(nextState.canLoginWithClassic).isFalse() + nextState.eventSink(LoginWithClassicEvent.RefreshData) + } + requestDataResult.assertions().isCalledOnce() + } + + @Test + fun `present - start login with wrong state emits an error`() = runTest { + val presenter = createPresenter( + elementClassicConnection = FakeElementClassicConnection( + startResult = {}, + ), + isFeatureEnabled = true, + ) + presenter.test { + skipItems(1) + val state = awaitItem() + state.eventSink(LoginWithClassicEvent.StartLoginWithClassic) + val errorState = awaitItem() + assertThat(errorState.loginWithClassicAction.isFailure()).isTrue() + } + } + + @Test + fun `present - start login with correct state - user cancel`() = runTest { + val elementClassicConnection = FakeElementClassicConnection( + startResult = {}, + ) + val presenter = createPresenter( + elementClassicConnection = elementClassicConnection, + isFeatureEnabled = true, + ) + presenter.test { + skipItems(2) + elementClassicConnection.emitState( + ElementClassicConnectionState.ElementClassicReady(userId = A_USER_ID) + ) + val readyState = awaitItem() + assertThat(readyState.canLoginWithClassic).isTrue() + readyState.eventSink(LoginWithClassicEvent.StartLoginWithClassic) + val confirmingState = awaitItem() + assertThat(confirmingState.loginWithClassicAction.isConfirming()).isTrue() + assertThat((confirmingState.loginWithClassicAction as ConfirmingLoginWithElementClassic).userId).isEqualTo(A_USER_ID) + confirmingState.eventSink(LoginWithClassicEvent.CloseDialog) + val finalState = awaitItem() + assertThat(finalState.loginWithClassicAction.isUninitialized()).isTrue() + } + } + + @Test + fun `present - start login with correct state - user confirms`() = runTest { + val elementClassicConnection = FakeElementClassicConnection( + startResult = {}, + ) + val presenter = createPresenter( + elementClassicConnection = elementClassicConnection, + isFeatureEnabled = true, + ) + presenter.test { + skipItems(2) + elementClassicConnection.emitState( + ElementClassicConnectionState.ElementClassicReady(userId = A_USER_ID) + ) + val readyState = awaitItem() + assertThat(readyState.canLoginWithClassic).isTrue() + readyState.eventSink(LoginWithClassicEvent.StartLoginWithClassic) + val confirmingState = awaitItem() + assertThat(confirmingState.loginWithClassicAction.isConfirming()).isTrue() + assertThat((confirmingState.loginWithClassicAction as ConfirmingLoginWithElementClassic).userId).isEqualTo(A_USER_ID) + confirmingState.eventSink(LoginWithClassicEvent.DoLoginWithClassic) + val loadingState = awaitItem() + assertThat(loadingState.loginWithClassicAction.isLoading()).isTrue() + val finalState = awaitItem() + assertThat(finalState.loginWithClassicAction.isSuccess()).isTrue() + } + } + + @Test + fun `present - cannot sign in if a session with the same account already exists`() = runTest { + val elementClassicConnection = FakeElementClassicConnection( + startResult = {}, + ) + val presenter = createPresenter( + elementClassicConnection = elementClassicConnection, + isFeatureEnabled = true, + sessionStore = InMemorySessionStore( + initialList = listOf( + aSessionData( + sessionId = A_USER_ID.value, + ) + ) + ), + ) + presenter.test { + skipItems(2) + elementClassicConnection.emitState( + ElementClassicConnectionState.ElementClassicReady(userId = A_USER_ID) + ) + // No new item, because canLoginWithClassic is still false + } + } + + @Test + fun `present - cannot sign in if the feature is disabled`() = runTest { + val elementClassicConnection = FakeElementClassicConnection() + val presenter = createPresenter( + elementClassicConnection = elementClassicConnection, + isFeatureEnabled = false, + ) + presenter.test { + skipItems(1) + // Note: it should not happen IRL + elementClassicConnection.emitState( + ElementClassicConnectionState.ElementClassicReady(userId = A_USER_ID) + ) + // No new item, because canLoginWithClassic is still false + } + } +} + +private fun createPresenter( + elementClassicConnection: ElementClassicConnection = FakeElementClassicConnection(), + sessionStore: SessionStore = InMemorySessionStore(), + isFeatureEnabled: Boolean = false, + featureFlagService: FeatureFlagService = FakeFeatureFlagService( + initialState = mapOf(FeatureFlags.SignInWithClassic.key to isFeatureEnabled) + ), +) = LoginWithClassicPresenter( + elementClassicConnection = elementClassicConnection, + sessionStore = sessionStore, + featureFlagService = featureFlagService, +) diff --git a/features/logout/impl/src/main/res/values-hr/translations.xml b/features/logout/impl/src/main/res/values-hr/translations.xml new file mode 100644 index 00000000000..331eca04c47 --- /dev/null +++ b/features/logout/impl/src/main/res/values-hr/translations.xml @@ -0,0 +1,18 @@ + + + "Jeste li sigurni da se želite odjaviti?" + "Odjava" + "Odjava" + "Odjavljivanje…" + "Odjavit ćete se iz svoje posljednje sesije. Ako se sada odjavite, nećete moći pristupiti svojim šifriranim porukama." + "Isključili ste sigurnosno kopiranje" + "Vaši su se ključevi još uvijek sigurnosno kopirali kada ste se isključili iz mreže. Ponovno se povežite kako bi se vaši ključevi mogli sigurnosno kopirati prije nego što se odjavite." + "Vaši se ključevi još uvijek sigurnosno kopiraju" + "Pričekajte da se to dovrši prije nego što se odjavite." + "Vaši se ključevi još uvijek sigurnosno kopiraju" + "Odjava" + "Odjavit ćete se iz svoje posljednje sesije. Ako se sada odjavite, nećete moći pristupiti svojim šifriranim porukama." + "Oporavak nije postavljen" + "Odjavit ćete se iz svoje posljednje sesije. Ako se sada odjavite, možda nećete moći pristupiti svojim šifriranim porukama." + "Jeste li spremili svoj ključ za oporavak?" + diff --git a/features/messages/impl/build.gradle.kts b/features/messages/impl/build.gradle.kts index e6a1299d55f..bd3e2aaf290 100644 --- a/features/messages/impl/build.gradle.kts +++ b/features/messages/impl/build.gradle.kts @@ -71,6 +71,7 @@ dependencies { implementation(libs.jsoup) implementation(libs.androidx.constraintlayout) implementation(libs.androidx.constraintlayout.compose) + implementation(libs.androidx.datastore.preferences) implementation(libs.androidx.media3.exoplayer) implementation(libs.androidx.media3.ui) implementation(libs.sigpwned.emoji4j) 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 f753a19d1f8..9d582aaaf16 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 @@ -12,12 +12,10 @@ import android.os.Build import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.MutableState -import androidx.compose.runtime.State 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.saveable.rememberSaveable @@ -32,6 +30,7 @@ import io.element.android.features.messages.api.timeline.HtmlConverterProvider import io.element.android.features.messages.impl.actionlist.ActionListEvents import io.element.android.features.messages.impl.actionlist.ActionListState import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction +import io.element.android.features.messages.impl.crypto.historyvisible.HistoryVisibleState import io.element.android.features.messages.impl.crypto.identity.IdentityChangeState import io.element.android.features.messages.impl.link.LinkState import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvent @@ -75,14 +74,10 @@ import io.element.android.libraries.matrix.api.encryption.EncryptionService import io.element.android.libraries.matrix.api.encryption.identity.IdentityState import io.element.android.libraries.matrix.api.permalink.PermalinkParser import io.element.android.libraries.matrix.api.room.JoinedRoom -import io.element.android.libraries.matrix.api.room.MessageEventType import io.element.android.libraries.matrix.api.room.RoomInfo import io.element.android.libraries.matrix.api.room.RoomMembersState import io.element.android.libraries.matrix.api.room.isDm -import io.element.android.libraries.matrix.api.room.powerlevels.canPinUnpin -import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOther -import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOwn -import io.element.android.libraries.matrix.api.room.powerlevels.canSendMessage +import io.element.android.libraries.matrix.api.room.powerlevels.permissionsAsState import io.element.android.libraries.matrix.api.timeline.item.event.EventOrTransactionId import io.element.android.libraries.matrix.ui.messages.reply.map import io.element.android.libraries.matrix.ui.model.getAvatarData @@ -107,6 +102,7 @@ class MessagesPresenter( @Assisted private val timelinePresenter: Presenter, private val timelineProtectionPresenter: Presenter, private val identityChangeStatePresenter: Presenter, + private val historyVisibleStatePresenter: Presenter, private val linkPresenter: Presenter, @Assisted private val actionListPresenter: Presenter, private val customReactionPresenter: Presenter, @@ -158,6 +154,7 @@ class MessagesPresenter( val timelineState = timelinePresenter.present() val timelineProtectionState = timelineProtectionPresenter.present() val identityChangeState = identityChangeStatePresenter.present() + val historyVisibleState = historyVisibleStatePresenter.present() val actionListState = actionListPresenter.present() val linkState = linkPresenter.present() val customReactionState = customReactionPresenter.present() @@ -167,7 +164,9 @@ class MessagesPresenter( val roomCallState = roomCallStatePresenter.present() val roomMemberModerationState = roomMemberModerationPresenter.present() - val userEventPermissions by userEventPermissions(roomInfo) + val userEventPermissions by room.permissionsAsState(UserEventPermissions.DEFAULT) { perms -> + perms.userEventPermissions() + } val roomAvatar by remember { derivedStateOf { roomInfo.avatarData() } @@ -287,6 +286,7 @@ class MessagesPresenter( timelineState = timelineState, timelineProtectionState = timelineProtectionState, identityChangeState = identityChangeState, + historyVisibleState = historyVisibleState, linkState = linkState, actionListState = actionListState, customReactionState = customReactionState, @@ -306,24 +306,6 @@ class MessagesPresenter( ) } - @Composable - private fun userEventPermissions(roomInfo: RoomInfo): State { - val key = if (roomInfo.privilegedCreatorRole && roomInfo.creators.contains(room.sessionId)) { - Long.MAX_VALUE - } else { - roomInfo.roomPowerLevels?.hashCode() ?: 0L - } - return produceState(UserEventPermissions.DEFAULT, key1 = key) { - value = UserEventPermissions( - canSendMessage = room.canSendMessage(type = MessageEventType.RoomMessage).getOrElse { true }, - canSendReaction = room.canSendMessage(type = MessageEventType.Reaction).getOrElse { true }, - canRedactOwn = room.canRedactOwn().getOrElse { false }, - canRedactOther = room.canRedactOther().getOrElse { false }, - canPinUnpin = room.canPinUnpin().getOrElse { false }, - ) - } - } - private fun RoomInfo.avatarData(): AvatarData { return AvatarData( id = id.value, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt index ea147b3cff3..3099edc9c27 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt @@ -10,6 +10,7 @@ package io.element.android.features.messages.impl import io.element.android.features.messages.api.timeline.voicemessages.composer.VoiceMessageComposerState import io.element.android.features.messages.impl.actionlist.ActionListState +import io.element.android.features.messages.impl.crypto.historyvisible.HistoryVisibleState import io.element.android.features.messages.impl.crypto.identity.IdentityChangeState import io.element.android.features.messages.impl.link.LinkState import io.element.android.features.messages.impl.messagecomposer.MessageComposerState @@ -47,6 +48,7 @@ data class MessagesState( val timelineState: TimelineState, val timelineProtectionState: TimelineProtectionState, val identityChangeState: IdentityChangeState, + val historyVisibleState: HistoryVisibleState, val linkState: LinkState, val actionListState: ActionListState, val customReactionState: CustomReactionState, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt index 1c55a1035ff..1177d82eecb 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt @@ -14,7 +14,10 @@ import io.element.android.features.messages.api.timeline.voicemessages.composer. import io.element.android.features.messages.api.timeline.voicemessages.composer.aVoiceMessagePreviewState import io.element.android.features.messages.impl.actionlist.ActionListState import io.element.android.features.messages.impl.actionlist.anActionListState +import io.element.android.features.messages.impl.crypto.historyvisible.HistoryVisibleState +import io.element.android.features.messages.impl.crypto.historyvisible.aHistoryVisibleState import io.element.android.features.messages.impl.crypto.identity.IdentityChangeState +import io.element.android.features.messages.impl.crypto.identity.aRoomMemberIdentityStateChange import io.element.android.features.messages.impl.crypto.identity.anIdentityChangeState import io.element.android.features.messages.impl.link.LinkState import io.element.android.features.messages.impl.link.aLinkState @@ -38,6 +41,7 @@ import io.element.android.features.messages.impl.timeline.protection.aTimelinePr import io.element.android.features.roomcall.api.RoomCallState import io.element.android.features.roomcall.api.aStandByCallState import io.element.android.features.roommembermoderation.api.RoomMemberModerationEvents +import io.element.android.features.roommembermoderation.api.RoomMemberModerationPermissions import io.element.android.features.roommembermoderation.api.RoomMemberModerationState import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.designsystem.components.avatar.AvatarData @@ -48,6 +52,7 @@ import io.element.android.libraries.matrix.api.encryption.identity.IdentityState import io.element.android.libraries.matrix.api.room.tombstone.SuccessorRoom import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.textcomposer.model.MessageComposerMode +import io.element.android.libraries.textcomposer.model.aTextEditorStateMarkdown import io.element.android.libraries.textcomposer.model.aTextEditorStateRich import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -83,6 +88,19 @@ open class MessagesStateProvider : PreviewParameterProvider { timelineItems = aTimelineItemList(aTimelineItemTextContent()), ) ), + aMessagesState( + composerState = aMessageComposerState(textEditorState = aTextEditorStateMarkdown()), + identityChangeState = anIdentityChangeState(listOf(aRoomMemberIdentityStateChange())) + ), + aMessagesState( + composerState = aMessageComposerState(textEditorState = aTextEditorStateMarkdown()), + historyVisibleState = aHistoryVisibleState(showAlert = true) + ), + aMessagesState( + composerState = aMessageComposerState(textEditorState = aTextEditorStateMarkdown()), + identityChangeState = anIdentityChangeState(listOf(aRoomMemberIdentityStateChange())), + historyVisibleState = aHistoryVisibleState(showAlert = true) + ) ) } @@ -103,6 +121,7 @@ fun aMessagesState( ), timelineProtectionState: TimelineProtectionState = aTimelineProtectionState(), identityChangeState: IdentityChangeState = anIdentityChangeState(), + historyVisibleState: HistoryVisibleState = aHistoryVisibleState(), linkState: LinkState = aLinkState(), readReceiptBottomSheetState: ReadReceiptBottomSheetState = aReadReceiptBottomSheetState(), actionListState: ActionListState = anActionListState(), @@ -130,6 +149,7 @@ fun aMessagesState( voiceMessageComposerState = voiceMessageComposerState, timelineProtectionState = timelineProtectionState, identityChangeState = identityChangeState, + historyVisibleState = historyVisibleState, linkState = linkState, timelineState = timelineState, readReceiptBottomSheetState = readReceiptBottomSheetState, @@ -150,11 +170,9 @@ fun aMessagesState( ) fun aRoomMemberModerationState( - canKick: Boolean = false, - canBan: Boolean = false, + permissions: RoomMemberModerationPermissions = RoomMemberModerationPermissions.DEFAULT, ) = object : RoomMemberModerationState { - override val canKick: Boolean = canKick - override val canBan: Boolean = canBan + override val permissions: RoomMemberModerationPermissions = permissions override val eventSink: (RoomMemberModerationEvents) -> Unit = {} } 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 0ff03ada041..d5754d206da 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 @@ -29,10 +29,15 @@ import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +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.shadow import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.Role @@ -41,6 +46,7 @@ import androidx.compose.ui.semantics.role import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme @@ -48,6 +54,7 @@ import io.element.android.features.messages.api.timeline.voicemessages.composer. import io.element.android.features.messages.impl.actionlist.ActionListEvents import io.element.android.features.messages.impl.actionlist.ActionListView import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction +import io.element.android.features.messages.impl.crypto.historyvisible.HistoryVisibleStateView import io.element.android.features.messages.impl.crypto.identity.IdentityChangeStateView import io.element.android.features.messages.impl.link.LinkEvents import io.element.android.features.messages.impl.link.LinkView @@ -62,6 +69,10 @@ import io.element.android.features.messages.impl.pinned.banner.PinnedMessagesBan import io.element.android.features.messages.impl.timeline.FOCUS_ON_PINNED_EVENT_DEBOUNCE_DURATION_IN_MILLIS import io.element.android.features.messages.impl.timeline.TimelineEvents 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.aTimelineState import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionBottomSheet import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionEvents import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryEvents @@ -69,6 +80,9 @@ import io.element.android.features.messages.impl.timeline.components.reactionsum import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheet import io.element.android.features.messages.impl.timeline.components.receipt.bottomsheet.ReadReceiptBottomSheetEvents 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.topbars.MessagesViewTopBar import io.element.android.features.messages.impl.topbars.ThreadTopBar import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessagePermissionRationaleDialog @@ -82,6 +96,7 @@ import io.element.android.libraries.designsystem.components.rememberExpandableBo import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.text.toAnnotatedString +import io.element.android.libraries.designsystem.text.toDp import io.element.android.libraries.designsystem.theme.components.BottomSheetDragHandle import io.element.android.libraries.designsystem.theme.components.Scaffold import io.element.android.libraries.designsystem.theme.components.Text @@ -96,10 +111,12 @@ import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.encryption.identity.IdentityState import io.element.android.libraries.matrix.api.room.tombstone.SuccessorRoom 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.user.MatrixUser 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 timber.log.Timber import kotlin.time.Duration.Companion.milliseconds @@ -129,6 +146,8 @@ fun MessagesView( val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage) + var maxComposerHeightPx by remember { mutableIntStateOf(120) } + // This is needed because the composer is inside an AndroidView that can't be affected by the FocusManager in Compose val localView = LocalView.current @@ -179,7 +198,13 @@ fun MessagesView( modifier = modifier .fillMaxSize() .imePadding() - .systemBarsPadding(), + .systemBarsPadding() + .onSizeChanged { size -> + // Let the composer takes at max half of the available height. + // The value will be different if the soft keyboard is displayed + // or not. + maxComposerHeightPx = (size.height * 0.5f).toInt() + }, content = { Scaffold( contentWindowInsets = WindowInsets.statusBars, @@ -315,7 +340,7 @@ fun MessagesView( } else { RectangleShape }, - maxBottomSheetContentHeight = 360.dp, + maxBottomSheetContentHeight = maxComposerHeightPx.toDp(), ) ActionListView( @@ -474,11 +499,18 @@ private fun MessagesViewComposerBottomSheetContents( // Do not show the identity change if user is composing a Rich message or is seeing suggestion(s). if (state.composerState.suggestions.isEmpty() && state.composerState.textEditorState is TextEditorState.Markdown) { - IdentityChangeStateView( - showMatrixId = state.showMatrixId, - state = state.identityChangeState, - onLinkClick = onLinkClick, - ) + if (state.identityChangeState.roomMemberIdentityStateChanges.isNotEmpty()) { + IdentityChangeStateView( + showMatrixId = state.showMatrixId, + state = state.identityChangeState, + onLinkClick = onLinkClick, + ) + } else { + HistoryVisibleStateView( + state = state.historyVisibleState, + onLinkClick = onLinkClick, + ) + } } val verificationViolation = state.identityChangeState.roomMemberIdentityStateChanges.firstOrNull { it.identityState == IdentityState.VerificationViolation @@ -553,3 +585,57 @@ internal fun MessagesViewPreview(@PreviewParameter(MessagesStateProvider::class) knockRequestsBannerView = {}, ) } + +@Preview +@Composable +internal fun MessagesViewA11yPreview() = ElementPreview { + val content = aTimelineItemTextContent( + body = "A message content" + ) + MessagesView( + state = aMessagesState( + roomName = "A DM with a very looong name", + dmUserVerificationState = IdentityState.VerificationViolation, + timelineState = aTimelineState( + timelineItems = persistentListOf( + // 1 items with isMine = false + aTimelineItemEvent( + isMine = false, + content = content, + groupPosition = TimelineItemGroupPosition.None, + sendState = LocalEventSendState.Failed.Unknown("Message failed to send"), + ), + // A state event on top of it + aTimelineItemEvent( + isMine = false, + content = aTimelineItemStateEventContent(), + groupPosition = TimelineItemGroupPosition.None + ), + // 1 item with isMine = true + aTimelineItemEvent( + isMine = true, + content = content, + groupPosition = TimelineItemGroupPosition.None + ), + // A grouped event on top of it + aGroupedEvents(), + // A day separator + aTimelineItemDaySeparator(), + ), + // Render a focused event for an event with sender information displayed + focusedEventIndex = 2, + ) + ), + onBackClick = {}, + onRoomDetailsClick = {}, + onEventContentClick = { _, _ -> false }, + onUserDataClick = {}, + onLinkClick = { _, _ -> }, + onSendLocationClick = {}, + onCreatePollClick = {}, + onJoinCallClick = {}, + onViewAllPinnedMessagesClick = { }, + forceJumpToBottomVisibility = true, + knockRequestsBannerView = {}, + ) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/UserEventPermissions.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/UserEventPermissions.kt index f7d221950b8..349c8e58dc2 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/UserEventPermissions.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/UserEventPermissions.kt @@ -8,6 +8,9 @@ package io.element.android.features.messages.impl +import io.element.android.libraries.matrix.api.room.MessageEventType +import io.element.android.libraries.matrix.api.room.powerlevels.RoomPermissions + /** * Represents the permissions a user has in a room. * It's dependent of the user's power level in the room. @@ -29,3 +32,13 @@ data class UserEventPermissions( ) } } + +fun RoomPermissions.userEventPermissions(): UserEventPermissions { + return UserEventPermissions( + canRedactOwn = canOwnUserRedactOwn(), + canRedactOther = canOwnUserRedactOther(), + canSendMessage = canOwnUserSendMessage(MessageEventType.RoomMessage), + canSendReaction = canOwnUserSendMessage(MessageEventType.Reaction), + canPinUnpin = canOwnUserPinUnpin() + ) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt index d7e0332bd4b..f7641ec21bb 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewPresenter.kt @@ -37,6 +37,7 @@ import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder 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.MediaSenderFactory import io.element.android.libraries.mediaupload.api.MediaUploadInfo import io.element.android.libraries.mediaupload.api.allFiles @@ -62,6 +63,7 @@ class AttachmentsPreviewPresenter( private val mediaOptimizationSelectorPresenterFactory: MediaOptimizationSelectorPresenter.Factory, @SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope, private val dispatchers: CoroutineDispatchers, + private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider, ) : Presenter { @AssistedFactory interface Factory { @@ -107,13 +109,9 @@ class AttachmentsPreviewPresenter( // to prepare it for sending. This is done to avoid blocking the UI thread when the // user clicks on the send button. if (mediaOptimizationSelectorState.displayMediaSelectorViews == false) { - val mediaOptimizationConfig = MediaOptimizationConfig( - compressImages = mediaOptimizationSelectorState.isImageOptimizationEnabled == true, - videoCompressionPreset = mediaOptimizationSelectorState.selectedVideoPreset ?: VideoCompressionPreset.STANDARD, - ) preprocessMediaJob = preProcessAttachment( attachment = attachment, - mediaOptimizationConfig = mediaOptimizationConfig, + mediaOptimizationConfig = mediaOptimizationConfigProvider.get(), displayProgress = false, sendActionState = sendActionState, ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewStateProvider.kt index 6823aead3fd..70d7ab006ef 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/preview/AttachmentsPreviewStateProvider.kt @@ -95,7 +95,7 @@ fun aMediaUploadInfo( ) fun aMediaOptimisationSelectorState( - maxUploadSize: Long = 100, + maxUploadSize: Long = 100 * 1024 * 1024, videoSizeEstimations: AsyncData> = AsyncData.Success(persistentListOf()), isImageOptimizationEnabled: Boolean = true, selectedVideoPreset: VideoCompressionPreset = VideoCompressionPreset.STANDARD, 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 7c9ffdaf89f..8f55957e327 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 @@ -231,7 +231,7 @@ private fun ImageOptimizationSelector(state: MediaOptimizationSelectorState) { Text( modifier = Modifier.weight(1f).align(Alignment.CenterVertically), text = stringResource(R.string.screen_media_upload_preview_optimize_image_quality_title), - style = ElementTheme.materialTypography.bodyLarge, + style = ElementTheme.typography.fontBodyLgRegular, ) Switch( modifier = Modifier.height(32.dp), @@ -337,7 +337,7 @@ private fun VideoQualitySelectorDialog( supportingContent = { Text( text = preset.subtitle(), - style = ElementTheme.materialTypography.bodyMedium, + style = ElementTheme.typography.fontBodyMdRegular, color = ElementTheme.colors.textSecondary, ) }, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/video/DefaultMediaOptimizationSelectorPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/video/DefaultMediaOptimizationSelectorPresenter.kt index d0716abe839..c81c306f90a 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/video/DefaultMediaOptimizationSelectorPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/attachments/video/DefaultMediaOptimizationSelectorPresenter.kt @@ -25,13 +25,12 @@ import io.element.android.libraries.di.SessionScope import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.mediaupload.api.MaxUploadSizeProvider +import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProvider import io.element.android.libraries.mediaupload.api.compressorHelper import io.element.android.libraries.mediaviewer.api.local.LocalMedia -import io.element.android.libraries.preferences.api.store.SessionPreferencesStore import io.element.android.libraries.preferences.api.store.VideoCompressionPreset import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList -import kotlinx.coroutines.flow.first import timber.log.Timber import kotlin.math.roundToLong @@ -39,8 +38,8 @@ import kotlin.math.roundToLong class DefaultMediaOptimizationSelectorPresenter( @Assisted private val localMedia: LocalMedia, private val maxUploadSizeProvider: MaxUploadSizeProvider, - private val sessionPreferencesStore: SessionPreferencesStore, private val featureFlagService: FeatureFlagService, + private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider, mediaExtractorFactory: VideoMetadataExtractor.Factory, ) : MediaOptimizationSelectorPresenter { @ContributesBinding(SessionScope::class) @@ -124,11 +123,12 @@ class DefaultMediaOptimizationSelectorPresenter( var selectedVideoOptimizationPreset by remember { mutableStateOf>(AsyncData.Loading()) } LaunchedEffect(videoSizeEstimations.dataOrNull()) { - selectedImageOptimization = AsyncData.Success(sessionPreferencesStore.doesOptimizeImages().first()) + val mediaOptimizationConfig = mediaOptimizationConfigProvider.get() + selectedImageOptimization = AsyncData.Success(mediaOptimizationConfig.compressImages) // Find the best video preset based on the default preset and the video size estimations // Since the estimation for the current preset may be way too large to upload, we check the ones that provide lower file sizes selectedVideoOptimizationPreset = findBestVideoPreset( - defaultVideoPreset = sessionPreferencesStore.getVideoCompressionPreset().first(), + defaultVideoPreset = mediaOptimizationConfig.videoCompressionPreset, videoSizeEstimations = videoSizeEstimations, ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/HistoryVisibleAcknowledgementRepository.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/HistoryVisibleAcknowledgementRepository.kt new file mode 100644 index 00000000000..1fa992fc3e3 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/HistoryVisibleAcknowledgementRepository.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2025 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.crypto.historyvisible + +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.androidutils.hash.hash +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.preferences.api.store.PreferenceDataStoreFactory +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +interface HistoryVisibleAcknowledgementRepository { + fun hasAcknowledged(roomId: RoomId): Flow + suspend fun setAcknowledged(roomId: RoomId, value: Boolean) +} + +@ContributesBinding(SessionScope::class) +class DefaultHistoryVisibleAcknowledgementRepository( + sessionId: SessionId, + preferenceDataStoreFactory: PreferenceDataStoreFactory, +) : HistoryVisibleAcknowledgementRepository { + val store = + sessionId.value.hash().take(16).let { hash -> + preferenceDataStoreFactory.create("elementx_historyvisible_$hash") + } + + override fun hasAcknowledged(roomId: RoomId): Flow { + return store.data.map { prefs -> + val acknowledged = prefs[booleanPreferencesKey(roomId.value)] ?: false + acknowledged + } + } + + override suspend fun setAcknowledged(roomId: RoomId, value: Boolean) { + store.edit { prefs -> + prefs[booleanPreferencesKey(roomId.value)] = value + } + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/HistoryVisibleEvent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/HistoryVisibleEvent.kt new file mode 100644 index 00000000000..775d9c00d42 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/HistoryVisibleEvent.kt @@ -0,0 +1,12 @@ +/* + * Copyright (c) 2025 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.crypto.historyvisible + +sealed interface HistoryVisibleEvent { + data object Acknowledge : HistoryVisibleEvent +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/HistoryVisibleState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/HistoryVisibleState.kt new file mode 100644 index 00000000000..3f980eb0864 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/HistoryVisibleState.kt @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2025 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.crypto.historyvisible + +data class HistoryVisibleState( + val showAlert: Boolean, + val eventSink: (HistoryVisibleEvent) -> Unit, +) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/HistoryVisibleStatePresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/HistoryVisibleStatePresenter.kt new file mode 100644 index 00000000000..e79681cd5cb --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/HistoryVisibleStatePresenter.kt @@ -0,0 +1,64 @@ +/* + * Copyright (c) 2025 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.crypto.historyvisible + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import dev.zacsweers.metro.Inject +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.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.room.JoinedRoom +import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibility +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +@Inject +class HistoryVisibleStatePresenter( + private val featureFlagService: FeatureFlagService, + private val repository: HistoryVisibleAcknowledgementRepository, + private val room: JoinedRoom, +) : Presenter { + @Composable + override fun present(): HistoryVisibleState { + val isFeatureEnabled by featureFlagService.isFeatureEnabledFlow(FeatureFlags.EnableKeyShareOnInvite).collectAsState(initial = false) + val roomInfo by room.roomInfoFlow.collectAsState() + // Implicitly assume the alert is initially acknowledged to avoid flashes in UI. + val acknowledged by repository.hasAcknowledged(room.roomId).collectAsState(initial = true) + val isHistoryVisible = roomInfo.historyVisibility == RoomHistoryVisibility.Shared || roomInfo.historyVisibility == RoomHistoryVisibility.WorldReadable + + val coroutineScope = rememberCoroutineScope() + + LaunchedEffect(isHistoryVisible, acknowledged) { + if (!isHistoryVisible && acknowledged) { + // Clear the dismissed flag, if it is set to ensure that if a room is changed public -> private -> public, + // we show the banner again when it is set back to public. + repository.setAcknowledged(room.roomId, false) + } + } + + fun handleEvent(event: HistoryVisibleEvent) { + when (event) { + is HistoryVisibleEvent.Acknowledge -> coroutineScope.setAcknowledged(room.roomId, true) + } + } + + return HistoryVisibleState( + showAlert = isFeatureEnabled && isHistoryVisible && roomInfo.isEncrypted == true && !acknowledged, + eventSink = ::handleEvent, + ) + } + + private fun CoroutineScope.setAcknowledged(roomId: RoomId, value: Boolean) = launch { + repository.setAcknowledged(roomId, value) + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/HistoryVisibleStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/HistoryVisibleStateProvider.kt new file mode 100644 index 00000000000..752abdc76b7 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/HistoryVisibleStateProvider.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2025 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.crypto.historyvisible + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider + +class HistoryVisibleStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aHistoryVisibleState(showAlert = true), + ) +} + +internal fun aHistoryVisibleState( + showAlert: Boolean = false, + eventSink: (HistoryVisibleEvent) -> Unit = {}, +) = HistoryVisibleState( + showAlert, + eventSink = eventSink, +) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/HistoryVisibleStateView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/HistoryVisibleStateView.kt new file mode 100644 index 00000000000..d0655f695d3 --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/HistoryVisibleStateView.kt @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2025 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.crypto.historyvisible + +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.tooling.preview.PreviewParameter +import io.element.android.appconfig.LearnMoreConfig +import io.element.android.libraries.designsystem.atomic.molecules.ComposerAlertLevel +import io.element.android.libraries.designsystem.atomic.molecules.ComposerAlertMolecule +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.text.stringWithLink +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun HistoryVisibleStateView( + state: HistoryVisibleState, + onLinkClick: (String, Boolean) -> Unit, + modifier: Modifier = Modifier, +) { + if (!state.showAlert) { + return + } + ComposerAlertMolecule( + modifier = modifier, + avatar = null, + showIcon = true, + level = ComposerAlertLevel.Info, + content = stringWithLink( + textRes = CommonStrings.crypto_history_visible, + url = LearnMoreConfig.HISTORY_VISIBLE_URL, + onLinkClick = { url -> onLinkClick(url, true) }, + ), + submitText = stringResource(CommonStrings.action_dismiss), + onSubmitClick = { state.eventSink(HistoryVisibleEvent.Acknowledge) }, + ) +} + +@PreviewsDayNight +@Composable +internal fun HistoryVisibleStateViewPreview( + @PreviewParameter(HistoryVisibleStateProvider::class) state: HistoryVisibleState, +) = ElementPreview { + HistoryVisibleStateView( + state = state, + onLinkClick = { _, _ -> }, + ) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/MessagesViewWithHistoryVisiblePreview.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/MessagesViewWithHistoryVisiblePreview.kt new file mode 100644 index 00000000000..07cf5170d3a --- /dev/null +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/MessagesViewWithHistoryVisiblePreview.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2025 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.crypto.historyvisible + +import androidx.compose.runtime.Composable +import io.element.android.features.messages.impl.MessagesView +import io.element.android.features.messages.impl.aMessagesState +import io.element.android.features.messages.impl.messagecomposer.aMessageComposerState +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.textcomposer.model.aTextEditorStateMarkdown + +@PreviewsDayNight +@Composable +internal fun MessagesViewWithHistoryVisiblePreview() = ElementPreview { + MessagesView( + state = aMessagesState( + composerState = aMessageComposerState( + textEditorState = aTextEditorStateMarkdown( + initialText = "", + initialFocus = false, + ) + ), + historyVisibleState = aHistoryVisibleState(showAlert = true), + ), + onBackClick = {}, + onRoomDetailsClick = {}, + onEventContentClick = { _, _ -> false }, + onUserDataClick = {}, + onLinkClick = { _, _ -> }, + onSendLocationClick = {}, + onCreatePollClick = {}, + onJoinCallClick = {}, + onViewAllPinnedMessagesClick = {}, + knockRequestsBannerView = {} + ) +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/di/MessagesBindsModule.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/di/MessagesBindsModule.kt index a345e09fa29..a88dbb1b494 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/di/MessagesBindsModule.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/di/MessagesBindsModule.kt @@ -11,6 +11,8 @@ package io.element.android.features.messages.impl.di import dev.zacsweers.metro.BindingContainer import dev.zacsweers.metro.Binds import dev.zacsweers.metro.ContributesTo +import io.element.android.features.messages.impl.crypto.historyvisible.HistoryVisibleState +import io.element.android.features.messages.impl.crypto.historyvisible.HistoryVisibleStatePresenter import io.element.android.features.messages.impl.crypto.identity.IdentityChangeState import io.element.android.features.messages.impl.crypto.identity.IdentityChangeStatePresenter import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailurePresenter @@ -61,4 +63,7 @@ interface MessagesBindsModule { @Binds fun bindIdentityChangeStatePresenter(presenter: IdentityChangeStatePresenter): Presenter + + @Binds + fun bindHistoryVisibleStatePresenter(presenter: HistoryVisibleStatePresenter): Presenter } 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 276499d5bbb..fd803109c75 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 @@ -55,6 +55,7 @@ import io.element.android.libraries.matrix.api.room.draft.ComposerDraft import io.element.android.libraries.matrix.api.room.draft.ComposerDraftType import io.element.android.libraries.matrix.api.room.getDirectRoomMember import io.element.android.libraries.matrix.api.room.isDm +import io.element.android.libraries.matrix.api.room.powerlevels.use import io.element.android.libraries.matrix.api.timeline.TimelineException import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails @@ -63,7 +64,7 @@ import io.element.android.libraries.mediapickers.api.PickerProvider import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProvider import io.element.android.libraries.mediaupload.api.MediaSenderFactory import io.element.android.libraries.mediaviewer.api.local.LocalMediaFactory -import io.element.android.libraries.permissions.api.PermissionsEvents +import io.element.android.libraries.permissions.api.PermissionsEvent import io.element.android.libraries.permissions.api.PermissionsPresenter import io.element.android.libraries.preferences.api.store.SessionPreferencesStore import io.element.android.libraries.push.api.notifications.conversations.NotificationConversationService @@ -98,6 +99,7 @@ import timber.log.Timber import kotlin.time.Duration.Companion.seconds import io.element.android.libraries.core.mimetype.MimeTypes.Any as AnyMimeTypes +@Suppress("LargeClass") @AssistedInject class MessageComposerPresenter( @Assisted private val navigator: MessagesNavigator, @@ -284,7 +286,7 @@ class MessageComposerPresenter( cameraPhotoPicker.launch() } else { pendingEvent = event - cameraPermissionState.eventSink(PermissionsEvents.RequestPermissions) + cameraPermissionState.eventSink(PermissionsEvent.RequestPermissions) } } MessageComposerEvent.PickAttachmentSource.VideoFromCamera -> localCoroutineScope.launch { @@ -293,7 +295,7 @@ class MessageComposerPresenter( cameraVideoPicker.launch() } else { pendingEvent = event - cameraPermissionState.eventSink(PermissionsEvents.RequestPermissions) + cameraPermissionState.eventSink(PermissionsEvent.RequestPermissions) } } MessageComposerEvent.PickAttachmentSource.Location -> { @@ -396,7 +398,9 @@ class MessageComposerPresenter( val currentUserId = room.sessionId suspend fun canSendRoomMention(): Boolean { - val userCanSendAtRoom = room.canUserTriggerRoomNotification(currentUserId).getOrDefault(false) + val userCanSendAtRoom = room.roomPermissions().use(false) { perms -> + perms.canOwnUserTriggerRoomNotification() + } return !room.isDm() && userCanSendAtRoom } 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 57af770d5b2..292a77ba6ab 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,6 +15,7 @@ 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 @@ -100,6 +101,7 @@ 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( @@ -113,8 +115,8 @@ class PinnedMessagesListNode( HapticFeedbackConstants.LONG_PRESS ) context.copyToClipboard( - it.url, - context.getString(CommonStrings.common_copied_to_clipboard) + text = it.url, + toastMessage = toastMessage, ) }, modifier = modifier diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenter.kt index cdc1f85f1d8..b2d2caa7f91 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenter.kt @@ -10,11 +10,10 @@ package io.element.android.features.messages.impl.pinned.list import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.State 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.rememberUpdatedState import androidx.compose.runtime.setValue @@ -35,6 +34,7 @@ import io.element.android.features.messages.impl.timeline.factories.TimelineItem import io.element.android.features.messages.impl.timeline.model.TimelineItem import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionState import io.element.android.features.messages.impl.typing.TypingNotificationState +import io.element.android.features.messages.impl.userEventPermissions import io.element.android.features.roomcall.api.aStandByCallState import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.Presenter @@ -44,11 +44,9 @@ 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.room.JoinedRoom -import io.element.android.libraries.matrix.api.room.powerlevels.canPinUnpin -import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOther -import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOwn +import io.element.android.libraries.matrix.api.room.isDm +import io.element.android.libraries.matrix.api.room.powerlevels.permissionsAsState import io.element.android.libraries.matrix.api.room.roomMembers -import io.element.android.libraries.matrix.ui.room.isDmAsState import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analyticsproviders.api.trackers.captureInteraction @@ -97,31 +95,33 @@ class PinnedMessagesListPresenter( @Composable override fun present(): PinnedMessagesListState { htmlConverterProvider.Update() - val isDm by room.isDmAsState() - - val timelineRoomInfo = remember(isDm) { - TimelineRoomInfo( - isDm = isDm, - name = room.info().name, - // We don't need to compute those values - userHasPermissionToSendMessage = false, - userHasPermissionToSendReaction = false, - // We do not care about the call state here. - roomCallState = aStandByCallState(), - // don't compute this value or the pin icon will be shown - pinnedEventIds = persistentListOf(), - typingNotificationState = TypingNotificationState( - renderTypingNotifications = false, - typingMembers = persistentListOf(), - reserveSpace = false, - ), - predecessorRoom = room.predecessorRoom(), - ) + val roomInfo by room.roomInfoFlow.collectAsState() + val timelineRoomInfo by remember { + derivedStateOf { + TimelineRoomInfo( + isDm = roomInfo.isDm, + name = roomInfo.name, + // We don't need to compute those values + userHasPermissionToSendMessage = false, + userHasPermissionToSendReaction = false, + // We do not care about the call state here. + roomCallState = aStandByCallState(), + // don't compute this value or the pin icon will be shown + pinnedEventIds = persistentListOf(), + typingNotificationState = TypingNotificationState( + renderTypingNotifications = false, + typingMembers = persistentListOf(), + reserveSpace = false, + ), + predecessorRoom = room.predecessorRoom(), + ) + } } val timelineProtectionState = timelineProtectionPresenter.present() val linkState = linkPresenter.present() - val syncUpdateFlow = room.syncUpdateFlow.collectAsState() - val userEventPermissions by userEventPermissions(syncUpdateFlow.value) + val userEventPermissions by room.permissionsAsState(UserEventPermissions.DEFAULT) { perms -> + perms.userEventPermissions() + } val displayThreadSummaries by featureFlagService.isFeatureEnabledFlow(FeatureFlags.Threads).collectAsState(false) @@ -192,19 +192,6 @@ class PinnedMessagesListPresenter( } } - @Composable - private fun userEventPermissions(updateKey: Long): State { - return produceState(UserEventPermissions.DEFAULT, key1 = updateKey) { - value = UserEventPermissions( - canSendMessage = false, - canSendReaction = false, - canRedactOwn = room.canRedactOwn().getOrElse { false }, - canRedactOther = room.canRedactOther().getOrElse { false }, - canPinUnpin = room.canPinUnpin().getOrElse { false }, - ) - } - } - @Composable private fun PinnedMessagesListEffect(onItemsChange: (AsyncData>) -> Unit) { val updatedOnItemsChange by rememberUpdatedState(onItemsChange) 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 909c32b52a6..7cb28068481 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 @@ -25,6 +25,7 @@ import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedInject import io.element.android.features.messages.impl.MessagesNavigator +import io.element.android.features.messages.impl.UserEventPermissions import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailureEvents import io.element.android.features.messages.impl.crypto.sendfailure.resolve.ResolveVerifiedUserSendFailureState import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory @@ -41,12 +42,12 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemBwiScanStateChangedModel import io.element.android.features.messages.impl.timeline.model.virtual.TimelineItemTypingNotificationModel import io.element.android.features.messages.impl.typing.TypingNotificationState +import io.element.android.features.messages.impl.userEventPermissions import io.element.android.features.messages.impl.voicemessages.timeline.RedactedVoiceMessageManager 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.architecture.Presenter -import io.element.android.libraries.core.bool.orFalse import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.di.annotations.SessionCoroutineScope import io.element.android.libraries.featureflag.api.FeatureFlagService @@ -55,20 +56,20 @@ import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.UniqueId import io.element.android.libraries.matrix.api.core.asEventId import io.element.android.libraries.matrix.api.room.JoinedRoom -import io.element.android.libraries.matrix.api.room.MessageEventType import io.element.android.libraries.matrix.api.room.isDm +import io.element.android.libraries.matrix.api.room.powerlevels.permissionsAsState import io.element.android.libraries.matrix.api.room.roomMembers 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.event.MessageShield import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin -import io.element.android.libraries.matrix.ui.room.canSendMessageAsState import io.element.android.libraries.preferences.api.store.SessionPreferencesStore import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.DisplayFirstTimelineItems -import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.NotificationTapOpensTimeline +import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.NotificationToMessage import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.OpenRoom import io.element.android.services.analytics.api.AnalyticsService 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.toImmutableList @@ -105,6 +106,7 @@ class TimelinePresenter( private val analyticsService: AnalyticsService, ) : Presenter { private val tag = "TimelinePresenter" + @AssistedFactory interface Factory { fun create( @@ -138,11 +140,6 @@ class TimelinePresenter( val roomInfo by room.roomInfoFlow.collectAsState() - val syncUpdateFlow = room.syncUpdateFlow.collectAsState() - - val userHasPermissionToSendMessage by room.canSendMessageAsState(type = MessageEventType.RoomMessage, updateKey = syncUpdateFlow.value) - val userHasPermissionToSendReaction by room.canSendMessageAsState(type = MessageEventType.Reaction, updateKey = syncUpdateFlow.value) - val prevMostRecentItemId = rememberSaveable { mutableStateOf(null) } val newEventState = remember { mutableStateOf(NewEventState.None) } @@ -218,7 +215,7 @@ class TimelinePresenter( }.start() is TimelineEvents.OnFocusEventRender -> { // If there was a pending 'notification tap opens timeline' transaction, finish it now we're focused in the required event - analyticsService.finishLongRunningTransaction(NotificationTapOpensTimeline) + analyticsService.finishLongRunningTransaction(NotificationToMessage) focusRequestState.value = focusRequestState.value.onFocusEventRender() } @@ -263,7 +260,7 @@ class TimelinePresenter( combine(timelineController.timelineItems(), room.membersStateFlow) { items, membersState -> val parent = analyticsService.getLongRunningTransaction(DisplayFirstTimelineItems) val transaction = parent?.startChild("timelineItemsFactory.replaceWith", "Processing timeline items") - transaction?.setData("items", items.count()) + transaction?.putExtraData(AnalyticsUserData.TIMELINE_ITEM_COUNT, items.count().toString()) timelineItemsFactory.replaceWith( timelineItems = items, roomMembers = membersState.roomMembers().orEmpty() @@ -295,13 +292,16 @@ class TimelinePresenter( val typingNotificationState = typingNotificationPresenter.present() val roomCallState = roomCallStatePresenter.present() + val userEventPermissions by room.permissionsAsState(UserEventPermissions.DEFAULT) { perms -> + perms.userEventPermissions() + } val timelineRoomInfo by remember(typingNotificationState, roomCallState, roomInfo) { derivedStateOf { TimelineRoomInfo( name = roomInfo.name, - isDm = roomInfo.isDm.orFalse(), - userHasPermissionToSendMessage = userHasPermissionToSendMessage, - userHasPermissionToSendReaction = userHasPermissionToSendReaction, + isDm = roomInfo.isDm, + userHasPermissionToSendMessage = userEventPermissions.canSendMessage, + userHasPermissionToSendReaction = userEventPermissions.canSendReaction, roomCallState = roomCallState, pinnedEventIds = roomInfo.pinnedEventIds, typingNotificationState = typingNotificationState, @@ -504,9 +504,8 @@ class TimelinePresenter( newMostRecentItemId != prevMostRecentItemIdValue if (hasNewEvent) { - val newMostRecentEvent = newMostRecentItem // Scroll to bottom if the new event is from me, even if sent from another device - val fromMe = newMostRecentEvent?.isMine == true + val fromMe = newMostRecentItem.isMine newEventState.value = if (fromMe) { NewEventState.FromMe } else { 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 b3f69f0e2bf..73a15b797fa 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 @@ -123,6 +123,7 @@ fun TimelineView( } val context = LocalContext.current + val toastMessage = stringResource(CommonStrings.common_copied_to_clipboard) val view = LocalView.current // Disable reverse layout when TalkBack is enabled to avoid incorrect ordering issues seen in the current Compose UI version val useReverseLayout = !isTalkbackActive() @@ -136,8 +137,8 @@ fun TimelineView( HapticFeedbackConstants.LONG_PRESS ) context.copyToClipboard( - link.url, - context.getString(CommonStrings.common_copied_to_clipboard) + text = link.url, + toastMessage = toastMessage, ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVoiceView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVoiceView.kt index a3f214f979c..5dbd0c478f1 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVoiceView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVoiceView.kt @@ -9,6 +9,7 @@ package io.element.android.features.messages.impl.timeline.components.event 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 @@ -42,6 +43,7 @@ import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.features.messages.impl.timeline.components.layout.ContentAvoidingLayoutData import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContentProvider +import io.element.android.libraries.designsystem.atomic.atoms.PlaybackSpeedButton import io.element.android.libraries.designsystem.components.media.WaveformPlaybackView import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight @@ -51,7 +53,7 @@ import io.element.android.libraries.designsystem.theme.components.IconButton import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.utils.time.isTalkbackActive -import io.element.android.libraries.voiceplayer.api.VoiceMessageEvents +import io.element.android.libraries.voiceplayer.api.VoiceMessageEvent import io.element.android.libraries.voiceplayer.api.VoiceMessageState import io.element.android.libraries.voiceplayer.api.VoiceMessageStateProvider import kotlinx.coroutines.delay @@ -64,26 +66,26 @@ fun TimelineItemVoiceView( modifier: Modifier = Modifier, ) { fun playPause() { - state.eventSink(VoiceMessageEvents.PlayPause) + state.eventSink(VoiceMessageEvent.PlayPause) } val a11y = stringResource(CommonStrings.common_voice_message) val a11yActionLabel = stringResource( - when (state.button) { - VoiceMessageState.Button.Play -> CommonStrings.a11y_play - VoiceMessageState.Button.Pause -> CommonStrings.a11y_pause - VoiceMessageState.Button.Downloading -> CommonStrings.common_downloading - VoiceMessageState.Button.Retry -> CommonStrings.action_retry - VoiceMessageState.Button.Disabled -> CommonStrings.error_unknown + when (state.buttonType) { + VoiceMessageState.ButtonType.Play -> CommonStrings.a11y_play + VoiceMessageState.ButtonType.Pause -> CommonStrings.a11y_pause + VoiceMessageState.ButtonType.Downloading -> CommonStrings.common_downloading + VoiceMessageState.ButtonType.Retry -> CommonStrings.action_retry + VoiceMessageState.ButtonType.Disabled -> CommonStrings.error_unknown } ) Row( modifier = modifier .clearAndSetSemantics { contentDescription = a11y - if (state.button == VoiceMessageState.Button.Disabled) { + if (state.buttonType == VoiceMessageState.ButtonType.Disabled) { disabled() - } else if (state.button in listOf(VoiceMessageState.Button.Play, VoiceMessageState.Button.Pause)) { + } else if (state.buttonType in listOf(VoiceMessageState.ButtonType.Play, VoiceMessageState.ButtonType.Pause)) { onClick(label = a11yActionLabel) { playPause() true @@ -101,30 +103,41 @@ fun TimelineItemVoiceView( verticalAlignment = Alignment.CenterVertically, ) { if (!isTalkbackActive()) { - when (state.button) { - VoiceMessageState.Button.Play -> PlayButton(onClick = ::playPause) - VoiceMessageState.Button.Pause -> PauseButton(onClick = ::playPause) - VoiceMessageState.Button.Downloading -> ProgressButton() - VoiceMessageState.Button.Retry -> RetryButton(onClick = ::playPause) - VoiceMessageState.Button.Disabled -> PlayButton(onClick = {}, enabled = false) + when (state.buttonType) { + VoiceMessageState.ButtonType.Play -> PlayButton(onClick = ::playPause) + VoiceMessageState.ButtonType.Pause -> PauseButton(onClick = ::playPause) + VoiceMessageState.ButtonType.Downloading -> ProgressButton() + VoiceMessageState.ButtonType.Retry -> RetryButton(onClick = ::playPause) + VoiceMessageState.ButtonType.Disabled -> PlayButton(onClick = {}, enabled = false) } } Spacer(Modifier.width(8.dp)) - Text( - text = state.time, - color = ElementTheme.colors.textSecondary, - style = ElementTheme.typography.fontBodySmMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + PlaybackSpeedButton( + speed = state.playbackSpeed, + onClick = { state.eventSink(VoiceMessageEvent.ChangePlaybackSpeed) }, + ) + Text( + text = state.time, + color = ElementTheme.colors.textSecondary, + style = ElementTheme.typography.fontBodySmMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } Spacer(Modifier.width(8.dp)) WaveformPlaybackView( showCursor = state.showCursor, playbackProgress = state.progress, waveform = content.waveform, - modifier = Modifier.height(34.dp), + modifier = Modifier + .weight(1f) + .height(34.dp), seekEnabled = !isTalkbackActive(), - onSeek = { state.eventSink(VoiceMessageEvents.Seek(it)) }, + onSeek = { state.eventSink(VoiceMessageEvent.Seek(it)) }, ) } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/group/GroupHeaderView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/group/GroupHeaderView.kt index 0c13214a7df..883c591fd29 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/group/GroupHeaderView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/group/GroupHeaderView.kt @@ -49,7 +49,7 @@ fun GroupHeaderView( modifier: Modifier = Modifier ) { // Ignore isHighlighted for now, we need a design decision on it. - val backgroundColor = Color.Companion.Transparent + val backgroundColor = Color.Transparent val shape = RoundedCornerShape(CORNER_RADIUS) Box( 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 8a19b88b263..3b0448ddb35 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 @@ -112,7 +112,7 @@ fun EventDebugInfoView( private fun prettyJSON(maybeJSON: String): String { return try { JSONObject(maybeJSON).toString(2) - } catch (e: JSONException) { + } catch (_: JSONException) { // Prefer not pretty-printing over crashing if the data is not actually JSON maybeJSON } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt index a3c8a1bb262..3d8467729a9 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt @@ -9,7 +9,6 @@ package io.element.android.features.messages.impl.timeline.factories.event import android.text.style.URLSpan -import androidx.core.text.buildSpannedString import androidx.core.text.getSpans import androidx.core.text.toSpannable import dev.zacsweers.metro.Inject @@ -35,11 +34,9 @@ import io.element.android.libraries.matrix.api.permalink.PermalinkParser import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType -import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent -import io.element.android.libraries.matrix.api.timeline.item.event.MessageFormat import io.element.android.libraries.matrix.api.timeline.item.event.NoticeMessageType import io.element.android.libraries.matrix.api.timeline.item.event.OtherMessageType import io.element.android.libraries.matrix.api.timeline.item.event.StickerMessageType @@ -50,6 +47,7 @@ import io.element.android.libraries.matrix.ui.messages.toHtmlDocument import io.element.android.libraries.mediaviewer.api.util.FileExtensionExtractor import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList +import org.jsoup.nodes.Document import kotlin.time.Duration @Inject @@ -60,7 +58,7 @@ class TimelineItemContentMessageFactory( private val permalinkParser: PermalinkParser, private val textPillificationHelper: TextPillificationHelper, ) { - suspend fun create( + fun create( content: MessageContent, senderDisambiguatedDisplayName: String, eventId: EventId?, @@ -68,26 +66,29 @@ class TimelineItemContentMessageFactory( return when (val messageType = content.type) { is EmoteMessageType -> { val emoteBody = "* $senderDisambiguatedDisplayName ${messageType.body.trimEnd()}" - val formattedBody = parseHtml(messageType.formatted, prefix = "* $senderDisambiguatedDisplayName") ?: textPillificationHelper.pillify( - emoteBody - ).safeLinkify() + val dom = messageType.formatted?.toHtmlDocument( + permalinkParser = permalinkParser, + prefix = "* $senderDisambiguatedDisplayName", + ) + val formattedBody = dom?.let(::parseHtml) + ?: textPillificationHelper.pillify(emoteBody).safeLinkify() TimelineItemEmoteContent( body = emoteBody, - htmlDocument = messageType.formatted?.toHtmlDocument( - permalinkParser = permalinkParser, - prefix = "* $senderDisambiguatedDisplayName", - ), + htmlDocument = dom, formattedBody = formattedBody, isEdited = content.isEdited, ) } is ImageMessageType -> { + val dom = messageType.formattedCaption?.toHtmlDocument(permalinkParser = permalinkParser) + val formattedCaption = dom?.let(::parseHtml) + ?: messageType.caption?.withLinks() val aspectRatio = aspectRatioOf(messageType.info?.width, messageType.info?.height) TimelineItemImageContent( filename = messageType.filename, fileSize = messageType.info?.size ?: 0, caption = messageType.caption?.trimEnd(), - formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(), + formattedCaption = formattedCaption, isEdited = content.isEdited, mediaSource = messageType.source, thumbnailSource = messageType.info?.thumbnailSource, @@ -103,12 +104,15 @@ class TimelineItemContentMessageFactory( ) } is StickerMessageType -> { + val dom = messageType.formattedCaption?.toHtmlDocument(permalinkParser = permalinkParser) + val formattedCaption = dom?.let(::parseHtml) + ?: messageType.caption?.withLinks() val aspectRatio = aspectRatioOf(messageType.info?.width, messageType.info?.height) TimelineItemStickerContent( filename = messageType.filename, fileSize = messageType.info?.size ?: 0, caption = messageType.caption?.trimEnd(), - formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(), + formattedCaption = formattedCaption, isEdited = content.isEdited, mediaSource = messageType.source, thumbnailSource = messageType.info?.thumbnailSource, @@ -140,12 +144,15 @@ class TimelineItemContentMessageFactory( } } is VideoMessageType -> { + val dom = messageType.formattedCaption?.toHtmlDocument(permalinkParser = permalinkParser) + val formattedCaption = dom?.let(::parseHtml) + ?: messageType.caption?.withLinks() val aspectRatio = aspectRatioOf(messageType.info?.width, messageType.info?.height) TimelineItemVideoContent( filename = messageType.filename, fileSize = messageType.info?.size ?: 0, caption = messageType.caption?.trimEnd(), - formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(), + formattedCaption = formattedCaption, isEdited = content.isEdited, thumbnailSource = messageType.info?.thumbnailSource, mediaSource = messageType.source, @@ -162,11 +169,14 @@ class TimelineItemContentMessageFactory( ) } is AudioMessageType -> { + val dom = messageType.formattedCaption?.toHtmlDocument(permalinkParser = permalinkParser) + val formattedCaption = dom?.let(::parseHtml) + ?: messageType.caption?.withLinks() TimelineItemAudioContent( filename = messageType.filename, fileSize = messageType.info?.size ?: 0, caption = messageType.caption?.trimEnd(), - formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(), + formattedCaption = formattedCaption, isEdited = content.isEdited, mediaSource = messageType.source, duration = messageType.info?.duration ?: Duration.ZERO, @@ -176,12 +186,15 @@ class TimelineItemContentMessageFactory( ) } is VoiceMessageType -> { + val dom = messageType.formattedCaption?.toHtmlDocument(permalinkParser = permalinkParser) + val formattedCaption = dom?.let(::parseHtml) + ?: messageType.caption?.withLinks() TimelineItemVoiceContent( eventId = eventId, filename = messageType.filename, fileSize = messageType.info?.size ?: 0, caption = messageType.caption?.trimEnd(), - formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(), + formattedCaption = formattedCaption, isEdited = content.isEdited, mediaSource = messageType.source, duration = messageType.info?.duration ?: Duration.ZERO, @@ -192,12 +205,15 @@ class TimelineItemContentMessageFactory( ) } is FileMessageType -> { + val dom = messageType.formattedCaption?.toHtmlDocument(permalinkParser = permalinkParser) + val formattedCaption = dom?.let(::parseHtml) + ?: messageType.caption?.withLinks() val fileExtension = fileExtensionExtractor.extractFromName(messageType.filename) TimelineItemFileContent( filename = messageType.filename, fileSize = messageType.info?.size ?: 0, caption = messageType.caption?.trimEnd(), - formattedCaption = parseHtml(messageType.formattedCaption) ?: messageType.caption?.withLinks(), + formattedCaption = formattedCaption, isEdited = content.isEdited, thumbnailSource = messageType.info?.thumbnailSource, mediaSource = messageType.source, @@ -208,9 +224,9 @@ class TimelineItemContentMessageFactory( } is NoticeMessageType -> { val body = messageType.body.trimEnd() - val formattedBody = parseHtml(messageType.formatted) ?: textPillificationHelper.pillify( - body - ).safeLinkify() + val dom = messageType.formatted?.toHtmlDocument(permalinkParser = permalinkParser) + val formattedBody = dom?.let(::parseHtml) + ?: textPillificationHelper.pillify(body).safeLinkify() val htmlDocument = messageType.formatted?.toHtmlDocument(permalinkParser = permalinkParser) TimelineItemNoticeContent( body = body, @@ -221,12 +237,13 @@ class TimelineItemContentMessageFactory( } is TextMessageType -> { val body = messageType.body.trimEnd() - val formattedBody = parseHtml(messageType.formatted) ?: textPillificationHelper.pillify( - body - ).safeLinkify() + val dom = messageType.formatted?.toHtmlDocument(permalinkParser = permalinkParser) + val formattedBody = dom?.let(::parseHtml) + ?: textPillificationHelper.pillify(body).safeLinkify() + val htmlDocument = messageType.formatted?.toHtmlDocument(permalinkParser = permalinkParser) TimelineItemTextContent( body = body, - htmlDocument = messageType.formatted?.toHtmlDocument(permalinkParser = permalinkParser), + htmlDocument = htmlDocument, formattedBody = formattedBody, isEdited = content.isEdited, ) @@ -253,21 +270,11 @@ class TimelineItemContentMessageFactory( return result?.takeIf { it.isFinite() } } - private fun parseHtml(formattedBody: FormattedBody?, prefix: String? = null): CharSequence? { - if (formattedBody == null || formattedBody.format != MessageFormat.HTML) return null - val result = htmlConverterProvider.provide() - .fromHtmlToSpans(formattedBody.body.trimEnd()) + private fun parseHtml(document: Document): CharSequence? { + return htmlConverterProvider.provide() + .fromDocumentToSpans(document) .let { textPillificationHelper.pillify(it) } .safeLinkify() - return if (prefix != null) { - buildSpannedString { - append(prefix) - append(" ") - append(result) - } - } else { - result - } } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenter.kt index 051ded02ae6..56b0e402d29 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/DefaultVoiceMessageComposerPresenter.kt @@ -33,7 +33,7 @@ import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.annotations.SessionCoroutineScope import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.mediaupload.api.MediaSenderFactory -import io.element.android.libraries.permissions.api.PermissionsEvents +import io.element.android.libraries.permissions.api.PermissionsEvent import io.element.android.libraries.permissions.api.PermissionsPresenter import io.element.android.libraries.textcomposer.model.VoiceMessagePlayerEvent import io.element.android.libraries.textcomposer.model.VoiceMessageRecorderEvent @@ -111,7 +111,7 @@ class DefaultVoiceMessageComposerPresenter( } else -> { Timber.i("Voice message permission needed") - permissionState.eventSink(PermissionsEvents.RequestPermissions) + permissionState.eventSink(PermissionsEvent.RequestPermissions) } } } @@ -176,10 +176,10 @@ class DefaultVoiceMessageComposerPresenter( localCoroutineScope.deleteRecording() } VoiceMessageComposerEvent.DismissPermissionsRationale -> { - permissionState.eventSink(PermissionsEvents.CloseDialog) + permissionState.eventSink(PermissionsEvent.CloseDialog) } VoiceMessageComposerEvent.AcceptPermissionRationale -> { - permissionState.eventSink(PermissionsEvents.OpenSystemSettingAndCloseDialog) + permissionState.eventSink(PermissionsEvent.OpenSystemSettingAndCloseDialog) } is VoiceMessageComposerEvent.LifecycleEvent -> handleLifecycleEvent(event.event) VoiceMessageComposerEvent.DismissSendFailureDialog -> { diff --git a/features/messages/impl/src/main/res/values-hr/translations.xml b/features/messages/impl/src/main/res/values-hr/translations.xml new file mode 100644 index 00000000000..024a9ac1c7b --- /dev/null +++ b/features/messages/impl/src/main/res/values-hr/translations.xml @@ -0,0 +1,84 @@ + + + "Aktivnosti" + "Zastave" + "Hrana i piće" + "Životinje i priroda" + "Objekti" + "Emotikoni i osobe" + "Putovanja i mjesta" + "Nedavni emotikoni" + "Simboli" + "Opisi možda neće biti vidljivi osobama koji se služe starijim aplikacijama." + "Dodirnite za promjenu kvalitete prijenosa videozapisa" + "Datoteka se nije mogla prenijeti." + "Prijenos medija za obradu nije uspio, pokušajte ponovno." + "Prijenos medija nije uspio, pokušajte ponovno." + "Maksimalna dopuštena veličina datoteke je %1$s." + "Datoteka je prevelika za prijenos" + "Stavka %1$d od %2$d" + "Optimiziraj kvalitetu slike" + "Obrada…" + "Blokiraj korisnika" + "Označite ako želite sakriti sve trenutačne i buduće poruke od ovog korisnika" + "Ova poruka bit će prijavljena administratoru vašeg matičnog poslužitelja. On neće moći pročitati nijednu šifriranu poruku." + "Razlog za prijavu ovog sadržaja" + "Kamera" + "Uslikaj" + "Snimi videozapis" + "Privitak" + "Biblioteka fotografija i videozapisa" + "Lokacija" + "Anketa" + "Oblikovanje teksta" + "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?" + "Sami ste u ovom razgovoru" + "Obavijestite cijelu sobu" + "Svi" + "Pošalji ponovno" + "Slanje vaše poruke nije uspjelo" + "Dodaj reakciju" + "Ovo je početak sobe %1$s." + "Ovo je početak ovog razgovora." + "Nepodržani poziv. Pitajte pozivatelja može li se služiti novom aplikacijom %1$s." + "Prikaži manje" + "Poruka je kopirana" + "Nemate dopuštenje za objavljivanje u ovoj sobi" + + "%1$d član reagirao je s %2$s" + "%1$d člana reagirala su s %2$s" + "%1$d članova reagiralo je s %2$s" + + + "Vi i %1$d član reagirali ste s %2$s" + "Vi i %1$d člana reagirali ste s %2$s" + "Vi i %1$d članova reagirali ste s %2$s" + + "Reagirali ste s %1$s" + "Prikaži manje" + "Prikaži više" + "Prikaži sažetak reakcija" + "Novo" + + "%1$d promjena sobe" + "%1$d promjene sobe" + "%1$d promjena sobe" + + "Prijeđi u novu sobu" + "Ova je soba zamijenjena i više nije aktivna" + "Pogledaj stare poruke" + "Ova je soba nastavak druge sobe" + + "%1$s, %2$s i ostalih %3$d" + "%1$s, %2$s i ostalih %3$d" + "%1$s, %2$s i ostalih %3$d" + + + "%1$s tipka" + "%1$s tipka" + "%1$s tipka" + + "%1$s i %2$s" + 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 852e2504b20..bdf84e9eec4 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 @@ -17,6 +17,7 @@ import io.element.android.features.messages.impl.actionlist.ActionListEvents import io.element.android.features.messages.impl.actionlist.ActionListState import io.element.android.features.messages.impl.actionlist.anActionListState import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction +import io.element.android.features.messages.impl.crypto.historyvisible.aHistoryVisibleState import io.element.android.features.messages.impl.crypto.identity.anIdentityChangeState import io.element.android.features.messages.impl.fixtures.aMessageEvent import io.element.android.features.messages.impl.link.aLinkState @@ -63,6 +64,7 @@ import io.element.android.libraries.matrix.api.permalink.PermalinkParser import io.element.android.libraries.matrix.api.room.MessageEventType import io.element.android.libraries.matrix.api.room.RoomMembersState import io.element.android.libraries.matrix.api.room.RoomMembershipState +import io.element.android.libraries.matrix.api.room.StateEventType import io.element.android.libraries.matrix.api.room.tombstone.SuccessorRoom import io.element.android.libraries.matrix.api.timeline.Timeline import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo @@ -85,6 +87,7 @@ 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.test.room.aRoomMember +import io.element.android.libraries.matrix.test.room.powerlevels.FakeRoomPermissions import io.element.android.libraries.matrix.test.timeline.FakeTimeline import io.element.android.libraries.matrix.test.timeline.aTimelineItemDebugInfo import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails @@ -142,11 +145,7 @@ class MessagesPresenterTest { fun `present - check that the room's unread flag is removed`() = runTest { val room = FakeJoinedRoom( baseRoom = FakeBaseRoom( - canUserSendMessageResult = { _, _ -> Result.success(true) }, - canRedactOwnResult = { Result.success(true) }, - canRedactOtherResult = { Result.success(true) }, - canUserJoinCallResult = { Result.success(true) }, - canUserPinUnpinResult = { Result.success(true) }, + roomPermissions = roomPermissions(), markAsReadResult = { lambdaError() } ), typingNoticeResult = { Result.success(Unit) }, @@ -172,11 +171,7 @@ class MessagesPresenterTest { } val room = FakeJoinedRoom( baseRoom = FakeBaseRoom( - canUserSendMessageResult = { _, _ -> Result.success(true) }, - canRedactOwnResult = { Result.success(true) }, - canRedactOtherResult = { Result.success(true) }, - canUserJoinCallResult = { Result.success(true) }, - canUserPinUnpinResult = { Result.success(true) }, + roomPermissions = roomPermissions(), ), liveTimeline = timeline, typingNoticeResult = { Result.success(Unit) }, @@ -222,11 +217,7 @@ class MessagesPresenterTest { } val room = FakeJoinedRoom( baseRoom = FakeBaseRoom( - canUserSendMessageResult = { _, _ -> Result.success(true) }, - canRedactOwnResult = { Result.success(true) }, - canRedactOtherResult = { Result.success(true) }, - canUserJoinCallResult = { Result.success(true) }, - canUserPinUnpinResult = { Result.success(true) }, + roomPermissions = roomPermissions(), ), liveTimeline = timeline, typingNoticeResult = { Result.success(Unit) }, @@ -287,11 +278,7 @@ class MessagesPresenterTest { val event = aMessageEvent() val room = FakeJoinedRoom( baseRoom = FakeBaseRoom( - canUserSendMessageResult = { _, _ -> Result.success(true) }, - canRedactOwnResult = { Result.success(true) }, - canRedactOtherResult = { Result.success(true) }, - canUserJoinCallResult = { Result.success(true) }, - canUserPinUnpinResult = { Result.success(true) }, + roomPermissions = roomPermissions(), eventPermalinkResult = { Result.success("a link") }, ), typingNoticeResult = { Result.success(Unit) }, @@ -513,11 +500,7 @@ class MessagesPresenterTest { val liveTimeline = FakeTimeline() val joinedRoom = FakeJoinedRoom( baseRoom = FakeBaseRoom( - canUserSendMessageResult = { _, _ -> Result.success(true) }, - canRedactOwnResult = { Result.success(true) }, - canRedactOtherResult = { Result.success(true) }, - canUserJoinCallResult = { Result.success(true) }, - canUserPinUnpinResult = { Result.success(true) }, + roomPermissions = roomPermissions(), ), liveTimeline = liveTimeline, typingNoticeResult = { Result.success(Unit) }, @@ -585,11 +568,7 @@ class MessagesPresenterTest { fun `present - shows prompt to reinvite users in DM`() = runTest { val room = FakeJoinedRoom( baseRoom = FakeBaseRoom( - canUserSendMessageResult = { _, _ -> Result.success(true) }, - canRedactOwnResult = { Result.success(true) }, - canRedactOtherResult = { Result.success(true) }, - canUserJoinCallResult = { Result.success(true) }, - canUserPinUnpinResult = { Result.success(true) }, + roomPermissions = roomPermissions(), ).apply { givenRoomInfo(aRoomInfo(isDirect = true, joinedMembersCount = 1, activeMembersCount = 1)) }, @@ -618,11 +597,7 @@ class MessagesPresenterTest { fun `present - doesn't show reinvite prompt in non-direct room`() = runTest { val room = FakeJoinedRoom( baseRoom = FakeBaseRoom( - canUserSendMessageResult = { _, _ -> Result.success(true) }, - canRedactOwnResult = { Result.success(true) }, - canRedactOtherResult = { Result.success(true) }, - canUserJoinCallResult = { Result.success(true) }, - canUserPinUnpinResult = { Result.success(true) }, + roomPermissions = roomPermissions(), ).apply { givenRoomInfo(aRoomInfo(isDirect = false, joinedMembersCount = 1, activeMembersCount = 1)) }, @@ -644,11 +619,7 @@ class MessagesPresenterTest { fun `present - doesn't show reinvite prompt if other party is present`() = runTest { val room = FakeJoinedRoom( baseRoom = FakeBaseRoom( - canUserSendMessageResult = { _, _ -> Result.success(true) }, - canRedactOwnResult = { Result.success(true) }, - canRedactOtherResult = { Result.success(true) }, - canUserJoinCallResult = { Result.success(true) }, - canUserPinUnpinResult = { Result.success(true) }, + roomPermissions = roomPermissions(), ).apply { givenRoomInfo(aRoomInfo(isDirect = true, joinedMembersCount = 2, activeMembersCount = 2)) }, @@ -671,11 +642,7 @@ class MessagesPresenterTest { val inviteUserResult = lambdaRecorder { _: UserId -> Result.success(Unit) } val room = FakeJoinedRoom( baseRoom = FakeBaseRoom( - canUserSendMessageResult = { _, _ -> Result.success(true) }, - canRedactOwnResult = { Result.success(true) }, - canRedactOtherResult = { Result.success(true) }, - canUserJoinCallResult = { Result.success(true) }, - canUserPinUnpinResult = { Result.success(true) }, + roomPermissions = roomPermissions(), ), typingNoticeResult = { Result.success(Unit) }, inviteUserResult = inviteUserResult, @@ -706,11 +673,7 @@ class MessagesPresenterTest { val inviteUserResult = lambdaRecorder { _: UserId -> Result.success(Unit) } val room = FakeJoinedRoom( baseRoom = FakeBaseRoom( - canUserSendMessageResult = { _, _ -> Result.success(true) }, - canRedactOwnResult = { Result.success(true) }, - canRedactOtherResult = { Result.success(true) }, - canUserJoinCallResult = { Result.success(true) }, - canUserPinUnpinResult = { Result.success(true) }, + roomPermissions = roomPermissions(), ), typingNoticeResult = { Result.success(Unit) }, inviteUserResult = inviteUserResult, @@ -743,11 +706,7 @@ class MessagesPresenterTest { fun `present - handle reinviting other user when memberlist is not ready`() = runTest { val room = FakeJoinedRoom( baseRoom = FakeBaseRoom( - canUserSendMessageResult = { _, _ -> Result.success(true) }, - canRedactOwnResult = { Result.success(true) }, - canRedactOtherResult = { Result.success(true) }, - canUserJoinCallResult = { Result.success(true) }, - canUserPinUnpinResult = { Result.success(true) }, + roomPermissions = roomPermissions(), ), typingNoticeResult = { Result.success(Unit) }, ) @@ -768,11 +727,7 @@ class MessagesPresenterTest { fun `present - handle reinviting other user when inviting fails`() = runTest { val room = FakeJoinedRoom( baseRoom = FakeBaseRoom( - canUserSendMessageResult = { _, _ -> Result.success(true) }, - canRedactOwnResult = { Result.success(true) }, - canRedactOtherResult = { Result.success(true) }, - canUserJoinCallResult = { Result.success(true) }, - canUserPinUnpinResult = { Result.success(true) }, + roomPermissions = roomPermissions(), ), typingNoticeResult = { Result.success(Unit) }, inviteUserResult = { Result.failure(RuntimeException("Oops!")) }, @@ -806,17 +761,7 @@ class MessagesPresenterTest { fun `present - permission to post`() = runTest { val room = FakeJoinedRoom( baseRoom = FakeBaseRoom( - canRedactOwnResult = { Result.success(true) }, - canRedactOtherResult = { Result.success(true) }, - canUserJoinCallResult = { Result.success(true) }, - canUserPinUnpinResult = { Result.success(true) }, - canUserSendMessageResult = { _, messageEventType -> - when (messageEventType) { - MessageEventType.RoomMessage -> Result.success(true) - MessageEventType.Reaction -> Result.success(true) - else -> lambdaError() - } - }, + roomPermissions = roomPermissions(), ), typingNoticeResult = { Result.success(Unit) }, ) @@ -832,17 +777,9 @@ class MessagesPresenterTest { fun `present - no permission to post`() = runTest { val room = FakeJoinedRoom( baseRoom = FakeBaseRoom( - canRedactOwnResult = { Result.success(true) }, - canRedactOtherResult = { Result.success(true) }, - canUserJoinCallResult = { Result.success(true) }, - canUserPinUnpinResult = { Result.success(true) }, - canUserSendMessageResult = { _, messageEventType -> - when (messageEventType) { - MessageEventType.RoomMessage -> Result.success(false) - MessageEventType.Reaction -> Result.success(false) - else -> lambdaError() - } - }, + roomPermissions = roomPermissions( + canSendMessage = false + ), ), typingNoticeResult = { Result.success(Unit) }, ) @@ -858,11 +795,9 @@ class MessagesPresenterTest { fun `present - permission to redact own`() = runTest { val joinedRoom = FakeJoinedRoom( baseRoom = FakeBaseRoom( - canRedactOwnResult = { Result.success(true) }, - canUserSendMessageResult = { _, _ -> Result.success(true) }, - canRedactOtherResult = { Result.success(false) }, - canUserJoinCallResult = { Result.success(true) }, - canUserPinUnpinResult = { Result.success(true) }, + roomPermissions = roomPermissions( + canRedactOther = false + ), ), typingNoticeResult = { Result.success(Unit) }, ) @@ -879,11 +814,9 @@ class MessagesPresenterTest { fun `present - permission to redact other`() = runTest { val joinedRoom = FakeJoinedRoom( baseRoom = FakeBaseRoom( - canRedactOtherResult = { Result.success(true) }, - canUserSendMessageResult = { _, _ -> Result.success(true) }, - canRedactOwnResult = { Result.success(false) }, - canUserJoinCallResult = { Result.success(true) }, - canUserPinUnpinResult = { Result.success(true) }, + roomPermissions = roomPermissions( + canRedactOwn = false + ), ), typingNoticeResult = { Result.success(Unit) }, ) @@ -928,11 +861,7 @@ class MessagesPresenterTest { val timeline = FakeTimeline() val room = FakeJoinedRoom( baseRoom = FakeBaseRoom( - canUserSendMessageResult = { _, _ -> Result.success(true) }, - canRedactOwnResult = { Result.success(true) }, - canRedactOtherResult = { Result.success(true) }, - canUserJoinCallResult = { Result.success(true) }, - canUserPinUnpinResult = { Result.success(true) }, + roomPermissions = roomPermissions(), ), liveTimeline = timeline, typingNoticeResult = { Result.success(Unit) }, @@ -972,11 +901,7 @@ class MessagesPresenterTest { val analyticsService = FakeAnalyticsService() val room = FakeJoinedRoom( baseRoom = FakeBaseRoom( - canUserSendMessageResult = { _, _ -> Result.success(true) }, - canRedactOwnResult = { Result.success(true) }, - canRedactOtherResult = { Result.success(true) }, - canUserJoinCallResult = { Result.success(true) }, - canUserPinUnpinResult = { Result.success(true) }, + roomPermissions = roomPermissions(), ), liveTimeline = timeline, typingNoticeResult = { Result.success(Unit) }, @@ -1073,11 +998,7 @@ class MessagesPresenterTest { } val room = FakeJoinedRoom( baseRoom = FakeBaseRoom( - canUserSendMessageResult = { _, _ -> Result.success(true) }, - canRedactOwnResult = { Result.success(true) }, - canRedactOtherResult = { Result.success(true) }, - canUserJoinCallResult = { Result.success(true) }, - canUserPinUnpinResult = { Result.success(true) }, + roomPermissions = roomPermissions(), ), liveTimeline = timeline, typingNoticeResult = { Result.success(Unit) }, @@ -1114,11 +1035,7 @@ class MessagesPresenterTest { val successorReason = "This room has been moved to a new location" val room = FakeJoinedRoom( baseRoom = FakeBaseRoom( - canUserSendMessageResult = { _, _ -> Result.success(true) }, - canRedactOwnResult = { Result.success(true) }, - canRedactOtherResult = { Result.success(true) }, - canUserJoinCallResult = { Result.success(true) }, - canUserPinUnpinResult = { Result.success(true) }, + roomPermissions = roomPermissions(), initialRoomInfo = aRoomInfo( successorRoom = SuccessorRoom( roomId = successorRoomId, @@ -1142,11 +1059,7 @@ class MessagesPresenterTest { fun `present - room without successor room has null successor info in state`() = runTest { val room = FakeJoinedRoom( baseRoom = FakeBaseRoom( - canUserSendMessageResult = { _, _ -> Result.success(true) }, - canRedactOwnResult = { Result.success(true) }, - canRedactOtherResult = { Result.success(true) }, - canUserJoinCallResult = { Result.success(true) }, - canUserPinUnpinResult = { Result.success(true) }, + roomPermissions = roomPermissions(), initialRoomInfo = aRoomInfo(successorRoom = null) ), typingNoticeResult = { Result.success(Unit) }, @@ -1164,11 +1077,13 @@ class MessagesPresenterTest { val room = FakeJoinedRoom( baseRoom = FakeBaseRoom( sessionId = A_SESSION_ID, - canUserSendMessageResult = { _, _ -> Result.success(true) }, - canRedactOwnResult = { Result.success(true) }, - canRedactOtherResult = { Result.success(true) }, - canUserJoinCallResult = { Result.success(true) }, - canUserPinUnpinResult = { Result.success(true) }, + roomPermissions = FakeRoomPermissions( + canSendState = { true }, + canSendMessage = { true }, + canRedactOther = true, + canRedactOwn = true, + canPinUnpin = true, + ), initialRoomInfo = aRoomInfo(isDirect = true, isEncrypted = true) ).apply { givenRoomMembersState(RoomMembersState.Ready(persistentListOf(aRoomMember(userId = A_SESSION_ID), aRoomMember(userId = A_USER_ID_2)))) @@ -1311,16 +1226,44 @@ class MessagesPresenterTest { } } + private fun roomPermissions( + canStartCall: Boolean = true, + canRedactOther: Boolean = true, + canRedactOwn: Boolean = true, + canSendMessage: Boolean = true, + canSendReaction: Boolean = true, + canPinUnpin: Boolean = true, + ) = FakeRoomPermissions( + canSendState = { type -> + when (type) { + StateEventType.CallMember -> canStartCall + else -> lambdaError() + } + }, + canSendMessage = { type -> + when (type) { + MessageEventType.RoomMessage -> canSendMessage + MessageEventType.Reaction -> canSendReaction + else -> lambdaError() + } + }, + canRedactOther = canRedactOther, + canRedactOwn = canRedactOwn, + canPinUnpin = canPinUnpin, + ) + private fun TestScope.createMessagesPresenter( coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(), timeline: Timeline = FakeTimeline(), joinedRoom: FakeJoinedRoom = FakeJoinedRoom( baseRoom = FakeBaseRoom( - canUserSendMessageResult = { _, _ -> Result.success(true) }, - canRedactOwnResult = { Result.success(true) }, - canRedactOtherResult = { Result.success(true) }, - canUserJoinCallResult = { Result.success(true) }, - canUserPinUnpinResult = { Result.success(true) }, + roomPermissions = FakeRoomPermissions( + canSendState = { true }, + canSendMessage = { true }, + canRedactOther = true, + canRedactOwn = true, + canPinUnpin = true, + ), ).apply { givenRoomInfo(aRoomInfo(id = roomId, name = "")) }, @@ -1355,6 +1298,7 @@ class MessagesPresenterTest { timelinePresenter = { aTimelineState(eventSink = timelineEventSink) }, timelineProtectionPresenter = { aTimelineProtectionState() }, identityChangeStatePresenter = { anIdentityChangeState() }, + historyVisibleStatePresenter = { aHistoryVisibleState() }, linkPresenter = { aLinkState() }, actionListPresenter = { anActionListState(eventSink = actionListEventSink) }, customReactionPresenter = { aCustomReactionState() }, diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/AttachmentsPreviewPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/AttachmentsPreviewPresenterTest.kt index 5263182fa0a..fe462fc9881 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/AttachmentsPreviewPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/AttachmentsPreviewPresenterTest.kt @@ -44,6 +44,7 @@ import io.element.android.libraries.mediaupload.api.MediaPreProcessor import io.element.android.libraries.mediaupload.api.MediaSenderFactory import io.element.android.libraries.mediaupload.api.MediaUploadInfo import io.element.android.libraries.mediaupload.impl.DefaultMediaSender +import io.element.android.libraries.mediaupload.test.FakeMediaOptimizationConfigProvider import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor import io.element.android.libraries.mediaviewer.api.aVideoMediaInfo import io.element.android.libraries.mediaviewer.api.anApkMediaInfo @@ -598,6 +599,7 @@ class AttachmentsPreviewPresenterTest { ) } ), + mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(), ): AttachmentsPreviewPresenter { return AttachmentsPreviewPresenter( attachment = aMediaAttachment(localMedia), @@ -619,6 +621,7 @@ class AttachmentsPreviewPresenterTest { mediaOptimizationSelectorPresenterFactory = mediaOptimizationSelectorPresenterFactory, timelineMode = timelineMode, inReplyToEventId = null, + mediaOptimizationConfigProvider = mediaOptimizationConfigProvider, ) } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/video/DefaultMediaOptimizationSelectorPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/video/DefaultMediaOptimizationSelectorPresenterTest.kt index aad3e0b8850..96cc93ea3dd 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/video/DefaultMediaOptimizationSelectorPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/attachments/video/DefaultMediaOptimizationSelectorPresenterTest.kt @@ -22,12 +22,12 @@ import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.matrix.test.AN_EXCEPTION import io.element.android.libraries.mediaupload.api.MaxUploadSizeProvider +import io.element.android.libraries.mediaupload.test.FakeMediaOptimizationConfigProvider import io.element.android.libraries.mediaviewer.api.aVideoMediaInfo import io.element.android.libraries.mediaviewer.api.anImageMediaInfo import io.element.android.libraries.mediaviewer.api.local.LocalMedia import io.element.android.libraries.mediaviewer.test.viewer.aLocalMedia import io.element.android.libraries.preferences.api.store.VideoCompressionPreset -import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore import io.element.android.tests.testutils.WarmUpRule import io.mockk.mockk import kotlinx.coroutines.test.runTest @@ -233,16 +233,16 @@ class DefaultMediaOptimizationSelectorPresenterTest { private fun createDefaultMediaOptimizationSelectorPresenter( localMedia: LocalMedia = aLocalMedia(mockMediaUrl, aVideoMediaInfo()), maxUploadSizeProvider: MaxUploadSizeProvider = MaxUploadSizeProvider { Result.success(1_000L) }, - sessionPreferencesStore: InMemorySessionPreferencesStore = InMemorySessionPreferencesStore(), featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService(mapOf(FeatureFlags.SelectableMediaQuality.key to true)), mediaExtractorFactory: FakeVideoMetadataExtractorFactory = FakeVideoMetadataExtractorFactory(), + mediaOptimizationConfigProvider: FakeMediaOptimizationConfigProvider = FakeMediaOptimizationConfigProvider(), ): DefaultMediaOptimizationSelectorPresenter { return DefaultMediaOptimizationSelectorPresenter( localMedia = localMedia, maxUploadSizeProvider = maxUploadSizeProvider, - sessionPreferencesStore = sessionPreferencesStore, featureFlagService = featureFlagService, mediaExtractorFactory = mediaExtractorFactory, + mediaOptimizationConfigProvider = mediaOptimizationConfigProvider, ) } } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/FakeHistoryVisibleAcknowledgementRepository.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/FakeHistoryVisibleAcknowledgementRepository.kt new file mode 100644 index 00000000000..faf21720faa --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/FakeHistoryVisibleAcknowledgementRepository.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2025 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.crypto.historyvisible + +import io.element.android.libraries.matrix.api.core.RoomId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow + +class FakeHistoryVisibleAcknowledgementRepository( + private val acknowledgements: MutableMap> = mutableMapOf() +) : HistoryVisibleAcknowledgementRepository { + override fun hasAcknowledged(roomId: RoomId): Flow { + return acknowledgements.getOrPut(roomId) { + MutableStateFlow(false) + } + } + + override suspend fun setAcknowledged(roomId: RoomId, value: Boolean) { + val flow = acknowledgements.getOrPut(roomId) { + MutableStateFlow(value) + } + flow.emit(value) + } + + companion object { + /** + * Create the repository with a pre-existing entry. + */ + fun withRoom(roomId: RoomId, acknowledged: Boolean = false): FakeHistoryVisibleAcknowledgementRepository { + return FakeHistoryVisibleAcknowledgementRepository( + mutableMapOf( + roomId to MutableStateFlow(acknowledged) + ) + ) + } + } +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/HistoryVisibleStatePresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/HistoryVisibleStatePresenterTest.kt new file mode 100644 index 00000000000..afa1992cacc --- /dev/null +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/crypto/historyvisible/HistoryVisibleStatePresenterTest.kt @@ -0,0 +1,137 @@ +/* + * Copyright (c) 2025 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.crypto.historyvisible + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.featureflag.test.FakeFeatureFlagService +import io.element.android.libraries.matrix.api.room.JoinedRoom +import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibility +import io.element.android.libraries.matrix.test.room.FakeJoinedRoom +import io.element.android.libraries.matrix.test.room.aRoomInfo +import io.element.android.tests.testutils.WarmUpRule +import io.element.android.tests.testutils.awaitLastSequentialItem +import io.element.android.tests.testutils.test +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test + +class HistoryVisibleStatePresenterTest { + @get:Rule + val warmUpRule = WarmUpRule() + + @Test + fun `present - not visible if feature disabled`() = runTest { + val room = FakeJoinedRoom() + room.givenRoomInfo(aRoomInfo(historyVisibility = RoomHistoryVisibility.Shared, isEncrypted = true)) + val presenter = createHistoryVisibleStatePresenter(room, enabled = false, acknowledged = false) + presenter.test { + assertThat(awaitLastSequentialItem().showAlert).isFalse() + } + } + + @Test + fun `present - initial with room shared, unencrypted`() = runTest { + val room = FakeJoinedRoom() + room.givenRoomInfo(aRoomInfo(historyVisibility = RoomHistoryVisibility.Shared, isEncrypted = false)) + val presenter = createHistoryVisibleStatePresenter(room) + presenter.test { + assertThat(awaitLastSequentialItem().showAlert).isFalse() + } + } + + @Test + fun `present - initial with room joined, encrypted`() = runTest { + val room = FakeJoinedRoom() + room.givenRoomInfo(aRoomInfo(historyVisibility = RoomHistoryVisibility.Joined, isEncrypted = true)) + val presenter = createHistoryVisibleStatePresenter(room) + presenter.test { + assertThat(awaitLastSequentialItem().showAlert).isFalse() + } + } + + @Test + fun `present - initial with room invited, encrypted`() = runTest { + val room = FakeJoinedRoom() + room.givenRoomInfo(aRoomInfo(historyVisibility = RoomHistoryVisibility.Invited, isEncrypted = true)) + val presenter = createHistoryVisibleStatePresenter(room) + presenter.test { + assertThat(awaitLastSequentialItem().showAlert).isFalse() + } + } + + @Test + fun `present - initial with room shared, encrypted, unacknowledged`() = runTest { + val room = FakeJoinedRoom() + room.givenRoomInfo(aRoomInfo(historyVisibility = RoomHistoryVisibility.Shared, isEncrypted = true)) + val presenter = createHistoryVisibleStatePresenter(room, acknowledged = false) + presenter.test { + val initialState = awaitItem() + assertThat(initialState.showAlert).isFalse() + val nextState = awaitItem() + assertThat(nextState.showAlert).isTrue() + } + } + + @Test + fun `present - initial with room shared, encrypted, acknowledged`() = runTest { + val room = FakeJoinedRoom() + room.givenRoomInfo(aRoomInfo(historyVisibility = RoomHistoryVisibility.Shared, isEncrypted = true)) + val presenter = createHistoryVisibleStatePresenter(room, acknowledged = true) + presenter.test { + assertThat(awaitLastSequentialItem().showAlert).isFalse() + } + } + + @Test + fun `present - transition from joined + unencrypted, to shared + encrypted`() = runTest { + val room = FakeJoinedRoom() + val featureFlagService = FakeFeatureFlagService(mapOf(FeatureFlags.EnableKeyShareOnInvite.key to true)) + val repository = FakeHistoryVisibleAcknowledgementRepository() + + room.givenRoomInfo(aRoomInfo(historyVisibility = RoomHistoryVisibility.Joined, isEncrypted = false)) + + val presenter = HistoryVisibleStatePresenter( + featureFlagService, + repository, + room, + ) + + presenter.test { + // emitted by the feature flag service(?) + assertThat(awaitItem().showAlert).isFalse() + + // emitted state from room info assignment + assertThat(awaitItem().showAlert).isFalse() + + // room is marked as encrypted + room.givenRoomInfo(aRoomInfo(historyVisibility = RoomHistoryVisibility.Joined, isEncrypted = true)) + assertThat(awaitItem().showAlert).isFalse() + + // room history visibility is changed to shared + room.givenRoomInfo(aRoomInfo(historyVisibility = RoomHistoryVisibility.Shared, isEncrypted = true)) + assertThat(awaitItem().showAlert).isTrue() + + // alert is acknowledged + repository.setAcknowledged(room.roomId, true) + assertThat(awaitItem().showAlert).isFalse() + } + } + + private fun createHistoryVisibleStatePresenter( + room: JoinedRoom = FakeJoinedRoom(), + enabled: Boolean = true, + acknowledged: Boolean = false + ): HistoryVisibleStatePresenter { + return HistoryVisibleStatePresenter( + room = room, + featureFlagService = FakeFeatureFlagService(mapOf("feature.enableKeyShareOnInvite" to enabled)), + repository = FakeHistoryVisibleAcknowledgementRepository.withRoom(room.roomId, acknowledged) + ) + } +} 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 4a6e7771163..2feafbc9e55 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 @@ -69,6 +69,7 @@ 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.test.room.aRoomMember +import io.element.android.libraries.matrix.test.room.powerlevels.FakeRoomPermissions import io.element.android.libraries.matrix.test.timeline.FakeTimeline import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails import io.element.android.libraries.mediapickers.api.PickerProvider @@ -991,9 +992,12 @@ class MessageComposerPresenterTest { val invitedUser = aRoomMember(userId = A_USER_ID_3, membership = RoomMembershipState.INVITE) val bob = aRoomMember(userId = A_USER_ID_2, membership = RoomMembershipState.JOIN) val david = aRoomMember(userId = A_USER_ID_4, displayName = "Dave", membership = RoomMembershipState.JOIN) - var canUserTriggerRoomNotificationResult = true val room = FakeJoinedRoom( - baseRoom = FakeBaseRoom(canUserTriggerRoomNotificationResult = { Result.success(canUserTriggerRoomNotificationResult) }), + baseRoom = FakeBaseRoom( + roomPermissions = FakeRoomPermissions( + canTriggerRoomNotification = true, + ) + ), typingNoticeResult = { Result.success(Unit) } ).apply { givenRoomMembersState( @@ -1033,10 +1037,38 @@ class MessageComposerPresenterTest { // If the suggestion isn't a mention, no suggestions are returned initialState.eventSink(MessageComposerEvent.SuggestionReceived(Suggestion(0, 0, SuggestionType.Command, ""))) assertThat(awaitItem().suggestions).isEmpty() + } + } - // If user has no permission to send `@room` mentions, `RoomMemberSuggestion.Room` is not returned - canUserTriggerRoomNotificationResult = false + @Test + fun `present - room mention suggestions no permission`() = runTest { + val currentUser = aRoomMember(userId = A_USER_ID, membership = RoomMembershipState.JOIN) + val invitedUser = aRoomMember(userId = A_USER_ID_3, membership = RoomMembershipState.INVITE) + val bob = aRoomMember(userId = A_USER_ID_2, membership = RoomMembershipState.JOIN) + val david = aRoomMember(userId = A_USER_ID_4, displayName = "Dave", membership = RoomMembershipState.JOIN) + val room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + roomPermissions = FakeRoomPermissions( + canTriggerRoomNotification = false, + ) + ), + typingNoticeResult = { Result.success(Unit) } + ).apply { + givenRoomMembersState( + RoomMembersState.Ready( + persistentListOf(currentUser, invitedUser, bob, david), + ) + ) + givenRoomInfo(aRoomInfo(isDirect = false)) + } + val presenter = createPresenter(room) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitItem() + // An empty suggestion returns the joined members that are not the current user, but not the room initialState.eventSink(MessageComposerEvent.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, ""))) + skipItems(1) assertThat(awaitItem().suggestions) .containsExactly(ResolvedSuggestion.Member(bob), ResolvedSuggestion.Member(david)) } @@ -1049,7 +1081,9 @@ class MessageComposerPresenterTest { val bob = aRoomMember(userId = A_USER_ID_2, membership = RoomMembershipState.JOIN) val david = aRoomMember(userId = A_USER_ID_4, displayName = "Dave", membership = RoomMembershipState.JOIN) val room = FakeJoinedRoom( - baseRoom = FakeBaseRoom(canUserTriggerRoomNotificationResult = { Result.success(true) }), + baseRoom = FakeBaseRoom( + roomPermissions = FakeRoomPermissions(canTriggerRoomNotification = true), + ), typingNoticeResult = { Result.success(Unit) } ).apply { givenRoomMembersState( @@ -1069,7 +1103,6 @@ class MessageComposerPresenterTest { presenter.present() }.test { val initialState = awaitItem() - // An empty suggestion returns the joined members that are not the current user, but not the room initialState.eventSink(MessageComposerEvent.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, ""))) skipItems(1) diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenterTest.kt index 8807951d9de..351f841fcf5 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/pinned/list/PinnedMessagesListPresenterTest.kt @@ -31,6 +31,7 @@ import io.element.android.libraries.matrix.test.A_UNIQUE_ID 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.test.room.powerlevels.FakeRoomPermissions import io.element.android.libraries.matrix.test.sync.FakeSyncService import io.element.android.libraries.matrix.test.timeline.FakeTimeline import io.element.android.libraries.matrix.test.timeline.aMessageContent @@ -55,9 +56,7 @@ class PinnedMessagesListPresenterTest { fun `present - initial state`() = runTest { val room = FakeJoinedRoom( baseRoom = FakeBaseRoom( - canRedactOwnResult = { Result.success(true) }, - canRedactOtherResult = { Result.success(true) }, - canUserPinUnpinResult = { Result.success(true) }, + roomPermissions = roomPermissions(), ).apply { givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID))) } @@ -74,9 +73,7 @@ class PinnedMessagesListPresenterTest { fun `present - timeline failure state`() = runTest { val room = FakeJoinedRoom( baseRoom = FakeBaseRoom( - canRedactOwnResult = { Result.success(true) }, - canRedactOtherResult = { Result.success(true) }, - canUserPinUnpinResult = { Result.success(true) }, + roomPermissions = roomPermissions(), ).apply { givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID))) }, @@ -95,9 +92,7 @@ class PinnedMessagesListPresenterTest { fun `present - empty state`() = runTest { val room = FakeJoinedRoom( baseRoom = FakeBaseRoom( - canRedactOwnResult = { Result.success(true) }, - canRedactOtherResult = { Result.success(true) }, - canUserPinUnpinResult = { Result.success(true) }, + roomPermissions = roomPermissions(), ).apply { givenRoomInfo(aRoomInfo(pinnedEventIds = listOf())) }, @@ -117,9 +112,7 @@ class PinnedMessagesListPresenterTest { val pinnedEventsTimeline = createPinnedMessagesTimeline() val room = FakeJoinedRoom( baseRoom = FakeBaseRoom( - canRedactOwnResult = { Result.success(true) }, - canRedactOtherResult = { Result.success(true) }, - canUserPinUnpinResult = { Result.success(true) }, + roomPermissions = roomPermissions(), ).apply { givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID))) }, @@ -146,9 +139,7 @@ class PinnedMessagesListPresenterTest { val analyticsService = FakeAnalyticsService() val room = FakeJoinedRoom( baseRoom = FakeBaseRoom( - canRedactOwnResult = { Result.success(true) }, - canRedactOtherResult = { Result.success(true) }, - canUserPinUnpinResult = { Result.success(true) }, + roomPermissions = roomPermissions(), ).apply { givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID))) }, @@ -194,9 +185,7 @@ class PinnedMessagesListPresenterTest { val pinnedEventsTimeline = createPinnedMessagesTimeline() val room = FakeJoinedRoom( baseRoom = FakeBaseRoom( - canRedactOwnResult = { Result.success(true) }, - canRedactOtherResult = { Result.success(true) }, - canUserPinUnpinResult = { Result.success(true) }, + roomPermissions = roomPermissions(), ).apply { givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID))) }, @@ -225,9 +214,7 @@ class PinnedMessagesListPresenterTest { val pinnedEventsTimeline = createPinnedMessagesTimeline() val room = FakeJoinedRoom( baseRoom = FakeBaseRoom( - canRedactOwnResult = { Result.success(true) }, - canRedactOtherResult = { Result.success(true) }, - canUserPinUnpinResult = { Result.success(true) }, + roomPermissions = roomPermissions(), ).apply { givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID))) }, @@ -256,9 +243,7 @@ class PinnedMessagesListPresenterTest { val pinnedEventsTimeline = createPinnedMessagesTimeline() val room = FakeJoinedRoom( baseRoom = FakeBaseRoom( - canRedactOwnResult = { Result.success(true) }, - canRedactOtherResult = { Result.success(true) }, - canUserPinUnpinResult = { Result.success(true) }, + roomPermissions = roomPermissions(), ).apply { givenRoomInfo(aRoomInfo(pinnedEventIds = listOf(AN_EVENT_ID))) }, @@ -295,6 +280,16 @@ class PinnedMessagesListPresenterTest { ) } + private fun roomPermissions( + canRedactOther: Boolean = true, + canRedactOwn: Boolean = true, + canPinUnpin: Boolean = true, + ) = FakeRoomPermissions( + canRedactOther = canRedactOther, + canRedactOwn = canRedactOwn, + canPinUnpin = canPinUnpin, + ) + private fun TestScope.createPinnedMessagesListPresenter( navigator: PinnedMessagesListNavigator = FakePinnedMessagesListNavigator(), room: JoinedRoom = FakeJoinedRoom(), 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 f30c41b53b5..10d92033808 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 @@ -8,8 +8,6 @@ package io.element.android.features.messages.impl.timeline -import app.cash.molecule.RecompositionMode -import app.cash.molecule.moleculeFlow import app.cash.turbine.ReceiveTurbine import app.cash.turbine.test import com.google.common.truth.Truth.assertThat @@ -36,6 +34,7 @@ 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.core.UniqueId import io.element.android.libraries.matrix.api.core.asEventId +import io.element.android.libraries.matrix.api.room.MessageEventType import io.element.android.libraries.matrix.api.room.RoomMembersState import io.element.android.libraries.matrix.api.room.tombstone.PredecessorRoom import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem @@ -57,6 +56,7 @@ import io.element.android.libraries.matrix.test.A_USER_ID 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.aRoomMember +import io.element.android.libraries.matrix.test.room.powerlevels.FakeRoomPermissions import io.element.android.libraries.matrix.test.timeline.FakeTimeline import io.element.android.libraries.matrix.test.timeline.aMessageContent import io.element.android.libraries.matrix.test.timeline.anEventTimelineItem @@ -68,6 +68,7 @@ import io.element.android.tests.testutils.awaitLastSequentialItem import io.element.android.tests.testutils.consumeItemsUntilPredicate import io.element.android.tests.testutils.lambda.any 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 @@ -125,9 +126,7 @@ class TimelinePresenterTest { @Test fun `present - initial state`() = runTest { val presenter = createTimelinePresenter() - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { + presenter.test { val initialState = awaitFirstItem() assertThat(initialState.timelineItems).isEmpty() assertThat(initialState.isLive).isTrue() @@ -146,9 +145,7 @@ class TimelinePresenterTest { this.paginateLambda = paginateLambda } val presenter = createTimelinePresenter(timeline = timeline) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { + presenter.test { val initialState = awaitItem() initialState.eventSink.invoke(TimelineEvents.LoadMore(Timeline.PaginationDirection.BACKWARDS)) initialState.eventSink.invoke(TimelineEvents.LoadMore(Timeline.PaginationDirection.FORWARDS)) @@ -194,9 +191,6 @@ class TimelinePresenterTest { ) val room = FakeJoinedRoom( liveTimeline = timeline, - baseRoom = FakeBaseRoom( - canUserSendMessageResult = { _, _ -> Result.success(true) }, - ) ) val sessionPreferencesStore = InMemorySessionPreferencesStore(isSendPublicReadReceiptsEnabled = isSendPublicReadReceiptsEnabled) val presenter = createTimelinePresenter( @@ -204,9 +198,7 @@ class TimelinePresenterTest { room = room, sessionPreferencesStore = sessionPreferencesStore, ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { + presenter.test { val initialState = awaitFirstItem() initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(0)) runCurrent() @@ -239,9 +231,7 @@ class TimelinePresenterTest { this.sendReadReceiptLambda = sendReadReceiptsLambda } val presenter = createTimelinePresenter(timeline) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { + presenter.test { skipItems(1) awaitItem().run { eventSink.invoke(TimelineEvents.OnScrollFinished(1)) @@ -280,9 +270,7 @@ class TimelinePresenterTest { timeline = timeline, sessionPreferencesStore = sessionPreferencesStore, ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { + presenter.test { skipItems(1) awaitItem().run { eventSink.invoke(TimelineEvents.OnScrollFinished(0)) @@ -318,9 +306,7 @@ class TimelinePresenterTest { this.sendReadReceiptLambda = sendReadReceiptsLambda } val presenter = createTimelinePresenter(timeline) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { + presenter.test { skipItems(1) awaitItem().run { eventSink.invoke(TimelineEvents.OnScrollFinished(1)) @@ -348,9 +334,7 @@ class TimelinePresenterTest { this.sendReadReceiptLambda = sendReadReceiptsLambda } val presenter = createTimelinePresenter(timeline) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { + presenter.test { skipItems(1) val initialState = awaitFirstItem() initialState.eventSink.invoke(TimelineEvents.OnScrollFinished(1)) @@ -367,9 +351,7 @@ class TimelinePresenterTest { markAsReadResult = { Result.success(Unit) }, ) val presenter = createTimelinePresenter(timeline) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { + presenter.test { val initialState = awaitFirstItem() assertThat(initialState.newEventState).isEqualTo(NewEventState.None) assertThat(initialState.timelineItems.size).isEqualTo(0) @@ -418,9 +400,7 @@ class TimelinePresenterTest { timelineItems = timelineItems, ) val presenter = createTimelinePresenter(timeline) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { + presenter.test { val initialState = awaitFirstItem() assertThat(initialState.newEventState).isEqualTo(NewEventState.None) assertThat(initialState.timelineItems.size).isEqualTo(0) @@ -474,9 +454,7 @@ class TimelinePresenterTest { val presenter = createTimelinePresenter( sendPollResponseAction = sendPollResponseAction, ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { + presenter.test { val initialState = awaitFirstItem() initialState.eventSink.invoke(TimelineEvents.SelectPollAnswer(AN_EVENT_ID, "anAnswerId")) } @@ -490,9 +468,7 @@ class TimelinePresenterTest { val presenter = createTimelinePresenter( endPollAction = endPollAction, ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { + presenter.test { val initialState = awaitFirstItem() initialState.eventSink.invoke(TimelineEvents.EndPoll(AN_EVENT_ID)) } @@ -509,9 +485,7 @@ class TimelinePresenterTest { val presenter = createTimelinePresenter( messagesNavigator = navigator, ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { + presenter.test { awaitFirstItem().eventSink(TimelineEvents.EditPoll(AN_EVENT_ID)) onEditPollClickLambda.assertions().isCalledOnce().with(value(AN_EVENT_ID)) } @@ -528,9 +502,7 @@ class TimelinePresenterTest { ), redactedVoiceMessageManager = redactedVoiceMessageManager, ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { + presenter.test { assertThat(redactedVoiceMessageManager.invocations.size).isEqualTo(0) skipItems(2) assertThat(redactedVoiceMessageManager.invocations.size).isEqualTo(1) @@ -556,16 +528,14 @@ class TimelinePresenterTest { liveTimeline = liveTimeline, createTimelineResult = { Result.success(detachedTimeline) }, baseRoom = FakeBaseRoom( - canUserSendMessageResult = { _, _ -> Result.success(true) }, + roomPermissions = roomPermissions(), threadRootIdForEventResult = { _ -> Result.success(null) }, ), ) val presenter = createTimelinePresenter( room = room, ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { + presenter.test { val initialState = awaitFirstItem() initialState.eventSink.invoke(TimelineEvents.FocusOnEvent(AN_EVENT_ID)) awaitItem().also { state -> @@ -607,15 +577,13 @@ class TimelinePresenterTest { ) ), baseRoom = FakeBaseRoom( - canUserSendMessageResult = { _, _ -> Result.success(true) }, + roomPermissions = roomPermissions(), threadRootIdForEventResult = { Result.success(null) }, ), ), timelineItemIndexer = timelineItemIndexer, ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { + presenter.test { val initialState = awaitFirstItem() advanceUntilIdle() @@ -647,14 +615,12 @@ class TimelinePresenterTest { ), createTimelineResult = { Result.failure(RuntimeException("An error")) }, baseRoom = FakeBaseRoom( - canUserSendMessageResult = { _, _ -> Result.success(true) }, + roomPermissions = roomPermissions(), threadRootIdForEventResult = { _ -> Result.success(null) }, ), ) ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { + presenter.test { val initialState = awaitFirstItem() initialState.eventSink(TimelineEvents.FocusOnEvent(AN_EVENT_ID)) awaitItem().also { state -> @@ -696,7 +662,7 @@ class TimelinePresenterTest { liveTimeline = liveTimeline, createTimelineResult = { Result.success(detachedTimeline) }, baseRoom = FakeBaseRoom( - canUserSendMessageResult = { _, _ -> Result.success(true) }, + roomPermissions = roomPermissions(), threadRootIdForEventResult = { _ -> Result.success(threadId) }, ), ) @@ -707,9 +673,7 @@ class TimelinePresenterTest { timeline = liveTimeline, messagesNavigator = navigator, ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { + presenter.test { val initialState = awaitFirstItem() initialState.eventSink.invoke(TimelineEvents.FocusOnEvent(AN_EVENT_ID)) @@ -757,7 +721,7 @@ class TimelinePresenterTest { liveTimeline = liveTimeline, createTimelineResult = { Result.success(detachedTimeline) }, baseRoom = FakeBaseRoom( - canUserSendMessageResult = { _, _ -> Result.success(true) }, + roomPermissions = roomPermissions(), threadRootIdForEventResult = { _ -> Result.success(threadId) }, ), ) @@ -768,9 +732,7 @@ class TimelinePresenterTest { timeline = liveTimeline, messagesNavigator = navigator, ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { + presenter.test { val initialState = awaitFirstItem() initialState.eventSink.invoke(TimelineEvents.FocusOnEvent(AN_EVENT_ID)) @@ -813,7 +775,7 @@ class TimelinePresenterTest { liveTimeline = liveTimeline, createTimelineResult = { Result.success(detachedTimeline) }, baseRoom = FakeBaseRoom( - canUserSendMessageResult = { _, _ -> Result.success(true) }, + roomPermissions = roomPermissions(), // Use a different thread id threadRootIdForEventResult = { _ -> Result.success(A_THREAD_ID_2) }, ), @@ -825,9 +787,7 @@ class TimelinePresenterTest { timeline = liveTimeline, messagesNavigator = navigator, ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { + presenter.test { val initialState = awaitFirstItem() initialState.eventSink.invoke(TimelineEvents.FocusOnEvent(AN_EVENT_ID)) @@ -874,7 +834,7 @@ class TimelinePresenterTest { liveTimeline = liveTimeline, createTimelineResult = { Result.success(detachedTimeline) }, baseRoom = FakeBaseRoom( - canUserSendMessageResult = { _, _ -> Result.success(true) }, + roomPermissions = roomPermissions(), // The event is in the main timeline, not in a thread threadRootIdForEventResult = { _ -> Result.success(null) }, ), @@ -886,9 +846,7 @@ class TimelinePresenterTest { timeline = liveTimeline, messagesNavigator = navigator, ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { + presenter.test { val initialState = awaitFirstItem() initialState.eventSink.invoke(TimelineEvents.FocusOnEvent(AN_EVENT_ID)) @@ -919,9 +877,7 @@ class TimelinePresenterTest { fun `present - show shield hide shield`() = runTest { val presenter = createTimelinePresenter() val shield = aCriticalShield() - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { + presenter.test { val initialState = awaitFirstItem() assertThat(initialState.messageShield).isNull() initialState.eventSink(TimelineEvents.ShowShieldDialog(shield)) @@ -957,7 +913,9 @@ class TimelinePresenterTest { ) val room = FakeJoinedRoom( liveTimeline = timeline, - baseRoom = FakeBaseRoom(canUserSendMessageResult = { _, _ -> Result.success(true) }), + baseRoom = FakeBaseRoom( + roomPermissions = roomPermissions(), + ), ).apply { givenRoomMembersState(RoomMembersState.Unknown) } @@ -965,9 +923,7 @@ class TimelinePresenterTest { val avatarUrl = "https://domain.com/avatar.jpg" val presenter = createTimelinePresenter(timeline, room) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { + presenter.test { val initialState = consumeItemsUntilPredicate(30.seconds) { it.timelineItems.isNotEmpty() }.last() val event = initialState.timelineItems.first() as TimelineItem.Event assertThat(event.senderAvatar.url).isNull() @@ -991,15 +947,13 @@ class TimelinePresenterTest { val room = FakeJoinedRoom( baseRoom = FakeBaseRoom( - canUserSendMessageResult = { _, _ -> Result.success(true) }, + roomPermissions = roomPermissions(), predecessorRoomResult = { predecessorRoom } ), ) val presenter = createTimelinePresenter(room = room) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { + presenter.test { val initialState = awaitFirstItem() assertThat(initialState.timelineRoomInfo.predecessorRoom).isNotNull() assertThat(initialState.timelineRoomInfo.predecessorRoom?.roomId).isEqualTo(predecessorRoomId) @@ -1010,14 +964,12 @@ class TimelinePresenterTest { fun `present - timeline room info no predecessor`() = runTest { val room = FakeJoinedRoom( baseRoom = FakeBaseRoom( - canUserSendMessageResult = { _, _ -> Result.success(true) }, + roomPermissions = roomPermissions(), predecessorRoomResult = { null } ), ) val presenter = createTimelinePresenter(room = room) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { + presenter.test { val initialState = awaitFirstItem() assertThat(initialState.timelineRoomInfo.predecessorRoom).isNull() } @@ -1027,7 +979,7 @@ class TimelinePresenterTest { fun `present - timeline event navigate to room`() = runTest { val room = FakeJoinedRoom( baseRoom = FakeBaseRoom( - canUserSendMessageResult = { _, _ -> Result.success(true) }, + roomPermissions = roomPermissions(), ), ) val onNavigateToRoomLambda = lambdaRecorder, Unit> { _, _, _ -> } @@ -1053,11 +1005,32 @@ class TimelinePresenterTest { return awaitItem() } + private fun roomPermissions( + canRedactOther: Boolean = false, + canRedactOwn: Boolean = true, + canSendMessage: Boolean = true, + canSendReaction: Boolean = true, + canPinUnpin: Boolean = false, + ) = FakeRoomPermissions( + canSendMessage = { type -> + when (type) { + MessageEventType.RoomMessage -> canSendMessage + MessageEventType.Reaction -> canSendReaction + else -> lambdaError() + } + }, + canRedactOther = canRedactOther, + canRedactOwn = canRedactOwn, + canPinUnpin = canPinUnpin, + ) + private fun TestScope.createTimelinePresenter( timeline: Timeline = FakeTimeline(), room: FakeJoinedRoom = FakeJoinedRoom( liveTimeline = timeline, - baseRoom = FakeBaseRoom(canUserSendMessageResult = { _, _ -> Result.success(true) }), + baseRoom = FakeBaseRoom( + roomPermissions = roomPermissions(), + ), ), redactedVoiceMessageManager: RedactedVoiceMessageManager = FakeRedactedVoiceMessageManager(), messagesNavigator: FakeMessagesNavigator = FakeMessagesNavigator(), diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt index 160e3689162..9f23388ecb9 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt @@ -67,6 +67,7 @@ import io.element.android.libraries.mediaviewer.test.util.FileExtensionExtractor import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.test.runTest +import org.jsoup.nodes.Document import org.junit.Assert.fail import org.junit.Test import org.junit.runner.RunWith @@ -187,7 +188,7 @@ class TimelineItemContentMessageFactoryTest { } }.toSpannable() val sut = createTimelineItemContentMessageFactory( - htmlConverterTransform = { expected } + domConverterTransform = { expected } ) val result = sut.create( content = createMessageContent( @@ -679,7 +680,7 @@ class TimelineItemContentMessageFactoryTest { } }.toSpannable() val sut = createTimelineItemContentMessageFactory( - htmlConverterTransform = { expectedSpanned }, + domConverterTransform = { expectedSpanned }, permalinkParser = FakePermalinkParser { PermalinkData.FallbackLink(Uri.EMPTY) } ) val result = sut.create( @@ -765,11 +766,12 @@ class TimelineItemContentMessageFactoryTest { private fun createTimelineItemContentMessageFactory( htmlConverterTransform: (String) -> CharSequence = { it }, + domConverterTransform: (Document) -> CharSequence = { it.body().html() }, permalinkParser: FakePermalinkParser = FakePermalinkParser(), ) = TimelineItemContentMessageFactory( fileSizeFormatter = FakeFileSizeFormatter(), fileExtensionExtractor = FileExtensionExtractorWithoutValidation(), - htmlConverterProvider = FakeHtmlConverterProvider(htmlConverterTransform), + htmlConverterProvider = FakeHtmlConverterProvider(htmlConverterTransform, domConverterTransform), permalinkParser = permalinkParser, textPillificationHelper = FakeTextPillificationHelper(), ) diff --git a/features/messages/test/src/main/kotlin/io/element/android/features/messages/test/pinned/FakePinnedEventsTimelineProvider.kt b/features/messages/test/src/main/kotlin/io/element/android/features/messages/test/pinned/FakePinnedEventsTimelineProvider.kt new file mode 100644 index 00000000000..cb90db29afc --- /dev/null +++ b/features/messages/test/src/main/kotlin/io/element/android/features/messages/test/pinned/FakePinnedEventsTimelineProvider.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 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.test.pinned + +import io.element.android.features.messages.api.pinned.PinnedEventsTimelineProvider +import io.element.android.libraries.matrix.api.timeline.Timeline +import io.element.android.libraries.matrix.test.timeline.FakeTimelineProvider +import kotlinx.coroutines.flow.StateFlow + +class FakePinnedEventsTimelineProvider( + private val fakeTimelineProvider: FakeTimelineProvider = FakeTimelineProvider(), +) : PinnedEventsTimelineProvider { + override fun activeTimelineFlow(): StateFlow = fakeTimelineProvider.activeTimelineFlow() +} diff --git a/features/messages/test/src/main/kotlin/io/element/android/features/messages/test/timeline/FakeHtmlConverterProvider.kt b/features/messages/test/src/main/kotlin/io/element/android/features/messages/test/timeline/FakeHtmlConverterProvider.kt index 75b700b58de..1277783f6a8 100644 --- a/features/messages/test/src/main/kotlin/io/element/android/features/messages/test/timeline/FakeHtmlConverterProvider.kt +++ b/features/messages/test/src/main/kotlin/io/element/android/features/messages/test/timeline/FakeHtmlConverterProvider.kt @@ -11,9 +11,11 @@ package io.element.android.features.messages.test.timeline import androidx.compose.runtime.Composable import io.element.android.features.messages.api.timeline.HtmlConverterProvider import io.element.android.wysiwyg.utils.HtmlConverter +import org.jsoup.nodes.Document class FakeHtmlConverterProvider( private val transform: (String) -> CharSequence = { it }, + private val transformDom: (Document) -> CharSequence = { it.html() }, ) : HtmlConverterProvider { @Composable override fun Update() = Unit @@ -23,6 +25,10 @@ class FakeHtmlConverterProvider( override fun fromHtmlToSpans(html: String): CharSequence { return transform(html) } + + override fun fromDocumentToSpans(dom: Document): CharSequence { + return transformDom(dom) + } } } } diff --git a/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/migrations/AppMigration09.kt b/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/migrations/AppMigration09.kt new file mode 100644 index 00000000000..a420ac8e8f7 --- /dev/null +++ b/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/migrations/AppMigration09.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.features.migration.impl.migrations + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesIntoSet +import dev.zacsweers.metro.Inject +import io.element.android.libraries.matrix.api.MatrixClientProvider +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.sessionstorage.api.SessionStore + +/** + * Ensure we clear the well-known cached config, since it could be invalid due to an SDK issue. + */ +@ContributesIntoSet(AppScope::class) +@Inject +class AppMigration09( + private val sessionStore: SessionStore, + private val matrixClientProvider: MatrixClientProvider, +) : AppMigration { + override val order: Int = 9 + + override suspend fun migrate(isFreshInstall: Boolean) { + if (isFreshInstall) return + + val sessions = sessionStore.getAllSessions() + + for (session in sessions) { + val client = matrixClientProvider.getOrRestore(SessionId(session.userId)).getOrNull() ?: continue + client.resetWellKnownConfig() + } + } +} diff --git a/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/migrations/AppMigration09Test.kt b/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/migrations/AppMigration09Test.kt new file mode 100644 index 00000000000..380ea974647 --- /dev/null +++ b/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/migrations/AppMigration09Test.kt @@ -0,0 +1,46 @@ +/* + * 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.migration.impl.migrations + +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.matrix.test.FakeMatrixClientProvider +import io.element.android.libraries.sessionstorage.test.InMemorySessionStore +import io.element.android.libraries.sessionstorage.test.aSessionData +import io.element.android.tests.testutils.lambda.lambdaRecorder +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class AppMigration09Test { + @Test + fun `migration on fresh install does nothing`() = runTest { + val sessionStore = InMemorySessionStore(initialList = listOf(aSessionData())) + val getClientLambda = lambdaRecorder> { Result.success(FakeMatrixClient()) } + val clientProvider = FakeMatrixClientProvider(getClient = getClientLambda) + val migration = AppMigration09(sessionStore, clientProvider) + migration.migrate(isFreshInstall = true) + + getClientLambda.assertions().isNeverCalled() + } + + @Test + fun `migration on upgrade should invoke the resetWellKnownConfig method`() = runTest { + val sessionStore = InMemorySessionStore(initialList = listOf(aSessionData())) + val resetWellKnownLambda = lambdaRecorder> { Result.success(Unit) } + val getClientLambda = lambdaRecorder> { + Result.success(FakeMatrixClient(resetWellKnownConfigLambda = resetWellKnownLambda)) + } + val clientProvider = FakeMatrixClientProvider(getClient = getClientLambda) + val migration = AppMigration09(sessionStore, clientProvider) + migration.migrate(isFreshInstall = false) + + getClientLambda.assertions().isCalledOnce() + resetWellKnownLambda.assertions().isCalledOnce() + } +} diff --git a/features/poll/api/src/main/res/values-hr/translations.xml b/features/poll/api/src/main/res/values-hr/translations.xml new file mode 100644 index 00000000000..117ed51c6b3 --- /dev/null +++ b/features/poll/api/src/main/res/values-hr/translations.xml @@ -0,0 +1,10 @@ + + + + "%1$d posto ukupnog broja glasova" + "%1$d posto ukupnog broja glasova" + "%1$d posto ukupnog broja glasova" + + "Uklonit će prethodni odabir" + "Ovo je pobjednički odgovor" + diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollEvents.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollEvent.kt similarity index 56% rename from features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollEvents.kt rename to features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollEvent.kt index 3d1c162dd3a..b98ab899b97 100644 --- a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollEvents.kt +++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollEvent.kt @@ -10,15 +10,15 @@ package io.element.android.features.poll.impl.create import io.element.android.libraries.matrix.api.poll.PollKind -sealed interface CreatePollEvents { - data object Save : CreatePollEvents - data class Delete(val confirmed: Boolean) : CreatePollEvents - data class SetQuestion(val question: String) : CreatePollEvents - data class SetAnswer(val index: Int, val text: String) : CreatePollEvents - data object AddAnswer : CreatePollEvents - data class RemoveAnswer(val index: Int) : CreatePollEvents - data class SetPollKind(val pollKind: PollKind) : CreatePollEvents - data object NavBack : CreatePollEvents - data object ConfirmNavBack : CreatePollEvents - data object HideConfirmation : CreatePollEvents +sealed interface CreatePollEvent { + data object Save : CreatePollEvent + data class Delete(val confirmed: Boolean) : CreatePollEvent + data class SetQuestion(val question: String) : CreatePollEvent + data class SetAnswer(val index: Int, val text: String) : CreatePollEvent + data object AddAnswer : CreatePollEvent + data class RemoveAnswer(val index: Int) : CreatePollEvent + data class SetPollKind(val pollKind: PollKind) : CreatePollEvent + data object NavBack : CreatePollEvent + data object ConfirmNavBack : CreatePollEvent + data object HideConfirmation : CreatePollEvent } diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollPresenter.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollPresenter.kt index 3da8c3dc538..6138bab2aea 100644 --- a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollPresenter.kt +++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollPresenter.kt @@ -97,9 +97,9 @@ class CreatePollPresenter( val scope = rememberCoroutineScope() - fun handleEvent(event: CreatePollEvents) { + fun handleEvent(event: CreatePollEvent) { when (event) { - is CreatePollEvents.Save -> scope.launch { + is CreatePollEvent.Save -> scope.launch { if (canSave) { repository.savePoll( existingPollId = when (mode) { @@ -123,7 +123,7 @@ class CreatePollPresenter( Timber.d("Cannot create poll") } } - is CreatePollEvents.Delete -> { + is CreatePollEvent.Delete -> { if (mode !is CreatePollMode.EditPoll) { return } @@ -139,25 +139,25 @@ class CreatePollPresenter( navigateUp() } } - is CreatePollEvents.AddAnswer -> { + is CreatePollEvent.AddAnswer -> { poll = poll.withNewAnswer() } - is CreatePollEvents.RemoveAnswer -> { + is CreatePollEvent.RemoveAnswer -> { poll = poll.withAnswerRemoved(event.index) } - is CreatePollEvents.SetAnswer -> { + is CreatePollEvent.SetAnswer -> { poll = poll.withAnswerChanged(event.index, event.text) } - is CreatePollEvents.SetPollKind -> { + is CreatePollEvent.SetPollKind -> { poll = poll.copy(isDisclosed = event.pollKind.isDisclosed) } - is CreatePollEvents.SetQuestion -> { + is CreatePollEvent.SetQuestion -> { poll = poll.copy(question = event.question) } - is CreatePollEvents.NavBack -> { + is CreatePollEvent.NavBack -> { navigateUp() } - CreatePollEvents.ConfirmNavBack -> { + CreatePollEvent.ConfirmNavBack -> { val shouldConfirm = isDirty if (shouldConfirm) { showBackConfirmation = true @@ -165,7 +165,7 @@ class CreatePollPresenter( navigateUp() } } - is CreatePollEvents.HideConfirmation -> { + is CreatePollEvent.HideConfirmation -> { showBackConfirmation = false showDeleteConfirmation = false } diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollState.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollState.kt index 1046f25bd59..80aa7dc4b05 100644 --- a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollState.kt +++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/create/CreatePollState.kt @@ -20,7 +20,7 @@ data class CreatePollState( val pollKind: PollKind, val showBackConfirmation: Boolean, val showDeleteConfirmation: Boolean, - val eventSink: (CreatePollEvents) -> Unit, + val eventSink: (CreatePollEvent) -> Unit, ) { enum class Mode { New, 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 53caf33707d..3abf3718af3 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 @@ -62,20 +62,21 @@ fun CreatePollView( ) { val coroutineScope = rememberCoroutineScope() - val navBack = { state.eventSink(CreatePollEvents.ConfirmNavBack) } + val navBack = { state.eventSink(CreatePollEvent.ConfirmNavBack) } BackHandler(onBack = navBack) if (state.showBackConfirmation) { SaveChangesDialog( - onSubmitClick = { state.eventSink(CreatePollEvents.NavBack) }, - onDismiss = { state.eventSink(CreatePollEvents.HideConfirmation) } + onSaveClick = { state.eventSink(CreatePollEvent.Save) }, + onDiscardClick = { state.eventSink(CreatePollEvent.NavBack) }, + onDismiss = { state.eventSink(CreatePollEvent.HideConfirmation) }, ) } if (state.showDeleteConfirmation) { ConfirmationDialog( title = stringResource(id = R.string.screen_edit_poll_delete_confirmation_title), content = stringResource(id = R.string.screen_edit_poll_delete_confirmation), - onSubmitClick = { state.eventSink(CreatePollEvents.Delete(confirmed = true)) }, - onDismiss = { state.eventSink(CreatePollEvents.HideConfirmation) } + onSubmitClick = { state.eventSink(CreatePollEvent.Delete(confirmed = true)) }, + onDismiss = { state.eventSink(CreatePollEvent.HideConfirmation) } ) } val questionFocusRequester = remember { FocusRequester() } @@ -90,7 +91,7 @@ fun CreatePollView( mode = state.mode, saveEnabled = state.canSave, onBackClick = navBack, - onSaveClick = { state.eventSink(CreatePollEvents.Save) } + onSaveClick = { state.eventSink(CreatePollEvent.Save) } ) }, ) { paddingValues -> @@ -111,7 +112,7 @@ fun CreatePollView( label = stringResource(id = R.string.screen_create_poll_question_desc), value = state.question, onValueChange = { - state.eventSink(CreatePollEvents.SetQuestion(it)) + state.eventSink(CreatePollEvent.SetQuestion(it)) }, modifier = Modifier .focusRequester(questionFocusRequester) @@ -130,7 +131,7 @@ fun CreatePollView( TextField( value = answer.text, onValueChange = { - state.eventSink(CreatePollEvents.SetAnswer(index, it)) + state.eventSink(CreatePollEvent.SetAnswer(index, it)) }, modifier = Modifier .then(if (isLastItem) Modifier.focusRequester(answerFocusRequester) else Modifier) @@ -144,7 +145,7 @@ fun CreatePollView( imageVector = CompoundIcons.Delete(), contentDescription = stringResource(R.string.screen_create_poll_delete_option_a11y, answer.text), modifier = Modifier.clickable(answer.canDelete) { - state.eventSink(CreatePollEvents.RemoveAnswer(index)) + state.eventSink(CreatePollEvent.RemoveAnswer(index)) }, ) }, @@ -160,7 +161,7 @@ fun CreatePollView( ), style = ListItemStyle.Primary, onClick = { - state.eventSink(CreatePollEvents.AddAnswer) + state.eventSink(CreatePollEvent.AddAnswer) coroutineScope.launch(Dispatchers.Main) { lazyListState.animateScrollToItem(state.answers.size + 1) answerFocusRequester.requestFocus() @@ -180,7 +181,7 @@ fun CreatePollView( ), onClick = { state.eventSink( - CreatePollEvents.SetPollKind( + CreatePollEvent.SetPollKind( if (state.pollKind == PollKind.Disclosed) PollKind.Undisclosed else PollKind.Disclosed ) ) @@ -190,7 +191,7 @@ fun CreatePollView( ListItem( headlineContent = { Text(text = stringResource(id = CommonStrings.action_delete_poll)) }, style = ListItemStyle.Destructive, - onClick = { state.eventSink(CreatePollEvents.Delete(confirmed = false)) }, + onClick = { state.eventSink(CreatePollEvent.Delete(confirmed = false)) }, ) } } diff --git a/features/poll/impl/src/main/res/values-hr/translations.xml b/features/poll/impl/src/main/res/values-hr/translations.xml new file mode 100644 index 00000000000..adc11a1be79 --- /dev/null +++ b/features/poll/impl/src/main/res/values-hr/translations.xml @@ -0,0 +1,20 @@ + + + "Dodaj mogućnost" + "Prikaži rezultate tek nakon završetka ankete" + "Sakrij glasove" + "Mogućnost %1$d" + "Vaše promjene nisu spremljene. Jeste li sigurni da se želite vratiti?" + "Izbriši mogućnost %1$s" + "Pitanje ili tema" + "O čemu se radi u anketi?" + "Izradi anketu" + "Jeste li sigurni da želite izbrisati ovu anketu?" + "Izbriši anketu" + "Uredi anketu" + "Ne mogu pronaći nijednu tekuću anketu." + "Ne mogu pronaći nijednu prijašnju anketu." + "Tekuće" + "Prijašnje" + "Ankete" + diff --git a/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/create/CreatePollPresenterTest.kt b/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/create/CreatePollPresenterTest.kt index 1f916eb670c..dee0268c1c7 100644 --- a/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/create/CreatePollPresenterTest.kt +++ b/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/create/CreatePollPresenterTest.kt @@ -104,15 +104,15 @@ class CreatePollPresenterTest { val initial = awaitItem() assertThat(initial.canSave).isFalse() - initial.eventSink(CreatePollEvents.SetQuestion("A question?")) + initial.eventSink(CreatePollEvent.SetQuestion("A question?")) val questionSet = awaitItem() assertThat(questionSet.canSave).isFalse() - questionSet.eventSink(CreatePollEvents.SetAnswer(0, "Answer 1")) + questionSet.eventSink(CreatePollEvent.SetAnswer(0, "Answer 1")) val answer1Set = awaitItem() assertThat(answer1Set.canSave).isFalse() - answer1Set.eventSink(CreatePollEvents.SetAnswer(1, "Answer 2")) + answer1Set.eventSink(CreatePollEvent.SetAnswer(1, "Answer 2")) val answer2Set = awaitItem() assertThat(answer2Set.canSave).isTrue() } @@ -133,11 +133,11 @@ class CreatePollPresenterTest { presenter.present() }.test { val initial = awaitItem() - initial.eventSink(CreatePollEvents.SetQuestion("A question?")) - initial.eventSink(CreatePollEvents.SetAnswer(0, "Answer 1")) - initial.eventSink(CreatePollEvents.SetAnswer(1, "Answer 2")) + initial.eventSink(CreatePollEvent.SetQuestion("A question?")) + initial.eventSink(CreatePollEvent.SetAnswer(0, "Answer 1")) + initial.eventSink(CreatePollEvent.SetAnswer(1, "Answer 2")) skipItems(3) - initial.eventSink(CreatePollEvents.Save) + initial.eventSink(CreatePollEvent.Save) delay(1) // Wait for the coroutine to finish createPollResult.assertions().isCalledOnce() .with( @@ -182,10 +182,10 @@ class CreatePollPresenterTest { moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { - awaitDefaultItem().eventSink(CreatePollEvents.SetQuestion("A question?")) - awaitItem().eventSink(CreatePollEvents.SetAnswer(0, "Answer 1")) - awaitItem().eventSink(CreatePollEvents.SetAnswer(1, "Answer 2")) - awaitItem().eventSink(CreatePollEvents.Save) + awaitDefaultItem().eventSink(CreatePollEvent.SetQuestion("A question?")) + awaitItem().eventSink(CreatePollEvent.SetAnswer(0, "Answer 1")) + awaitItem().eventSink(CreatePollEvent.SetAnswer(1, "Answer 2")) + awaitItem().eventSink(CreatePollEvent.Save) delay(1) // Wait for the coroutine to finish createPollResult.assertions().isCalledOnce() assertThat(fakeAnalyticsService.capturedEvents).isEmpty() @@ -210,20 +210,20 @@ class CreatePollPresenterTest { }.test { awaitDefaultItem() awaitPollLoaded().apply { - eventSink(CreatePollEvents.SetQuestion("Changed question")) + eventSink(CreatePollEvent.SetQuestion("Changed question")) } awaitItem().apply { - eventSink(CreatePollEvents.SetAnswer(0, "Changed answer 1")) + eventSink(CreatePollEvent.SetAnswer(0, "Changed answer 1")) } awaitItem().apply { - eventSink(CreatePollEvents.SetAnswer(1, "Changed answer 2")) + eventSink(CreatePollEvent.SetAnswer(1, "Changed answer 2")) } awaitPollLoaded( newQuestion = "Changed question", newAnswer1 = "Changed answer 1", newAnswer2 = "Changed answer 2", ).apply { - eventSink(CreatePollEvents.Save) + eventSink(CreatePollEvent.Save) } advanceUntilIdle() // Wait for the coroutine to finish @@ -275,8 +275,8 @@ class CreatePollPresenterTest { presenter.present() }.test { awaitDefaultItem() - awaitPollLoaded().eventSink(CreatePollEvents.SetAnswer(0, "A")) - awaitPollLoaded(newAnswer1 = "A").eventSink(CreatePollEvents.Save) + awaitPollLoaded().eventSink(CreatePollEvent.SetAnswer(0, "A")) + awaitPollLoaded(newAnswer1 = "A").eventSink(CreatePollEvent.Save) advanceUntilIdle() // Wait for the coroutine to finish editPollLambda.assertions().isCalledOnce() assertThat(fakeAnalyticsService.capturedEvents).isEmpty() @@ -296,12 +296,12 @@ class CreatePollPresenterTest { val initial = awaitItem() assertThat(initial.answers.size).isEqualTo(2) - initial.eventSink(CreatePollEvents.AddAnswer) + initial.eventSink(CreatePollEvent.AddAnswer) val answerAdded = awaitItem() assertThat(answerAdded.answers.size).isEqualTo(3) assertThat(answerAdded.answers[2].text).isEmpty() - initial.eventSink(CreatePollEvents.RemoveAnswer(2)) + initial.eventSink(CreatePollEvent.RemoveAnswer(2)) val answerRemoved = awaitItem() assertThat(answerRemoved.answers.size).isEqualTo(2) } @@ -314,7 +314,7 @@ class CreatePollPresenterTest { presenter.present() }.test { val initial = awaitItem() - initial.eventSink(CreatePollEvents.SetQuestion("A question?")) + initial.eventSink(CreatePollEvent.SetQuestion("A question?")) val questionSet = awaitItem() assertThat(questionSet.question).isEqualTo("A question?") } @@ -327,7 +327,7 @@ class CreatePollPresenterTest { presenter.present() }.test { val initial = awaitItem() - initial.eventSink(CreatePollEvents.SetAnswer(0, "This is answer 1")) + initial.eventSink(CreatePollEvent.SetAnswer(0, "This is answer 1")) val answerSet = awaitItem() assertThat(answerSet.answers.first().text).isEqualTo("This is answer 1") } @@ -340,7 +340,7 @@ class CreatePollPresenterTest { presenter.present() }.test { val initial = awaitItem() - initial.eventSink(CreatePollEvents.SetPollKind(PollKind.Undisclosed)) + initial.eventSink(CreatePollEvent.SetPollKind(PollKind.Undisclosed)) val kindSet = awaitItem() assertThat(kindSet.pollKind).isEqualTo(PollKind.Undisclosed) } @@ -355,10 +355,10 @@ class CreatePollPresenterTest { val initial = awaitItem() assertThat(initial.canAddAnswer).isTrue() repeat(17) { - initial.eventSink(CreatePollEvents.AddAnswer) + initial.eventSink(CreatePollEvent.AddAnswer) assertThat(awaitItem().canAddAnswer).isTrue() } - initial.eventSink(CreatePollEvents.AddAnswer) + initial.eventSink(CreatePollEvent.AddAnswer) assertThat(awaitItem().canAddAnswer).isFalse() } } @@ -371,7 +371,7 @@ class CreatePollPresenterTest { }.test { val initial = awaitItem() assertThat(initial.answers.all { it.canDelete }).isFalse() - initial.eventSink(CreatePollEvents.AddAnswer) + initial.eventSink(CreatePollEvent.AddAnswer) assertThat(awaitItem().answers.all { it.canDelete }).isTrue() } } @@ -383,7 +383,7 @@ class CreatePollPresenterTest { presenter.present() }.test { val initial = awaitItem() - initial.eventSink(CreatePollEvents.SetAnswer(0, "A".repeat(241))) + initial.eventSink(CreatePollEvent.SetAnswer(0, "A".repeat(241))) assertThat(awaitItem().answers.first().text.length).isEqualTo(240) } } @@ -396,7 +396,7 @@ class CreatePollPresenterTest { }.test { val initial = awaitItem() assertThat(navUpInvocationsCount).isEqualTo(0) - initial.eventSink(CreatePollEvents.NavBack) + initial.eventSink(CreatePollEvent.NavBack) assertThat(navUpInvocationsCount).isEqualTo(1) } } @@ -410,7 +410,7 @@ class CreatePollPresenterTest { val initial = awaitItem() assertThat(navUpInvocationsCount).isEqualTo(0) assertThat(initial.showBackConfirmation).isFalse() - initial.eventSink(CreatePollEvents.ConfirmNavBack) + initial.eventSink(CreatePollEvent.ConfirmNavBack) assertThat(navUpInvocationsCount).isEqualTo(1) } } @@ -422,11 +422,11 @@ class CreatePollPresenterTest { presenter.present() }.test { val initial = awaitItem() - initial.eventSink(CreatePollEvents.SetQuestion("Non blank")) + initial.eventSink(CreatePollEvent.SetQuestion("Non blank")) assertThat(awaitItem().showBackConfirmation).isFalse() - initial.eventSink(CreatePollEvents.ConfirmNavBack) + initial.eventSink(CreatePollEvent.ConfirmNavBack) assertThat(awaitItem().showBackConfirmation).isTrue() - initial.eventSink(CreatePollEvents.HideConfirmation) + initial.eventSink(CreatePollEvent.HideConfirmation) assertThat(awaitItem().showBackConfirmation).isFalse() assertThat(navUpInvocationsCount).isEqualTo(0) } @@ -442,7 +442,7 @@ class CreatePollPresenterTest { val loaded = awaitPollLoaded() assertThat(navUpInvocationsCount).isEqualTo(0) assertThat(loaded.showBackConfirmation).isFalse() - loaded.eventSink(CreatePollEvents.ConfirmNavBack) + loaded.eventSink(CreatePollEvent.ConfirmNavBack) assertThat(navUpInvocationsCount).isEqualTo(1) } } @@ -455,11 +455,11 @@ class CreatePollPresenterTest { }.test { awaitDefaultItem() val loaded = awaitPollLoaded() - loaded.eventSink(CreatePollEvents.SetQuestion("CHANGED")) + loaded.eventSink(CreatePollEvent.SetQuestion("CHANGED")) assertThat(awaitItem().showBackConfirmation).isFalse() - loaded.eventSink(CreatePollEvents.ConfirmNavBack) + loaded.eventSink(CreatePollEvent.ConfirmNavBack) assertThat(awaitItem().showBackConfirmation).isTrue() - loaded.eventSink(CreatePollEvents.HideConfirmation) + loaded.eventSink(CreatePollEvent.HideConfirmation) assertThat(awaitItem().showBackConfirmation).isFalse() assertThat(navUpInvocationsCount).isEqualTo(0) } @@ -474,7 +474,7 @@ class CreatePollPresenterTest { presenter.present() }.test { awaitDefaultItem() - awaitPollLoaded().eventSink(CreatePollEvents.Delete(confirmed = false)) + awaitPollLoaded().eventSink(CreatePollEvent.Delete(confirmed = false)) awaitDeleteConfirmation() assert(redactEventLambda).isNeverCalled() } @@ -489,8 +489,8 @@ class CreatePollPresenterTest { presenter.present() }.test { awaitDefaultItem() - awaitPollLoaded().eventSink(CreatePollEvents.Delete(confirmed = false)) - awaitDeleteConfirmation().eventSink(CreatePollEvents.HideConfirmation) + awaitPollLoaded().eventSink(CreatePollEvent.Delete(confirmed = false)) + awaitDeleteConfirmation().eventSink(CreatePollEvent.HideConfirmation) awaitPollLoaded().apply { assertThat(showDeleteConfirmation).isFalse() } @@ -507,8 +507,8 @@ class CreatePollPresenterTest { presenter.present() }.test { awaitDefaultItem() - awaitPollLoaded().eventSink(CreatePollEvents.Delete(confirmed = false)) - awaitDeleteConfirmation().eventSink(CreatePollEvents.Delete(confirmed = true)) + awaitPollLoaded().eventSink(CreatePollEvent.Delete(confirmed = false)) + awaitDeleteConfirmation().eventSink(CreatePollEvent.Delete(confirmed = true)) awaitPollLoaded().apply { assertThat(showDeleteConfirmation).isFalse() } diff --git a/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/pollcontent/PollContentStateFactoryTest.kt b/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/pollcontent/PollContentStateFactoryTest.kt index 438d4511996..277508681e7 100644 --- a/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/pollcontent/PollContentStateFactoryTest.kt +++ b/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/pollcontent/PollContentStateFactoryTest.kt @@ -220,6 +220,7 @@ class PollContentStateFactoryTest { votes = votes, endTime = endTime, isEdited = false, + threadInfo = null, ) private fun aPollContentState( 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 82ff1e7eddc..5a59d9be8ae 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 @@ -41,6 +41,7 @@ interface PreferencesEntryPoint : FeatureEntryPoint { interface Callback : Plugin { fun navigateToAddAccount() + fun navigateToLinkNewDevice() fun navigateToBugReport() fun navigateToSecureBackup() fun navigateToRoomNotificationSettings(roomId: RoomId) 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 a7b3ac0acfb..a8e33b7d054 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 @@ -146,6 +146,7 @@ class PreferencesFlowNode( backstack.push(NavTarget.About) } + // TCHAP - Add FAQ_URL in Preferences override fun onOpenFAQ(activity: Activity, darkTheme: Boolean) { activity.openUrlInChromeCustomTab( null, @@ -174,6 +175,10 @@ class PreferencesFlowNode( backstack.push(NavTarget.Labs) } + override fun navigateToLinkNewDevice() { + callback.navigateToLinkNewDevice() + } + override fun navigateToUserProfile(matrixUser: MatrixUser) { backstack.push(NavTarget.UserProfile(matrixUser)) } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsView.kt index b518dae4d1a..c2b51973d02 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsView.kt @@ -234,7 +234,7 @@ private fun VideoQualitySelectorDialog( supportingContent = { Text( text = subtitle, - style = ElementTheme.materialTypography.bodyMedium, + style = ElementTheme.typography.fontBodyMdRegular, color = ElementTheme.colors.textSecondary, ) }, diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsEvents.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsEvents.kt index 3bf4f375d5b..1804d7e0707 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsEvents.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsEvents.kt @@ -21,4 +21,5 @@ sealed interface DeveloperSettingsEvents { data class SetShowColorPicker(val show: Boolean) : DeveloperSettingsEvents data class ChangeBrandColor(val color: Color?) : DeveloperSettingsEvents data object ClearCache : DeveloperSettingsEvents + data object VacuumStores : DeveloperSettingsEvents } 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 52e522dc898..a0d96be540f 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 @@ -29,22 +29,28 @@ import io.element.android.features.preferences.impl.developer.tracing.toLogLevel import io.element.android.features.preferences.impl.model.EnabledFeature 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.VacuumStoresUseCase import io.element.android.features.rageshake.api.preferences.RageshakePreferencesState +import io.element.android.libraries.androidutils.filesize.FileSizeFormatter 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.runCatchingUpdatingState +import io.element.android.libraries.core.data.ByteUnit import io.element.android.libraries.core.extensions.runCatchingExceptions import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.core.meta.BuildType import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.featureflag.ui.model.FeatureUiModel +import io.element.android.libraries.matrix.api.analytics.GetDatabaseSizesUseCase import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.preferences.api.store.AppPreferencesStore import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableMap import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList +import kotlinx.collections.immutable.toImmutableMap import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.map @@ -61,6 +67,9 @@ class DeveloperSettingsPresenter( private val appPreferencesStore: AppPreferencesStore, private val buildMeta: BuildMeta, private val enterpriseService: EnterpriseService, + private val vacuumStoresUseCase: VacuumStoresUseCase, + private val databaseSizesUseCase: GetDatabaseSizesUseCase, + private val fileSizeFormatter: FileSizeFormatter, ) : Presenter { @Composable override fun present(): DeveloperSettingsState { @@ -71,6 +80,9 @@ class DeveloperSettingsPresenter( val cacheSize = remember { mutableStateOf>(AsyncData.Uninitialized) } + val databaseSizes = remember { + mutableStateOf>>(AsyncData.Uninitialized) + } val clearCacheAction = remember { mutableStateOf>(AsyncAction.Uninitialized) } @@ -94,6 +106,7 @@ class DeveloperSettingsPresenter( } LaunchedEffect(Unit) { + computeDatabaseSizes(databaseSizes) featureFlagService.getAvailableFeatures() .run { // Never display room directory search in release builds for Play Store @@ -151,12 +164,16 @@ class DeveloperSettingsPresenter( is DeveloperSettingsEvents.SetShowColorPicker -> { showColorPicker = event.show } + DeveloperSettingsEvents.VacuumStores -> coroutineScope.launch { + vacuumStoresUseCase() + } } } return DeveloperSettingsState( features = featureUiModels, cacheSize = cacheSize.value, + databaseSizes = databaseSizes.value, clearCacheAction = clearCacheAction.value, rageshakeState = rageshakeState, customElementCallBaseUrlState = CustomElementCallBaseUrlState( @@ -209,6 +226,27 @@ class DeveloperSettingsPresenter( }.runCatchingUpdatingState(cacheSize) } + private fun CoroutineScope.computeDatabaseSizes(databaseSizes: MutableState>>) = launch { + suspend { + databaseSizesUseCase(sessionId).getOrThrow().let { sizes -> + buildMap { + sizes.stateStore?.let { stateStoreSize -> + put("State store", fileSizeFormatter.format(stateStoreSize.into(ByteUnit.BYTES), useShortFormat = true)) + } + sizes.eventCacheStore?.let { eventCacheStoreSize -> + put("Event cache store", fileSizeFormatter.format(eventCacheStoreSize.into(ByteUnit.BYTES), useShortFormat = true)) + } + sizes.mediaStore?.let { mediaStoreSize -> + put("Media store", fileSizeFormatter.format(mediaStoreSize.into(ByteUnit.BYTES), useShortFormat = true)) + } + sizes.cryptoStore?.let { cryptoStoreSize -> + put("Crypto store", fileSizeFormatter.format(cryptoStoreSize.into(ByteUnit.BYTES), useShortFormat = true)) + } + } + }.toImmutableMap() + }.runCatchingUpdatingState(databaseSizes) + } + private fun CoroutineScope.clearCache(clearCacheAction: MutableState>) = launch { suspend { clearCacheUseCase() diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsState.kt index f97270dc7a0..920c8ec95c2 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsState.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsState.kt @@ -15,10 +15,12 @@ import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.featureflag.ui.model.FeatureUiModel import io.element.android.libraries.matrix.api.tracing.TraceLogPack import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableMap data class DeveloperSettingsState( val features: ImmutableList, val cacheSize: AsyncData, + val databaseSizes: AsyncData>, val rageshakeState: RageshakePreferencesState, val clearCacheAction: AsyncAction, val customElementCallBaseUrlState: CustomElementCallBaseUrlState, diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsStateProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsStateProvider.kt index ea16ed9f0f5..9ac4fdfcc83 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsStateProvider.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsStateProvider.kt @@ -15,6 +15,7 @@ import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.featureflag.ui.model.aFeatureUiModelList import io.element.android.libraries.matrix.api.tracing.TraceLogPack +import kotlinx.collections.immutable.persistentMapOf import kotlinx.collections.immutable.toImmutableList open class DeveloperSettingsStateProvider : PreviewParameterProvider { @@ -47,6 +48,7 @@ fun aDeveloperSettingsState( features = aFeatureUiModelList(), rageshakeState = aRageshakePreferencesState(), cacheSize = AsyncData.Success("1.2 MB"), + databaseSizes = AsyncData.Success(persistentMapOf("state_store" to "1.2MB")), clearCacheAction = clearCacheAction, customElementCallBaseUrlState = customElementCallBaseUrlState, tracingLogLevel = AsyncData.Success(LogLevelItem.INFO), 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 6d34e97f636..444a391d43e 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 @@ -9,6 +9,7 @@ package io.element.android.features.preferences.impl.developer import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.progressSemantics @@ -146,6 +147,33 @@ fun DeveloperSettingsView( } val cache = state.cacheSize PreferenceCategory(title = "Cache") { + ListItem( + headlineContent = { Text("Database sizes") }, + supportingContent = { + if (state.databaseSizes.isLoading()) { + Text("Computing...") + } else { + val dbSizes = state.databaseSizes.dataOrNull() + if (dbSizes != null && dbSizes.isNotEmpty()) { + Column { + for ((dbName, size) in dbSizes) { + Text("$dbName: $size") + } + } + } else { + Text("Unknown") + } + } + } + ) + ListItem( + headlineContent = { + Text("Vacuum stores") + }, + onClick = { + state.eventSink(DeveloperSettingsEvents.VacuumStores) + } + ) ListItem( headlineContent = { Text("Clear cache") 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 e643bfaf36d..89939c55343 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 @@ -52,6 +52,7 @@ class PreferencesRootNode( fun navigateToLockScreenSettings() fun navigateToAdvancedSettings() fun navigateToLabs() + fun navigateToLinkNewDevice() fun navigateToUserProfile(matrixUser: MatrixUser) fun navigateToBlockedUsers() fun startSignOutFlow() @@ -97,6 +98,7 @@ class PreferencesRootNode( onOpenDeveloperSettings = callback::navigateToDeveloperSettings, onOpenAdvancedSettings = callback::navigateToAdvancedSettings, onOpenLabs = callback::navigateToLabs, + onLinkNewDeviceClick = callback::navigateToLinkNewDevice, onManageAccountClick = { onManageAccountClick(activity, it, isDark) }, onOpenNotificationSettings = callback::navigateToNotificationSettings, onOpenLockScreenSettings = callback::navigateToLockScreenSettings, 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 d9b8b7ab2ab..4fd23220459 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 @@ -69,6 +69,9 @@ class PreferencesRootPresenter( val isMultiAccountEnabled by remember { featureFlagService.isFeatureEnabledFlow(FeatureFlags.MultiAccount) }.collectAsState(initial = false) + val showLinkNewDevice by remember { + featureFlagService.isFeatureEnabledFlow(FeatureFlags.QrCodeLogin) + }.collectAsState(initial = false) val otherSessions by remember { sessionStore.sessionsFlow().map { list -> @@ -151,6 +154,7 @@ class PreferencesRootPresenter( devicesManagementUrl = devicesManagementUrl.value, showAnalyticsSettings = hasAnalyticsProviders, canReportBug = canReportBug, + showLinkNewDevice = showLinkNewDevice, showDeveloperSettings = showDeveloperSettings, canDeactivateAccount = canDeactivateAccount, showBlockedUsersItem = showBlockedUsersItem, 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 b077413f23d..e88c83ed679 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 @@ -26,6 +26,7 @@ data class PreferencesRootState( val accountManagementUrl: String?, val devicesManagementUrl: String?, val canReportBug: Boolean, + val showLinkNewDevice: Boolean, val showAnalyticsSettings: Boolean, val showDeveloperSettings: Boolean, val canDeactivateAccount: Boolean, diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStateProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStateProvider.kt index 528fd1a9112..872e793c6c8 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStateProvider.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStateProvider.kt @@ -32,6 +32,7 @@ fun aPreferencesRootState( accountManagementUrl = "aUrl", devicesManagementUrl = "anOtherUrl", showAnalyticsSettings = true, + showLinkNewDevice = true, canReportBug = true, showDeveloperSettings = true, showBlockedUsersItem = true, 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 6ba6cec4e50..08132ee3e94 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 @@ -54,6 +54,7 @@ fun PreferencesRootView( onAddAccountClick: () -> Unit, onSecureBackupClick: () -> Unit, onManageAccountClick: (url: String) -> Unit, + onLinkNewDeviceClick: () -> Unit, onOpenAnalytics: () -> Unit, onOpenRageShake: () -> Unit, onOpenLockScreenSettings: () -> Unit, @@ -105,6 +106,7 @@ fun PreferencesRootView( ManageAccountSection( state = state, onManageAccountClick = onManageAccountClick, + onLinkNewDeviceClick = onLinkNewDeviceClick, onOpenBlockedUsers = onOpenBlockedUsers ) @@ -200,8 +202,16 @@ private fun ColumnScope.ManageAppSection( private fun ColumnScope.ManageAccountSection( state: PreferencesRootState, onManageAccountClick: (url: String) -> Unit, + onLinkNewDeviceClick: () -> Unit, onOpenBlockedUsers: () -> Unit, ) { + if (state.showLinkNewDevice) { + ListItem( + headlineContent = { Text(stringResource(id = CommonStrings.common_link_new_device)) }, + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Devices())), + onClick = onLinkNewDeviceClick, + ) + } state.accountManagementUrl?.let { url -> ListItem( headlineContent = { Text(stringResource(id = CommonStrings.action_manage_account)) }, @@ -367,6 +377,7 @@ private fun ContentToPreview(matrixUser: MatrixUser) { onOpenAbout = {}, onSecureBackupClick = {}, onManageAccountClick = {}, + onLinkNewDeviceClick = {}, onOpenNotificationSettings = {}, onOpenLockScreenSettings = {}, onOpenUserProfile = {}, diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/tasks/VacuumStoresUseCase.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/tasks/VacuumStoresUseCase.kt new file mode 100644 index 00000000000..1d0de56f091 --- /dev/null +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/tasks/VacuumStoresUseCase.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2025 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.tasks + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.matrix.api.MatrixClient +import timber.log.Timber + +fun interface VacuumStoresUseCase { + suspend operator fun invoke() +} + +@ContributesBinding(AppScope::class) +class DefaultVacuumStoresUseCase( + private val matrixClient: MatrixClient, +) : VacuumStoresUseCase { + override suspend fun invoke() { + matrixClient.performDatabaseVacuum() + .onFailure { Timber.e(it, "Failed to vacuum stores") } + } +} diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileEvents.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileEvent.kt similarity index 70% rename from features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileEvents.kt rename to features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileEvent.kt index f7f2ffceb40..d88eb759631 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileEvents.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileEvent.kt @@ -10,10 +10,10 @@ package io.element.android.features.preferences.impl.user.editprofile import io.element.android.libraries.matrix.ui.media.AvatarAction -sealed interface EditUserProfileEvents { - data class HandleAvatarAction(val action: AvatarAction) : EditUserProfileEvents - data class UpdateDisplayName(val name: String) : EditUserProfileEvents - data object Exit : EditUserProfileEvents - data object Save : EditUserProfileEvents - data object CloseDialog : EditUserProfileEvents +sealed interface EditUserProfileEvent { + data class HandleAvatarAction(val action: AvatarAction) : EditUserProfileEvent + data class UpdateDisplayName(val name: String) : EditUserProfileEvent + data object Exit : EditUserProfileEvent + data object Save : EditUserProfileEvent + data object CloseDialog : EditUserProfileEvent } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfilePresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfilePresenter.kt index 1b3b2b072e5..51a9e2b67af 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfilePresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfilePresenter.kt @@ -38,7 +38,7 @@ import io.element.android.libraries.matrix.ui.media.AvatarAction import io.element.android.libraries.mediapickers.api.PickerProvider import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProvider import io.element.android.libraries.mediaupload.api.MediaPreProcessor -import io.element.android.libraries.permissions.api.PermissionsEvents +import io.element.android.libraries.permissions.api.PermissionsEvent import io.element.android.libraries.permissions.api.PermissionsPresenter import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CoroutineScope @@ -116,22 +116,22 @@ class EditUserProfilePresenter( !userDisplayName.isNullOrBlank() && hasProfileChanged } - fun handleEvent(event: EditUserProfileEvents) { + fun handleEvent(event: EditUserProfileEvent) { when (event) { - is EditUserProfileEvents.Save -> localCoroutineScope.saveChanges( + is EditUserProfileEvent.Save -> localCoroutineScope.saveChanges( name = userDisplayName, avatarUri = userAvatarUri?.toUri(), currentUser = matrixUser, action = saveAction, ) - is EditUserProfileEvents.HandleAvatarAction -> { + is EditUserProfileEvent.HandleAvatarAction -> { when (event.action) { AvatarAction.ChoosePhoto -> galleryImagePicker.launch() AvatarAction.TakePhoto -> if (cameraPermissionState.permissionGranted) { cameraPhotoPicker.launch() } else { pendingPermissionRequest = true - cameraPermissionState.eventSink(PermissionsEvents.RequestPermissions) + cameraPermissionState.eventSink(PermissionsEvent.RequestPermissions) } AvatarAction.Remove -> { temporaryUriDeleter.delete(userAvatarUri?.toUri()) @@ -139,8 +139,8 @@ class EditUserProfilePresenter( } } } - is EditUserProfileEvents.UpdateDisplayName -> userDisplayName = event.name - EditUserProfileEvents.Exit -> { + is EditUserProfileEvent.UpdateDisplayName -> userDisplayName = event.name + EditUserProfileEvent.Exit -> { when (saveAction.value) { is AsyncAction.Confirming -> { // Close the dialog right now @@ -161,7 +161,7 @@ class EditUserProfilePresenter( } } } - EditUserProfileEvents.CloseDialog -> saveAction.value = AsyncAction.Uninitialized + EditUserProfileEvent.CloseDialog -> saveAction.value = AsyncAction.Uninitialized } } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileState.kt index 767e37ad8db..23f9c065cc6 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileState.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileState.kt @@ -23,5 +23,5 @@ data class EditUserProfileState( val saveButtonEnabled: Boolean, val saveAction: AsyncAction, val cameraPermissionState: PermissionsState, - val eventSink: (EditUserProfileEvents) -> Unit + val eventSink: (EditUserProfileEvent) -> Unit ) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileStateProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileStateProvider.kt index a59b6001961..d6fee95ad2e 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileStateProvider.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileStateProvider.kt @@ -33,7 +33,7 @@ fun aEditUserProfileState( saveButtonEnabled: Boolean = true, saveAction: AsyncAction = AsyncAction.Uninitialized, cameraPermissionState: PermissionsState = aPermissionsState(showDialog = false), - eventSink: (EditUserProfileEvents) -> Unit = {}, + eventSink: (EditUserProfileEvent) -> Unit = {}, ) = EditUserProfileState( showMatrixId = false, userId = userId, 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 1ee8b092735..f2c69bc0f7f 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 @@ -34,6 +34,7 @@ import io.element.android.features.preferences.impl.R import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.designsystem.components.async.AsyncActionView import io.element.android.libraries.designsystem.components.async.AsyncActionViewDefaults +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 import io.element.android.libraries.designsystem.components.button.BackButton @@ -46,7 +47,8 @@ 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.AvatarActionBottomSheet -import io.element.android.libraries.matrix.ui.components.EditableAvatarView +import io.element.android.libraries.matrix.ui.components.AvatarPickerState +import io.element.android.libraries.matrix.ui.components.AvatarPickerView import io.element.android.libraries.permissions.api.PermissionsView import io.element.android.libraries.ui.strings.CommonStrings @@ -67,7 +69,7 @@ fun EditUserProfileView( fun onBackClick() { focusManager.clearFocus() - state.eventSink(EditUserProfileEvents.Exit) + state.eventSink(EditUserProfileEvent.Exit) } BackHandler( @@ -86,7 +88,7 @@ fun EditUserProfileView( enabled = state.saveButtonEnabled, onClick = { focusManager.clearFocus() - state.eventSink(EditUserProfileEvents.Save) + state.eventSink(EditUserProfileEvent.Save) }, ) } @@ -102,23 +104,19 @@ fun EditUserProfileView( .verticalScroll(rememberScrollState()) ) { Spacer(modifier = Modifier.height(24.dp)) - EditableAvatarView( - matrixId = state.userId.value, - displayName = state.displayName, - avatarUrl = state.userAvatarUrl, - avatarSize = AvatarSize.EditProfileDetails, - avatarType = AvatarType.User, - onAvatarClick = { onAvatarClick() }, - modifier = Modifier.align(Alignment.CenterHorizontally), - ) - Spacer(modifier = Modifier.height(16.dp)) + val avatarPickerState = remember(state.userAvatarUrl) { + val size = AvatarSize.EditProfileDetails + val type = AvatarType.User + AvatarPickerState.Selected( + avatarData = AvatarData(id = state.userId.value, name = state.displayName, size = size, url = state.userAvatarUrl), + type = type + ) + } - // TCHAP show displayName as Text instead of TextField - Text( - modifier = Modifier.fillMaxWidth(), - text = state.displayName, - style = ElementTheme.typography.fontBodyLgRegular, - textAlign = TextAlign.Center, + AvatarPickerView( + state = avatarPickerState, + onClick = ::onAvatarClick, + modifier = Modifier.align(Alignment.CenterHorizontally), ) // TCHAP hide the Matrix Id depending of showMatrixId feature flag if (state.showMatrixId) { @@ -130,13 +128,28 @@ fun EditUserProfileView( textAlign = TextAlign.Center, ) } + Spacer(modifier = Modifier.height(40.dp)) +// TextField( +// label = stringResource(R.string.screen_edit_profile_display_name), +// value = state.displayName, +// placeholder = stringResource(CommonStrings.common_room_name_placeholder), +// singleLine = true, +// onValueChange = { state.eventSink(EditUserProfileEvent.UpdateDisplayName(it)) }, +// ) + // TCHAP show displayName as Text instead of TextField + Text( + modifier = Modifier.fillMaxWidth(), + text = state.displayName, + style = ElementTheme.typography.fontBodyLgRegular, + textAlign = TextAlign.Center, + ) } AvatarActionBottomSheet( actions = state.avatarActions, isVisible = isAvatarActionsSheetVisible.value, onDismiss = { isAvatarActionsSheetVisible.value = false }, - onSelectAction = { state.eventSink(EditUserProfileEvents.HandleAvatarAction(it)) } + onSelectAction = { state.eventSink(EditUserProfileEvent.HandleAvatarAction(it)) } ) AsyncActionView( @@ -150,8 +163,9 @@ fun EditUserProfileView( when (confirming) { is AsyncAction.ConfirmingCancellation -> { SaveChangesDialog( - onSubmitClick = { state.eventSink(EditUserProfileEvents.Exit) }, - onDismiss = { state.eventSink(EditUserProfileEvents.CloseDialog) } + onSaveClick = { state.eventSink(EditUserProfileEvent.Save) }, + onDiscardClick = { state.eventSink(EditUserProfileEvent.Exit) }, + onDismiss = { state.eventSink(EditUserProfileEvent.CloseDialog) }, ) } } @@ -159,7 +173,7 @@ fun EditUserProfileView( onSuccess = { onEditProfileSuccess() }, errorTitle = { stringResource(R.string.screen_edit_profile_error_title) }, errorMessage = { stringResource(R.string.screen_edit_profile_error) }, - onErrorDismiss = { state.eventSink(EditUserProfileEvents.CloseDialog) }, + onErrorDismiss = { state.eventSink(EditUserProfileEvent.CloseDialog) }, ) } PermissionsView( diff --git a/features/preferences/impl/src/main/res/values-en-rUS/translations.xml b/features/preferences/impl/src/main/res/values-en-rUS/translations.xml index b1f615e6971..9f69227beca 100644 --- a/features/preferences/impl/src/main/res/values-en-rUS/translations.xml +++ b/features/preferences/impl/src/main/res/values-en-rUS/translations.xml @@ -3,4 +3,5 @@ "Optimize media quality" "Automatically optimize images for faster uploads and smaller file sizes." "Optimize image upload quality" + "Try out our latest ideas in development. These features are not finalized; they may be unstable, may change." diff --git a/features/preferences/impl/src/main/res/values-hr/translations.xml b/features/preferences/impl/src/main/res/values-hr/translations.xml new file mode 100644 index 00000000000..250af33d491 --- /dev/null +++ b/features/preferences/impl/src/main/res/values-hr/translations.xml @@ -0,0 +1,82 @@ + + + "Kako biste bili sigurni da nikada nećete propustiti važan poziv, promijenite postavke kako biste omogućili obavijesti preko cijelog zaslona kada je telefon zaključan." + "Poboljšajte svoje iskustvo poziva" + "Odaberite kako želite primati obavijesti" + "Način rada za razvojne inženjere" + "Omogućite pristup značajkama i funkcionalnostima za razvojne inženjere." + "Prilagođeni osnovni URL za Element Call" + "Postavite prilagođeni osnovni URL za Element Call." + "Nevažeći URL; provjerite jeste li uključili protokol (http/https) i ispravnu adresu." + "Sakrij avatare u zahtjevima za poziv u sobu" + "Sakrij preglede medija na vremenskoj traci" + "Laboratoriji" + "Brže prenesite fotografije i videozapise te smanjite potrošnju podataka" + "Optimiziraj kvalitetu medija" + "Moderiranje i sigurnost" + "Automatski optimizirajte slike za brži prijenos i manje veličine datoteka." + "Optimiziraj kvalitetu prijenosa slika" + "%1$s. Ovdje dodirnite za promjenu." + "Visoka (1080p)" + "Niska (480p)" + "Standardna (720p)" + "Kvaliteta prijenosa videozapisa" + "Pružatelj push obavijesti" + "Onemogućite uređivač obogaćenog teksta kako biste ručno tipkali Markdown." + "Potvrde o čitanju" + "Ako je to isključeno, vaše potvrde o čitanju neće se slati nikome. I dalje ćete primati potvrde o čitanju od drugih korisnika." + "Podijeli prisutnost" + "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" + "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." + "Nemate blokiranih korisnika" + "Odblokiraj" + "Moći ćete ponovno vidjeti sve njihove poruke." + "Odblokiraj korisnika" + "Deblokiranje…" + "Ime za prikaz" + "Vaše ime za prikaz" + "Došlo je do nepoznate pogreške i informacije se nisu mogle promijeniti." + "Nije moguće ažurirati profil" + "Uredi profil" + "Ažuriranje profila…" + "Omogući odgovore u nizu" + "Aplikacija će se ponovno pokrenuti kako bi se primijenila ova promjena." + "Isprobajte naše najnovije ideje u razvoju. Ove značajke nisu finalizirane; mogu biti nestabilne i mijenjati se." + "Jeste li spremni za eksperimentiranje?" + "Laboratoriji" + "Dodatne postavke" + "Audiopozivi i videopozivi" + "Neusklađenost konfiguracije" + "Pojednostavili smo postavke obavijesti kako bismo olakšali pronalaženje mogućnosti. Neke prilagođene postavke koje ste odabrali u prošlosti nisu ovdje prikazane, ali su i dalje aktivne. + +Ako nastavite, neke od vaših postavki mogu se promijeniti." + "Izravni razgovori" + "Prilagođena postavka po razgovoru" + "Došlo je do pogreške prilikom ažuriranja postavke obavijesti." + "Sve poruke" + "Samo spominjanja i ključne riječi" + "U izravnim razgovorima obavijesti me za" + "U grupnim chatovima obavijesti me za" + "Omogući obavijesti na ovom uređaju" + "Konfiguracija nije ispravljena, pokušajte ponovno." + "Grupni razgovori" + "Pozivnice" + "Vaš matični poslužitelj ne podržava ovu mogućnost u šifriranim sobama; možda nećete dobiti obavijesti u nekim sobama." + "Spominjanja" + "Sve" + "Spominjanja" + "Obavijesti me za" + "Obavijesti me o sobi @soba" + "Kako biste primali obavijesti, promijenite %1$s." + "postavke sustava" + "Obavijesti sustava su isključene" + "Obavijesti" + "Povijest push obavijesti" + "Rješavanje problema" + "Rješavanje problema s obavijestima" + 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 7a950629bea..963d7846b45 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 @@ -50,6 +50,7 @@ class DefaultPreferencesEntryPointTest { } val callback = object : PreferencesEntryPoint.Callback { override fun navigateToAddAccount() = lambdaError() + override fun navigateToLinkNewDevice() = lambdaError() override fun navigateToBugReport() = lambdaError() override fun navigateToSecureBackup() = lambdaError() override fun navigateToRoomNotificationSettings(roomId: RoomId) = lambdaError() 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 fe2d8445fde..1fcf9bff705 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 @@ -17,15 +17,20 @@ import io.element.android.features.enterprise.test.FakeEnterpriseService import io.element.android.features.preferences.impl.developer.tracing.LogLevelItem 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.VacuumStoresUseCase import io.element.android.features.rageshake.api.preferences.aRageshakePreferencesState +import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.core.data.megaBytes import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.core.meta.BuildType import io.element.android.libraries.featureflag.api.Feature 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.analytics.GetDatabaseSizesUseCase +import io.element.android.libraries.matrix.api.analytics.SdkStoreSizes import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.core.aBuildMeta @@ -34,6 +39,7 @@ 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.collections.immutable.persistentMapOf import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest import org.junit.Rule @@ -55,7 +61,12 @@ class DeveloperSettingsPresenterTest { ) } val presenter = createDeveloperSettingsPresenter( - featureFlagService = FakeFeatureFlagService(getAvailableFeaturesResult = getAvailableFeaturesResult) + featureFlagService = FakeFeatureFlagService(getAvailableFeaturesResult = getAvailableFeaturesResult), + databaseSizesUseCase = GetDatabaseSizesUseCase { + Result.success( + SdkStoreSizes(stateStore = 10.megaBytes, eventCacheStore = 10.megaBytes, mediaStore = 10.megaBytes, cryptoStore = 10.megaBytes) + ) + } ) presenter.test { awaitItem().also { state -> @@ -78,6 +89,14 @@ class DeveloperSettingsPresenterTest { } awaitItem().also { state -> assertThat(state.cacheSize).isInstanceOf(AsyncData.Success::class.java) + assertThat(state.databaseSizes.dataOrNull()).isEqualTo( + persistentMapOf( + "State store" to "10485760 Bytes", + "Event cache store" to "10485760 Bytes", + "Media store" to "10485760 Bytes", + "Crypto store" to "10485760 Bytes" + ) + ) } getAvailableFeaturesResult.assertions().isCalledOnce() .with(value(false), value(false)) @@ -212,6 +231,23 @@ class DeveloperSettingsPresenterTest { } } + @Test + fun `present - VacuumStores action invokes the VacuumStoresUseCase`() = runTest { + var vacuumCalled = false + val presenter = createDeveloperSettingsPresenter( + vacuumStoresUseCase = VacuumStoresUseCase { + vacuumCalled = true + } + ) + presenter.test { + val state = awaitItem() + assertThat(vacuumCalled).isFalse() + state.eventSink(DeveloperSettingsEvents.VacuumStores) + skipItems(1) + assertThat(vacuumCalled).isTrue() + } + } + private fun createDeveloperSettingsPresenter( sessionId: SessionId = A_SESSION_ID, featureFlagService: FakeFeatureFlagService = FakeFeatureFlagService( @@ -230,6 +266,8 @@ class DeveloperSettingsPresenterTest { preferencesStore: InMemoryAppPreferencesStore = InMemoryAppPreferencesStore(), buildMeta: BuildMeta = aBuildMeta(), enterpriseService: EnterpriseService = FakeEnterpriseService(), + vacuumStoresUseCase: VacuumStoresUseCase = VacuumStoresUseCase {}, + databaseSizesUseCase: GetDatabaseSizesUseCase = GetDatabaseSizesUseCase { Result.success(SdkStoreSizes(null, null, null, null)) }, ): DeveloperSettingsPresenter { return DeveloperSettingsPresenter( sessionId = sessionId, @@ -240,6 +278,9 @@ class DeveloperSettingsPresenterTest { appPreferencesStore = preferencesStore, buildMeta = buildMeta, enterpriseService = enterpriseService, + vacuumStoresUseCase = vacuumStoresUseCase, + databaseSizesUseCase = databaseSizesUseCase, + fileSizeFormatter = FakeFileSizeFormatter(), ) } } diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsViewTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsViewTest.kt index e812bf650d2..3854e3f4a1a 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsViewTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsViewTest.kt @@ -113,7 +113,7 @@ class DeveloperSettingsViewTest { eventsRecorder.assertSingle(DeveloperSettingsEvents.SetTracingLogLevel(LogLevelItem.DEBUG)) } - @Config(qualifiers = "h2000dp") + @Config(qualifiers = "h2200dp") @Test fun `clicking on clear cache emits the expected event`() { val eventsRecorder = EventsRecorder() 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 c0d7b6e817f..a83ec9af794 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 @@ -88,6 +88,7 @@ class PreferencesRootPresenterTest { assertThat(loadedState.accountManagementUrl).isNull() assertThat(loadedState.devicesManagementUrl).isNull() assertThat(loadedState.showAnalyticsSettings).isFalse() + assertThat(loadedState.showLinkNewDevice).isFalse() assertThat(loadedState.showDeveloperSettings).isTrue() assertThat(loadedState.canDeactivateAccount).isTrue() assertThat(loadedState.canReportBug).isTrue() @@ -259,6 +260,22 @@ class PreferencesRootPresenterTest { } } + @Test + fun `present - link new device`() = runTest { + createPresenter( + matrixClient = FakeMatrixClient( + sessionId = A_SESSION_ID, + canDeactivateAccountResult = { true }, + ), + featureFlagService = FakeFeatureFlagService( + initialState = mapOf(FeatureFlags.QrCodeLogin.key to true) + ), + ).test { + val state = awaitFirstItem() + assertThat(state.showLinkNewDevice).isTrue() + } + } + private suspend fun ReceiveTurbine.awaitFirstItem(): T { skipItems(1) return awaitItem() diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfilePresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfilePresenterTest.kt index 2cd2a9d01d9..cdbe0a542e1 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfilePresenterTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfilePresenterTest.kt @@ -58,8 +58,6 @@ class EditUserProfilePresenterTest { private val userAvatarUri: Uri = mockk() private val anotherAvatarUri: Uri = mockk() - private val fakeFileContents = ByteArray(2) - @Before fun setup() { fakePickerProvider = FakePickerProvider() @@ -128,7 +126,7 @@ class EditUserProfilePresenterTest { ) presenter.test { val initialState = awaitItem() - initialState.eventSink(EditUserProfileEvents.Exit) + initialState.eventSink(EditUserProfileEvent.Exit) closeLambda.assertions().isCalledOnce() } } @@ -143,21 +141,21 @@ class EditUserProfilePresenterTest { ) presenter.test { val initialState = awaitItem() - initialState.eventSink(EditUserProfileEvents.UpdateDisplayName("New name")) + initialState.eventSink(EditUserProfileEvent.UpdateDisplayName("New name")) val withUpdatedName = awaitItem() - withUpdatedName.eventSink(EditUserProfileEvents.Exit) + withUpdatedName.eventSink(EditUserProfileEvent.Exit) val withConfirmation = awaitItem() assertThat(withConfirmation.saveAction).isEqualTo(AsyncAction.ConfirmingCancellation) // Cancel - withConfirmation.eventSink(EditUserProfileEvents.CloseDialog) + withConfirmation.eventSink(EditUserProfileEvent.CloseDialog) val afterCancel = awaitItem() assertThat(afterCancel.saveAction).isEqualTo(AsyncAction.Uninitialized) // Try again and confirm - afterCancel.eventSink(EditUserProfileEvents.Exit) + afterCancel.eventSink(EditUserProfileEvent.Exit) val withConfirmation2 = awaitItem() assertThat(withConfirmation2.saveAction).isEqualTo(AsyncAction.ConfirmingCancellation) closeLambda.assertions().isNeverCalled() - withConfirmation2.eventSink(EditUserProfileEvents.Exit) + withConfirmation2.eventSink(EditUserProfileEvent.Exit) // Dialog is closed val finalState = awaitItem() assertThat(finalState.saveAction).isEqualTo(AsyncAction.Uninitialized) @@ -178,17 +176,17 @@ class EditUserProfilePresenterTest { val initialState = awaitItem() assertThat(initialState.displayName).isEqualTo("Name") assertThat(initialState.userAvatarUrl).isEqualTo(AN_AVATAR_URL) - initialState.eventSink(EditUserProfileEvents.UpdateDisplayName("Name II")) + initialState.eventSink(EditUserProfileEvent.UpdateDisplayName("Name II")) awaitItem().apply { assertThat(displayName).isEqualTo("Name II") assertThat(userAvatarUrl).isEqualTo(AN_AVATAR_URL) } - initialState.eventSink(EditUserProfileEvents.UpdateDisplayName("Name III")) + initialState.eventSink(EditUserProfileEvent.UpdateDisplayName("Name III")) awaitItem().apply { assertThat(displayName).isEqualTo("Name III") assertThat(userAvatarUrl).isEqualTo(AN_AVATAR_URL) } - initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.Remove)) + initialState.eventSink(EditUserProfileEvent.HandleAvatarAction(AvatarAction.Remove)) awaitItem().apply { assertThat(displayName).isEqualTo("Name III") assertThat(userAvatarUrl).isNull() @@ -209,7 +207,7 @@ class EditUserProfilePresenterTest { presenter.test { val initialState = awaitItem() assertThat(initialState.userAvatarUrl).isEqualTo(AN_AVATAR_URL) - initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.ChoosePhoto)) + initialState.eventSink(EditUserProfileEvent.HandleAvatarAction(AvatarAction.ChoosePhoto)) awaitItem().apply { assertThat(userAvatarUrl).isEqualTo(ANOTHER_AVATAR_URL) } @@ -233,7 +231,7 @@ class EditUserProfilePresenterTest { val initialState = awaitItem() assertThat(initialState.userAvatarUrl).isEqualTo(AN_AVATAR_URL) assertThat(initialState.cameraPermissionState.permissionGranted).isFalse() - initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.TakePhoto)) + initialState.eventSink(EditUserProfileEvent.HandleAvatarAction(AvatarAction.TakePhoto)) val stateWithAskingPermission = awaitItem() assertThat(stateWithAskingPermission.cameraPermissionState.showDialog).isTrue() fakePermissionsPresenter.setPermissionGranted() @@ -243,7 +241,7 @@ class EditUserProfilePresenterTest { assertThat(stateWithNewAvatar.userAvatarUrl).isEqualTo(ANOTHER_AVATAR_URL) // Do it again, no permission is requested fakePickerProvider.givenResult(userAvatarUri) - stateWithNewAvatar.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.TakePhoto)) + stateWithNewAvatar.eventSink(EditUserProfileEvent.HandleAvatarAction(AvatarAction.TakePhoto)) val stateWithNewAvatar2 = awaitItem() assertThat(stateWithNewAvatar2.userAvatarUrl).isEqualTo(AN_AVATAR_URL) deleteCallback.assertions().isCalledExactly(2).withSequence( @@ -268,22 +266,22 @@ class EditUserProfilePresenterTest { val initialState = awaitItem() assertThat(initialState.saveButtonEnabled).isFalse() // Once a change is made, the save button is enabled - initialState.eventSink(EditUserProfileEvents.UpdateDisplayName("Name II")) + initialState.eventSink(EditUserProfileEvent.UpdateDisplayName("Name II")) awaitItem().apply { assertThat(saveButtonEnabled).isTrue() } // If it's reverted then the save disables again - initialState.eventSink(EditUserProfileEvents.UpdateDisplayName("Name")) + initialState.eventSink(EditUserProfileEvent.UpdateDisplayName("Name")) awaitItem().apply { assertThat(saveButtonEnabled).isFalse() } // Make a change... - initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.Remove)) + initialState.eventSink(EditUserProfileEvent.HandleAvatarAction(AvatarAction.Remove)) awaitItem().apply { assertThat(saveButtonEnabled).isTrue() } // Revert it... - initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.ChoosePhoto)) + initialState.eventSink(EditUserProfileEvent.HandleAvatarAction(AvatarAction.ChoosePhoto)) awaitItem().apply { assertThat(saveButtonEnabled).isFalse() } @@ -309,22 +307,22 @@ class EditUserProfilePresenterTest { val initialState = awaitItem() assertThat(initialState.saveButtonEnabled).isFalse() // Once a change is made, the save button is enabled - initialState.eventSink(EditUserProfileEvents.UpdateDisplayName("Name II")) + initialState.eventSink(EditUserProfileEvent.UpdateDisplayName("Name II")) awaitItem().apply { assertThat(saveButtonEnabled).isTrue() } // If it's reverted then the save disables again - initialState.eventSink(EditUserProfileEvents.UpdateDisplayName("Name")) + initialState.eventSink(EditUserProfileEvent.UpdateDisplayName("Name")) awaitItem().apply { assertThat(saveButtonEnabled).isFalse() } // Make a change... - initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.ChoosePhoto)) + initialState.eventSink(EditUserProfileEvent.HandleAvatarAction(AvatarAction.ChoosePhoto)) awaitItem().apply { assertThat(saveButtonEnabled).isTrue() } // Revert it... - initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.Remove)) + initialState.eventSink(EditUserProfileEvent.HandleAvatarAction(AvatarAction.Remove)) awaitItem().apply { assertThat(saveButtonEnabled).isFalse() } @@ -348,9 +346,9 @@ class EditUserProfilePresenterTest { ) presenter.test { val initialState = awaitItem() - initialState.eventSink(EditUserProfileEvents.UpdateDisplayName("New name")) - initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.Remove)) - initialState.eventSink(EditUserProfileEvents.Save) + initialState.eventSink(EditUserProfileEvent.UpdateDisplayName("New name")) + initialState.eventSink(EditUserProfileEvent.HandleAvatarAction(AvatarAction.Remove)) + initialState.eventSink(EditUserProfileEvent.Save) consumeItemsUntilPredicate { matrixClient.setDisplayNameCalled && matrixClient.removeAvatarCalled && !matrixClient.uploadAvatarCalled } assertThat(matrixClient.setDisplayNameCalled).isTrue() assertThat(matrixClient.removeAvatarCalled).isTrue() @@ -369,8 +367,8 @@ class EditUserProfilePresenterTest { ) presenter.test { val initialState = awaitItem() - initialState.eventSink(EditUserProfileEvents.UpdateDisplayName(" Name ")) - initialState.eventSink(EditUserProfileEvents.Save) + initialState.eventSink(EditUserProfileEvent.UpdateDisplayName(" Name ")) + initialState.eventSink(EditUserProfileEvent.Save) consumeItemsUntilTimeout() assertThat(matrixClient.setDisplayNameCalled).isFalse() assertThat(matrixClient.uploadAvatarCalled).isFalse() @@ -388,8 +386,8 @@ class EditUserProfilePresenterTest { ) presenter.test { val initialState = awaitItem() - initialState.eventSink(EditUserProfileEvents.UpdateDisplayName("")) - initialState.eventSink(EditUserProfileEvents.Save) + initialState.eventSink(EditUserProfileEvent.UpdateDisplayName("")) + initialState.eventSink(EditUserProfileEvent.Save) assertThat(matrixClient.setDisplayNameCalled).isFalse() assertThat(matrixClient.uploadAvatarCalled).isFalse() assertThat(matrixClient.removeAvatarCalled).isFalse() @@ -401,7 +399,7 @@ class EditUserProfilePresenterTest { fun `present - save processes and sets avatar when processor returns successfully`() = runTest { val matrixClient = FakeMatrixClient() val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL) - givenPickerReturnsFile() + val tmpFile = givenPickerReturnsFile() val presenter = createEditUserProfilePresenter( matrixClient = matrixClient, matrixUser = user, @@ -409,12 +407,16 @@ class EditUserProfilePresenterTest { deleteLambda = { assertThat(it).isEqualTo(userAvatarUri) } ), ) - presenter.test { - val initialState = awaitItem() - initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.ChoosePhoto)) - initialState.eventSink(EditUserProfileEvents.Save) - consumeItemsUntilPredicate { matrixClient.uploadAvatarCalled } - assertThat(matrixClient.uploadAvatarCalled).isTrue() + try { + presenter.test { + val initialState = awaitItem() + initialState.eventSink(EditUserProfileEvent.HandleAvatarAction(AvatarAction.ChoosePhoto)) + initialState.eventSink(EditUserProfileEvent.Save) + consumeItemsUntilPredicate { matrixClient.uploadAvatarCalled } + assertThat(matrixClient.uploadAvatarCalled).isTrue() + } + } finally { + tmpFile.delete() } } @@ -433,8 +435,8 @@ class EditUserProfilePresenterTest { fakeMediaPreProcessor.givenResult(Result.failure(RuntimeException("Oh no"))) presenter.test { val initialState = awaitItem() - initialState.eventSink(EditUserProfileEvents.HandleAvatarAction(AvatarAction.ChoosePhoto)) - initialState.eventSink(EditUserProfileEvents.Save) + initialState.eventSink(EditUserProfileEvent.HandleAvatarAction(AvatarAction.ChoosePhoto)) + initialState.eventSink(EditUserProfileEvent.Save) skipItems(2) assertThat(matrixClient.uploadAvatarCalled).isFalse() assertThat(awaitItem().saveAction).isInstanceOf(AsyncAction.Failure::class.java) @@ -447,7 +449,7 @@ class EditUserProfilePresenterTest { val matrixClient = FakeMatrixClient().apply { givenSetDisplayNameResult(Result.failure(RuntimeException("!"))) } - saveAndAssertFailure(user, matrixClient, EditUserProfileEvents.UpdateDisplayName("New name")) + saveAndAssertFailure(user, matrixClient, EditUserProfileEvent.UpdateDisplayName("New name")) } @Test @@ -456,39 +458,47 @@ class EditUserProfilePresenterTest { val matrixClient = FakeMatrixClient().apply { givenRemoveAvatarResult(Result.failure(RuntimeException("!"))) } - saveAndAssertFailure(user, matrixClient, EditUserProfileEvents.HandleAvatarAction(AvatarAction.Remove)) + saveAndAssertFailure(user, matrixClient, EditUserProfileEvent.HandleAvatarAction(AvatarAction.Remove)) } @Test fun `present - sets save action to failure if setting avatar fails`() = runTest { - givenPickerReturnsFile() + val tmpFile = givenPickerReturnsFile() val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL) val matrixClient = FakeMatrixClient().apply { givenUploadAvatarResult(Result.failure(RuntimeException("!"))) } - saveAndAssertFailure(user, matrixClient, EditUserProfileEvents.HandleAvatarAction(AvatarAction.ChoosePhoto)) + try { + saveAndAssertFailure(user, matrixClient, EditUserProfileEvent.HandleAvatarAction(AvatarAction.ChoosePhoto)) + } finally { + tmpFile.delete() + } } @Test fun `present - CloseDialog resets save action state`() = runTest { - givenPickerReturnsFile() + val tmpFile = givenPickerReturnsFile() val user = aMatrixUser(id = A_USER_ID.value, displayName = "Name", avatarUrl = AN_AVATAR_URL) val matrixClient = FakeMatrixClient().apply { givenSetDisplayNameResult(Result.failure(RuntimeException("!"))) } val presenter = createEditUserProfilePresenter(matrixUser = user, matrixClient = matrixClient) - presenter.test { - val initialState = awaitItem() - initialState.eventSink(EditUserProfileEvents.UpdateDisplayName("foo")) - initialState.eventSink(EditUserProfileEvents.Save) - skipItems(2) - assertThat(awaitItem().saveAction).isInstanceOf(AsyncAction.Failure::class.java) - initialState.eventSink(EditUserProfileEvents.CloseDialog) - assertThat(awaitItem().saveAction).isInstanceOf(AsyncAction.Uninitialized::class.java) + try { + presenter.test { + val initialState = awaitItem() + initialState.eventSink(EditUserProfileEvent.UpdateDisplayName("foo")) + initialState.eventSink(EditUserProfileEvent.Save) + skipItems(2) + assertThat(awaitItem().saveAction).isInstanceOf(AsyncAction.Failure::class.java) + initialState.eventSink(EditUserProfileEvent.CloseDialog) + assertThat(awaitItem().saveAction).isInstanceOf(AsyncAction.Uninitialized::class.java) + } + } finally { + tmpFile.delete() } } - private suspend fun saveAndAssertFailure(matrixUser: MatrixUser, matrixClient: MatrixClient, event: EditUserProfileEvents) { + private suspend fun saveAndAssertFailure(matrixUser: MatrixUser, matrixClient: MatrixClient, event: EditUserProfileEvent) { val presenter = createEditUserProfilePresenter( matrixUser = matrixUser, matrixClient = matrixClient, @@ -499,27 +509,25 @@ class EditUserProfilePresenterTest { presenter.test { val initialState = awaitItem() initialState.eventSink(event) - initialState.eventSink(EditUserProfileEvents.Save) + initialState.eventSink(EditUserProfileEvent.Save) skipItems(1) assertThat(awaitItem().saveAction).isInstanceOf(AsyncAction.Loading::class.java) assertThat(awaitItem().saveAction).isInstanceOf(AsyncAction.Failure::class.java) } } - private fun givenPickerReturnsFile() { - mockkStatic(File::readBytes) - val processedFile: File = mockk { - every { readBytes() } returns fakeFileContents - } + private fun givenPickerReturnsFile(): File { + val file = File.createTempFile("test", "jpg") fakePickerProvider.givenResult(anotherAvatarUri) fakeMediaPreProcessor.givenResult( Result.success( MediaUploadInfo.AnyFile( - file = processedFile, + file = file, fileInfo = mockk(), ) ) ) + return file } companion object { diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileViewTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileViewTest.kt index f4c71443502..728e05ee7e7 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileViewTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileViewTest.kt @@ -34,45 +34,45 @@ class EditUserProfileViewTest { @Test fun `clicking on back emits the expected event`() { - val eventsRecorder = EventsRecorder() + val eventsRecorder = EventsRecorder() rule.setEditUserProfileView( aEditUserProfileState( eventSink = eventsRecorder, ), ) rule.pressBack() - eventsRecorder.assertSingle(EditUserProfileEvents.Exit) + eventsRecorder.assertSingle(EditUserProfileEvent.Exit) } @Test - fun `clicking on cancel exit emits the expected event`() { - val eventsRecorder = EventsRecorder() + fun `clicking on save from the exit confirmation dialog emits the expected event`() { + val eventsRecorder = EventsRecorder() rule.setEditUserProfileView( aEditUserProfileState( saveAction = AsyncAction.ConfirmingCancellation, eventSink = eventsRecorder, ), ) - rule.clickOn(CommonStrings.action_cancel) - eventsRecorder.assertSingle(EditUserProfileEvents.CloseDialog) + rule.clickOn(CommonStrings.action_save, inDialog = true) + eventsRecorder.assertSingle(EditUserProfileEvent.Save) } @Test - fun `clicking on OK exit emits the expected event`() { - val eventsRecorder = EventsRecorder() + fun `clicking on discard exit emits the expected event`() { + val eventsRecorder = EventsRecorder() rule.setEditUserProfileView( aEditUserProfileState( saveAction = AsyncAction.ConfirmingCancellation, eventSink = eventsRecorder, ), ) - rule.clickOn(CommonStrings.action_ok) - eventsRecorder.assertSingle(EditUserProfileEvents.Exit) + rule.clickOn(CommonStrings.action_discard) + eventsRecorder.assertSingle(EditUserProfileEvent.Exit) } @Test fun `clicking on save emits the expected event`() { - val eventsRecorder = EventsRecorder() + val eventsRecorder = EventsRecorder() rule.setEditUserProfileView( aEditUserProfileState( saveButtonEnabled = true, @@ -81,12 +81,12 @@ class EditUserProfileViewTest { ), ) rule.clickOn(CommonStrings.action_save) - eventsRecorder.assertSingle(EditUserProfileEvents.Save) + eventsRecorder.assertSingle(EditUserProfileEvent.Save) } @Test fun `clicking on avatar opens the bottom sheet dialog`() { - val eventsRecorder = EventsRecorder() + val eventsRecorder = EventsRecorder() val actions = listOf( AvatarAction.TakePhoto, AvatarAction.ChoosePhoto, @@ -110,7 +110,7 @@ class EditUserProfileViewTest { @Test fun `success invokes the expected callback`() { - val eventsRecorder = EventsRecorder(expectEvents = false) + val eventsRecorder = EventsRecorder(expectEvents = false) ensureCalledOnce { callback -> rule.setEditUserProfileView( aEditUserProfileState( diff --git a/features/rageshake/api/src/main/res/values-hr/translations.xml b/features/rageshake/api/src/main/res/values-hr/translations.xml new file mode 100644 index 00000000000..c3dfcb3203b --- /dev/null +++ b/features/rageshake/api/src/main/res/values-hr/translations.xml @@ -0,0 +1,7 @@ + + + "%1$s neočekivano je prestao s radom prilikom posljednjeg korištenja. Želite li s nama podijeliti izvješće o padu?" + "Čini se da ljutito treseš telefon. Želiš li otvoriti zaslon s izvješćem o pogrešci?" + "Snažno protresi" + "Prag detekcije" + diff --git a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporter.kt b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporter.kt index f51d699350b..f238f9d6ffc 100755 --- a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporter.kt +++ b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporter.kt @@ -158,6 +158,7 @@ class DefaultBugReporter( } if (withCrashLogs || withDevicesLogs) { saveLogCat() + ?.takeIf { it.length() < RageshakeConfig.MAX_LOG_CONTENT_SIZE } ?.let { logCatFile -> compressFile(logCatFile).also { logCatFile.safeDelete() @@ -191,6 +192,7 @@ class DefaultBugReporter( .addFormDataPart("label", buildMeta.versionName) .addFormDataPart("label", buildMeta.flavorDescription) .addFormDataPart("branch_name", buildMeta.gitBranchName) + userId?.let { matrixClientProvider.getOrNull(it)?.let { client -> val curveKey = client.encryptionService.deviceCurve25519() @@ -390,7 +392,11 @@ class DefaultBugReporter( ) { val logDirectory = logDirectory() logDirectory.listFiles() - ?.filter { it.isFile && !it.name.endsWith(LOG_CAT_FILENAME) } + ?.filter { + it.isFile && + !it.name.endsWith(LOG_CAT_FILENAME) && + it.length() < RageshakeConfig.MAX_LOG_CONTENT_SIZE + } }.orEmpty() } diff --git a/features/rageshake/impl/src/main/res/values-hr/translations.xml b/features/rageshake/impl/src/main/res/values-hr/translations.xml new file mode 100644 index 00000000000..e49427b2a91 --- /dev/null +++ b/features/rageshake/impl/src/main/res/values-hr/translations.xml @@ -0,0 +1,20 @@ + + + "Priloži snimku zaslona" + "Možete mi se obratiti ako imate bilo kakvih dodatnih pitanja." + "Javi mi se" + "Uredi snimku zaslona" + "Opišite problem. Što ste napravili? Što ste očekivali da će se dogoditi? Što se zapravo dogodilo. Molimo vas da što detaljnije opišete problem." + "Opišite problem…" + "Ako je moguće, molimo vas da opis bude na engleskom jeziku." + "Opis je prekratak; navedite više pojedinosti o tome što se dogodilo. Hvala!" + "Pošalji zapisnike o padu aplikacije" + "Dopusti zapisnike" + "Vaši su zapisnici preopširni pa ih nije moguće uključiti u ovo izvješće. Molimo vas da nam ih pošaljete na drugi način." + "Pošalji snimku zaslona" + "Zapisnici će biti uključeni u vašu poruku kako bismo bili sigurni da sve ispravno funkcionira. Kako biste poslali poruku bez zapisnika, isključite ovu postavku." + "%1$s neočekivano je prestao s radom prilikom posljednjeg korištenja. Želite li s nama podijeliti izvješće o padu?" + "Ako imate problema s obavijestima, prijenos pravila za slanje obavijesti može nam pomoći da utvrdimo uzrok. Imajte na umu da ta pravila mogu sadržavati privatne podatke, kao što su vaše ime za prikaz ili ključne riječi za koje želite primati obavijesti." + "Postavke slanja obavijesti" + "Prikaz zapisnika" + diff --git a/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporterTest.kt b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporterTest.kt index f82c57889fc..41e136a53e5 100755 --- a/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporterTest.kt +++ b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporterTest.kt @@ -323,7 +323,7 @@ class DefaultBugReporterTest { while (part != null) { part.headers["Content-Disposition"]?.let { contentDisposition -> regex.find(contentDisposition)?.groupValues?.get(1)?.let { name -> - foundValues.put(name, part!!.body.readUtf8()) + foundValues.put(name, part.body.readUtf8()) } } part = multipartReader.nextPart() diff --git a/features/reportroom/impl/src/main/res/values-hr/translations.xml b/features/reportroom/impl/src/main/res/values-hr/translations.xml new file mode 100644 index 00000000000..50ecc7a24d8 --- /dev/null +++ b/features/reportroom/impl/src/main/res/values-hr/translations.xml @@ -0,0 +1,8 @@ + + + "Vaša je prijava uspješno poslana, ali naišli smo na problem prilikom pokušaja napuštanja sobe. Pokušajte ponovno." + "Sobu nije moguće napustiti" + "Prijavi ovu sobu svom administratoru. Ako su poruke šifrirane, vaš administrator neće ih moći pročitati." + "Navedite razlog prijave…" + "Prijavi sobu" + diff --git a/features/rolesandpermissions/api/src/main/kotlin/io/element/android/features/rolesandpermissions/api/RolesAndPermissionsEntryPoint.kt b/features/rolesandpermissions/api/src/main/kotlin/io/element/android/features/rolesandpermissions/api/RolesAndPermissionsEntryPoint.kt index 1e9fe6a1c4f..bd3cea0439a 100644 --- a/features/rolesandpermissions/api/src/main/kotlin/io/element/android/features/rolesandpermissions/api/RolesAndPermissionsEntryPoint.kt +++ b/features/rolesandpermissions/api/src/main/kotlin/io/element/android/features/rolesandpermissions/api/RolesAndPermissionsEntryPoint.kt @@ -8,6 +8,19 @@ package io.element.android.features.rolesandpermissions.api -import io.element.android.libraries.architecture.SimpleFeatureEntryPoint +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 -fun interface RolesAndPermissionsEntryPoint : SimpleFeatureEntryPoint +fun interface RolesAndPermissionsEntryPoint : FeatureEntryPoint { + interface Callback : Plugin { + fun onDone() + } + + fun createNode( + parentNode: Node, + buildContext: BuildContext, + callback: Callback, + ): Node +} diff --git a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/DefaultRolesAndPermissionsEntryPoint.kt b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/DefaultRolesAndPermissionsEntryPoint.kt index 2f281a596ac..5f273658579 100644 --- a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/DefaultRolesAndPermissionsEntryPoint.kt +++ b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/DefaultRolesAndPermissionsEntryPoint.kt @@ -17,7 +17,11 @@ import io.element.android.libraries.di.RoomScope @ContributesBinding(RoomScope::class) class DefaultRolesAndPermissionsEntryPoint : RolesAndPermissionsEntryPoint { - override fun createNode(parentNode: Node, buildContext: BuildContext): Node { - return parentNode.createNode(buildContext) + override fun createNode( + parentNode: Node, + buildContext: BuildContext, + callback: RolesAndPermissionsEntryPoint.Callback, + ): Node { + return parentNode.createNode(buildContext, listOf(callback)) } } diff --git a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/RolesAndPermissionsFlowNode.kt b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/RolesAndPermissionsFlowNode.kt index 5966a4f0eda..4bd88071c31 100644 --- a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/RolesAndPermissionsFlowNode.kt +++ b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/RolesAndPermissionsFlowNode.kt @@ -14,7 +14,10 @@ import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.lifecycle.Lifecycle import androidx.lifecycle.coroutineScope +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.plugin.Plugin @@ -25,17 +28,24 @@ import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedInject import io.element.android.annotations.ContributesNode import io.element.android.features.rolesandpermissions.api.ChangeRoomMemberRolesListType +import io.element.android.features.rolesandpermissions.api.RolesAndPermissionsEntryPoint import io.element.android.features.rolesandpermissions.impl.permissions.ChangeRoomPermissionsNode import io.element.android.features.rolesandpermissions.impl.roles.ChangeRolesNode import io.element.android.features.rolesandpermissions.impl.root.RolesAndPermissionsNode import io.element.android.libraries.architecture.BackstackView import io.element.android.libraries.architecture.BaseFlowNode +import io.element.android.libraries.architecture.callback import io.element.android.libraries.architecture.createNode import io.element.android.libraries.designsystem.components.async.AsyncIndicator import io.element.android.libraries.designsystem.components.async.AsyncIndicatorHost import io.element.android.libraries.designsystem.components.async.AsyncIndicatorState import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.matrix.api.room.JoinedRoom +import io.element.android.libraries.matrix.api.room.powerlevels.canEditRolesAndPermissions +import io.element.android.libraries.matrix.api.room.powerlevels.permissionsFlow import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.parcelize.Parcelize @@ -44,6 +54,7 @@ import kotlinx.parcelize.Parcelize class RolesAndPermissionsFlowNode( @Assisted buildContext: BuildContext, @Assisted plugins: List, + private val room: JoinedRoom, ) : BaseFlowNode( backstack = BackStack( initialElement = NavTarget.Root, @@ -66,6 +77,7 @@ class RolesAndPermissionsFlowNode( data object ChangeRoomPermissions : NavTarget } + private val callback: RolesAndPermissionsEntryPoint.Callback = callback() private val asyncIndicatorState = AsyncIndicatorState() override fun onBuilt() { @@ -76,6 +88,15 @@ class RolesAndPermissionsFlowNode( onChangeComplete(changesSaved) } } + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.CREATED) { + room.permissionsFlow(false) { perms -> perms.canEditRolesAndPermissions() } + .filter { canEdit -> !canEdit } + .first() + // If the user can no longer edit roles and permissions, exit the flow + callback.onDone() + } + } } private fun onChangeComplete(changesSaved: Boolean) { diff --git a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/analytics/AnalyticUtils.kt b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/analytics/AnalyticUtils.kt index 9963a751f10..546d54c21ae 100644 --- a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/analytics/AnalyticUtils.kt +++ b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/analytics/AnalyticUtils.kt @@ -34,8 +34,8 @@ internal fun AnalyticsService.trackPermissionChangeAnalytics(initial: RoomPowerL if (updated.kick != initial?.kick) { capture(RoomModeration(RoomModeration.Action.ChangePermissionsKickMembers, analyticsMemberRoleForPowerLevel(updated.kick))) } - if (updated.sendEvents != initial?.sendEvents) { - capture(RoomModeration(RoomModeration.Action.ChangePermissionsSendMessages, analyticsMemberRoleForPowerLevel(updated.sendEvents))) + if (updated.eventsDefault != initial?.eventsDefault) { + capture(RoomModeration(RoomModeration.Action.ChangePermissionsSendMessages, analyticsMemberRoleForPowerLevel(updated.eventsDefault))) } if (updated.redactEvents != initial?.redactEvents) { capture(RoomModeration(RoomModeration.Action.ChangePermissionsRedactMessages, analyticsMemberRoleForPowerLevel(updated.redactEvents))) diff --git a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/permissions/ChangeRoomPermissionsPresenter.kt b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/permissions/ChangeRoomPermissionsPresenter.kt index b356ca33d20..552f1d0ec64 100644 --- a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/permissions/ChangeRoomPermissionsPresenter.kt +++ b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/permissions/ChangeRoomPermissionsPresenter.kt @@ -10,6 +10,7 @@ package io.element.android.features.rolesandpermissions.impl.permissions import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -20,9 +21,11 @@ import dev.zacsweers.metro.Inject import io.element.android.features.rolesandpermissions.impl.analytics.trackPermissionChangeAnalytics import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.core.coroutine.mapState import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevelsValues +import io.element.android.libraries.matrix.ui.model.powerLevelOf import io.element.android.services.analytics.api.AnalyticsService import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableMap @@ -36,8 +39,7 @@ class ChangeRoomPermissionsPresenter( ) : Presenter { companion object { private fun itemsForSection(section: RoomPermissionsSection) = when (section) { - RoomPermissionsSection.SpaceDetails, - RoomPermissionsSection.RoomDetails -> persistentListOf( + RoomPermissionsSection.EditDetails -> persistentListOf( RoomPermissionType.ROOM_NAME, RoomPermissionType.ROOM_AVATAR, RoomPermissionType.ROOM_TOPIC, @@ -46,19 +48,22 @@ class ChangeRoomPermissionsPresenter( RoomPermissionType.SEND_EVENTS, RoomPermissionType.REDACT_EVENTS, ) - RoomPermissionsSection.MembershipModeration -> persistentListOf( + RoomPermissionsSection.ManageMembers -> persistentListOf( RoomPermissionType.INVITE, RoomPermissionType.KICK, RoomPermissionType.BAN, ) + RoomPermissionsSection.ManageSpace -> persistentListOf( + RoomPermissionType.SPACE_MANAGE_ROOMS, + ) } private fun RoomPermissionsSection.shouldShow(isSpace: Boolean): Boolean { return when (this) { - RoomPermissionsSection.RoomDetails -> !isSpace - RoomPermissionsSection.MembershipModeration -> true + RoomPermissionsSection.EditDetails -> true + RoomPermissionsSection.ManageMembers -> true RoomPermissionsSection.MessagesAndContent -> !isSpace - RoomPermissionsSection.SpaceDetails -> isSpace + RoomPermissionsSection.ManageSpace -> isSpace } } @@ -73,8 +78,7 @@ class ChangeRoomPermissionsPresenter( private var initialPermissions by mutableStateOf(null) private var currentPermissions by mutableStateOf(null) - private var saveAction by mutableStateOf>(AsyncAction.Uninitialized) - private var confirmExitAction by mutableStateOf>(AsyncAction.Uninitialized) + private var saveAction by mutableStateOf>(AsyncAction.Uninitialized) @Composable override fun present(): ChangeRoomPermissionsState { @@ -88,6 +92,10 @@ class ChangeRoomPermissionsPresenter( derivedStateOf { initialPermissions != currentPermissions } } + val ownPowerLevel by remember { + room.roomInfoFlow.mapState { it.powerLevelOf(room.sessionId) } + }.collectAsState() + fun handleEvent(event: ChangeRoomPermissionsEvent) { when (event) { is ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction -> { @@ -100,33 +108,33 @@ class ChangeRoomPermissionsPresenter( RoomPermissionType.BAN -> currentPermissions?.copy(ban = powerLevel) RoomPermissionType.INVITE -> currentPermissions?.copy(invite = powerLevel) RoomPermissionType.KICK -> currentPermissions?.copy(kick = powerLevel) - RoomPermissionType.SEND_EVENTS -> currentPermissions?.copy(sendEvents = powerLevel) + RoomPermissionType.SEND_EVENTS -> currentPermissions?.copy(eventsDefault = powerLevel) RoomPermissionType.REDACT_EVENTS -> currentPermissions?.copy(redactEvents = powerLevel) RoomPermissionType.ROOM_NAME -> currentPermissions?.copy(roomName = powerLevel) RoomPermissionType.ROOM_AVATAR -> currentPermissions?.copy(roomAvatar = powerLevel) RoomPermissionType.ROOM_TOPIC -> currentPermissions?.copy(roomTopic = powerLevel) + RoomPermissionType.SPACE_MANAGE_ROOMS -> currentPermissions?.copy(spaceChild = powerLevel) } } is ChangeRoomPermissionsEvent.Save -> coroutineScope.save() is ChangeRoomPermissionsEvent.Exit -> { - confirmExitAction = if (!hasChanges || confirmExitAction.isConfirming()) { - AsyncAction.Success(Unit) + saveAction = if (!hasChanges || saveAction == AsyncAction.ConfirmingCancellation) { + AsyncAction.Success(false) } else { - AsyncAction.ConfirmingNoParams + AsyncAction.ConfirmingCancellation } } is ChangeRoomPermissionsEvent.ResetPendingActions -> { saveAction = AsyncAction.Uninitialized - confirmExitAction = AsyncAction.Uninitialized } } } return ChangeRoomPermissionsState( + ownPowerLevel = ownPowerLevel, currentPermissions = currentPermissions, itemsBySection = itemsBySection, hasChanges = hasChanges, saveAction = saveAction, - confirmExitAction = confirmExitAction, eventSink = ::handleEvent, ) } @@ -147,7 +155,7 @@ class ChangeRoomPermissionsPresenter( .onSuccess { analyticsService.trackPermissionChangeAnalytics(initialPermissions, updatedRoomPowerLevels) initialPermissions = currentPermissions - saveAction = AsyncAction.Success(Unit) + saveAction = AsyncAction.Success(true) } .onFailure { saveAction = AsyncAction.Failure(it) diff --git a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/permissions/ChangeRoomPermissionsState.kt b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/permissions/ChangeRoomPermissionsState.kt index 2dc2c816d60..535f2b4a71b 100644 --- a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/permissions/ChangeRoomPermissionsState.kt +++ b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/permissions/ChangeRoomPermissionsState.kt @@ -18,41 +18,62 @@ import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevelsValues import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableMap +import kotlinx.collections.immutable.persistentListOf data class ChangeRoomPermissionsState( + private val ownPowerLevel: Long, val currentPermissions: RoomPowerLevelsValues?, val itemsBySection: ImmutableMap>, val hasChanges: Boolean, - val saveAction: AsyncAction, - val confirmExitAction: AsyncAction, + val saveAction: AsyncAction, val eventSink: (ChangeRoomPermissionsEvent) -> Unit, ) { + private val ownRole = RoomMember.Role.forPowerLevel(ownPowerLevel) + + // Roles that the user can select based on their own role + val selectableRoles: ImmutableList = when (ownRole) { + is RoomMember.Role.Owner, + RoomMember.Role.Admin -> persistentListOf(SelectableRole.Admin, SelectableRole.Moderator, SelectableRole.Everyone) + RoomMember.Role.Moderator -> persistentListOf(SelectableRole.Moderator, SelectableRole.Everyone) + RoomMember.Role.User -> persistentListOf(SelectableRole.Everyone) + } + fun selectedRoleForType(type: RoomPermissionType): SelectableRole? { - if (currentPermissions == null) return null - val role = when (type) { - RoomPermissionType.BAN -> RoomMember.Role.forPowerLevel(currentPermissions.ban) - RoomPermissionType.INVITE -> RoomMember.Role.forPowerLevel(currentPermissions.invite) - RoomPermissionType.KICK -> RoomMember.Role.forPowerLevel(currentPermissions.kick) - RoomPermissionType.SEND_EVENTS -> RoomMember.Role.forPowerLevel(currentPermissions.sendEvents) - RoomPermissionType.REDACT_EVENTS -> RoomMember.Role.forPowerLevel(currentPermissions.redactEvents) - RoomPermissionType.ROOM_NAME -> RoomMember.Role.forPowerLevel(currentPermissions.roomName) - RoomPermissionType.ROOM_AVATAR -> RoomMember.Role.forPowerLevel(currentPermissions.roomAvatar) - RoomPermissionType.ROOM_TOPIC -> RoomMember.Role.forPowerLevel(currentPermissions.roomTopic) - } - return when (role) { + val powerLevel = currentPowerLevelForType(type = type) ?: return null + return when (RoomMember.Role.forPowerLevel(powerLevel)) { is RoomMember.Role.Owner, RoomMember.Role.Admin -> SelectableRole.Admin RoomMember.Role.Moderator -> SelectableRole.Moderator RoomMember.Role.User -> SelectableRole.Everyone } } + + fun canChangePermission(type: RoomPermissionType): Boolean { + val currentPowerLevel = currentPowerLevelForType(type) ?: return false + return ownPowerLevel >= currentPowerLevel + } + + private fun currentPowerLevelForType(type: RoomPermissionType): Long? { + if (currentPermissions == null) return null + return when (type) { + RoomPermissionType.BAN -> currentPermissions.ban + RoomPermissionType.INVITE -> currentPermissions.invite + RoomPermissionType.KICK -> currentPermissions.kick + RoomPermissionType.SEND_EVENTS -> currentPermissions.eventsDefault + RoomPermissionType.REDACT_EVENTS -> currentPermissions.redactEvents + RoomPermissionType.ROOM_NAME -> currentPermissions.roomName + RoomPermissionType.ROOM_AVATAR -> currentPermissions.roomAvatar + RoomPermissionType.ROOM_TOPIC -> currentPermissions.roomTopic + RoomPermissionType.SPACE_MANAGE_ROOMS -> currentPermissions.spaceChild + } + } } enum class RoomPermissionsSection { - SpaceDetails, - RoomDetails, + ManageMembers, + EditDetails, MessagesAndContent, - MembershipModeration, + ManageSpace } enum class SelectableRole : DropdownOption { @@ -81,5 +102,6 @@ enum class RoomPermissionType { REDACT_EVENTS, ROOM_NAME, ROOM_AVATAR, - ROOM_TOPIC + ROOM_TOPIC, + SPACE_MANAGE_ROOMS, } diff --git a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/permissions/ChangeRoomPermissionsStateProvider.kt b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/permissions/ChangeRoomPermissionsStateProvider.kt index d64c85f8cfd..2760272d8a4 100644 --- a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/permissions/ChangeRoomPermissionsStateProvider.kt +++ b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/permissions/ChangeRoomPermissionsStateProvider.kt @@ -19,29 +19,31 @@ class ChangeRoomPermissionsStateProvider : PreviewParameterProvider get() = sequenceOf( aChangeRoomPermissionsState(), + aChangeRoomPermissionsState(ownPowerLevel = RoomMember.Role.Moderator.powerLevel), aChangeRoomPermissionsState(hasChanges = true), aChangeRoomPermissionsState(hasChanges = true, saveAction = AsyncAction.Loading), aChangeRoomPermissionsState( hasChanges = true, saveAction = AsyncAction.Failure(IllegalStateException("Failed to save changes")) ), - aChangeRoomPermissionsState(hasChanges = true, confirmExitAction = AsyncAction.ConfirmingNoParams), + aChangeRoomPermissionsState(hasChanges = true, saveAction = AsyncAction.ConfirmingCancellation), + aChangeRoomPermissionsState(itemsBySection = ChangeRoomPermissionsPresenter.buildItems(isSpace = true)), ) } internal fun aChangeRoomPermissionsState( + ownPowerLevel: Long = RoomMember.Role.Admin.powerLevel, currentPermissions: RoomPowerLevelsValues = previewPermissions(), itemsBySection: Map> = ChangeRoomPermissionsPresenter.buildItems(false), hasChanges: Boolean = false, - saveAction: AsyncAction = AsyncAction.Uninitialized, - confirmExitAction: AsyncAction = AsyncAction.Uninitialized, + saveAction: AsyncAction = AsyncAction.Uninitialized, eventSink: (ChangeRoomPermissionsEvent) -> Unit = {}, ) = ChangeRoomPermissionsState( + ownPowerLevel = ownPowerLevel, currentPermissions = currentPermissions, itemsBySection = itemsBySection.toImmutableMap(), hasChanges = hasChanges, saveAction = saveAction, - confirmExitAction = confirmExitAction, eventSink = eventSink, ) @@ -53,12 +55,13 @@ private fun previewPermissions(): RoomPowerLevelsValues { ban = RoomMember.Role.User.powerLevel, // MessagesAndContent section redactEvents = RoomMember.Role.Moderator.powerLevel, - sendEvents = RoomMember.Role.Admin.powerLevel, + eventsDefault = RoomMember.Role.Admin.powerLevel, // RoomDetails section roomName = RoomMember.Role.Admin.powerLevel, roomAvatar = RoomMember.Role.Moderator.powerLevel, roomTopic = RoomMember.Role.User.powerLevel, // SpaceManagement section spaceChild = RoomMember.Role.Moderator.powerLevel, + stateDefault = RoomMember.Role.Moderator.powerLevel, ) } 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 1e88d091c84..529df0d50d6 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 @@ -18,9 +18,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.PreviewParameter import io.element.android.features.rolesandpermissions.impl.R +import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.designsystem.components.async.AsyncActionView import io.element.android.libraries.designsystem.components.button.BackButton -import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog +import io.element.android.libraries.designsystem.components.dialogs.SaveChangesDialog import io.element.android.libraries.designsystem.components.preferences.PreferenceDropdown import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight @@ -29,7 +30,6 @@ import io.element.android.libraries.designsystem.theme.components.Scaffold 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 -import kotlinx.collections.immutable.toImmutableList @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -73,7 +73,8 @@ fun ChangeRoomPermissionsView( PreferenceDropdown( title = titleForType(permissionType), selectedOption = state.selectedRoleForType(permissionType), - options = SelectableRole.entries.toImmutableList(), + options = state.selectableRoles, + enabled = state.canChangePermission(permissionType), onSelectOption = { role -> state.eventSink( ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction( @@ -91,33 +92,28 @@ fun ChangeRoomPermissionsView( AsyncActionView( async = state.saveAction, - onSuccess = { onComplete(true) }, - onErrorDismiss = { state.eventSink(ChangeRoomPermissionsEvent.ResetPendingActions) } - ) - - AsyncActionView( - async = state.confirmExitAction, - onSuccess = { onComplete(false) }, - confirmationDialog = { - ConfirmationDialog( - title = stringResource(R.string.screen_room_change_role_unsaved_changes_title), - content = stringResource(R.string.screen_room_change_role_unsaved_changes_description), - submitText = stringResource(CommonStrings.action_save), - cancelText = stringResource(CommonStrings.action_discard), - onSubmitClick = { state.eventSink(ChangeRoomPermissionsEvent.Save) }, - onDismiss = { state.eventSink(ChangeRoomPermissionsEvent.Exit) } - ) + onSuccess = { onComplete(it) }, + confirmationDialog = { confirming -> + when (confirming) { + is AsyncAction.ConfirmingCancellation -> { + SaveChangesDialog( + onSaveClick = { state.eventSink(ChangeRoomPermissionsEvent.Save) }, + onDiscardClick = { state.eventSink(ChangeRoomPermissionsEvent.Exit) }, + onDismiss = { state.eventSink(ChangeRoomPermissionsEvent.ResetPendingActions) }, + ) + } + } }, - onErrorDismiss = {}, + onErrorDismiss = { state.eventSink(ChangeRoomPermissionsEvent.ResetPendingActions) } ) } @Composable private fun titleForSection(section: RoomPermissionsSection): String = when (section) { - RoomPermissionsSection.SpaceDetails -> stringResource(R.string.screen_room_roles_and_permissions_space_details) - RoomPermissionsSection.RoomDetails -> stringResource(R.string.screen_room_roles_and_permissions_room_details) - RoomPermissionsSection.MessagesAndContent -> stringResource(R.string.screen_room_roles_and_permissions_messages_and_content) - RoomPermissionsSection.MembershipModeration -> stringResource(R.string.screen_room_roles_and_permissions_member_moderation) + RoomPermissionsSection.EditDetails -> stringResource(R.string.screen_room_change_permissions_room_details) + RoomPermissionsSection.MessagesAndContent -> stringResource(R.string.screen_room_change_permissions_messages_and_content) + RoomPermissionsSection.ManageMembers -> stringResource(R.string.screen_room_change_permissions_member_moderation) + RoomPermissionsSection.ManageSpace -> stringResource(R.string.screen_room_change_permissions_manage_space) } @Composable @@ -130,6 +126,7 @@ private fun titleForType(type: RoomPermissionType): String = when (type) { RoomPermissionType.ROOM_NAME -> stringResource(R.string.screen_room_change_permissions_room_name) RoomPermissionType.ROOM_AVATAR -> stringResource(R.string.screen_room_change_permissions_room_avatar) RoomPermissionType.ROOM_TOPIC -> stringResource(R.string.screen_room_change_permissions_room_topic) + RoomPermissionType.SPACE_MANAGE_ROOMS -> stringResource(R.string.screen_room_change_permissions_manage_space_rooms) } @PreviewsDayNight diff --git a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/roles/ChangeRolesPresenter.kt b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/roles/ChangeRolesPresenter.kt index 974f79aa8b2..2f232d5642f 100644 --- a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/roles/ChangeRolesPresenter.kt +++ b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/roles/ChangeRolesPresenter.kt @@ -39,6 +39,7 @@ import io.element.android.libraries.matrix.api.room.powerlevels.UserRoleChange import io.element.android.libraries.matrix.api.room.powerlevels.usersWithRole import io.element.android.libraries.matrix.api.room.toMatrixUser import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.ui.model.powerLevelOf import io.element.android.libraries.matrix.ui.model.roleOf import io.element.android.libraries.matrix.ui.room.PowerLevelRoomMemberComparator import io.element.android.services.analytics.api.AnalyticsService @@ -47,7 +48,7 @@ import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach @@ -83,18 +84,13 @@ class ChangeRolesPresenter( val usersWithRole = produceState>(initialValue = persistentListOf()) { // If the role is admin, we need to include the owners as well since they implicitly have admin role val owners = if (role == RoomMember.Role.Admin) { - combine( - room.usersWithRole(RoomMember.Role.Owner(isCreator = true)), - room.usersWithRole(RoomMember.Role.Owner(isCreator = false)), - ) { creators, superAdmins -> - creators + superAdmins - } + room.usersWithRole { role -> role is RoomMember.Role.Owner } } else { - emptyFlow() + flowOf(persistentListOf()) } combine( owners, - room.usersWithRole(role), + room.usersWithRole { it == role }, ) { owners, users -> owners + users }.map { members -> members.map { it.toMatrixUser() } } @@ -138,9 +134,10 @@ class ChangeRolesPresenter( val roomInfo by room.roomInfoFlow.collectAsState() fun canChangeMemberRole(userId: UserId): Boolean { - val currentUserRole = roomInfo.roleOf(room.sessionId) - val otherUserRole = roomInfo.roleOf(userId) - return currentUserRole.powerLevel > otherUserRole.powerLevel + val currentUserPowerLevel = roomInfo.powerLevelOf(room.sessionId) + val otherUserPowerLevel = roomInfo.powerLevelOf(userId) + return currentUserPowerLevel > otherUserPowerLevel && + currentUserPowerLevel >= role.powerLevel } fun handleEvent(event: ChangeRolesEvent) { 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 fcfdd42ffaf..7f7333199f1 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 @@ -174,8 +174,9 @@ fun ChangeRolesView( when (confirming) { is AsyncAction.ConfirmingCancellation -> { SaveChangesDialog( - onSubmitClick = { state.eventSink(ChangeRolesEvent.Exit) }, - onDismiss = { state.eventSink(ChangeRolesEvent.CloseDialog) } + onSaveClick = { state.eventSink(ChangeRolesEvent.Save) }, + onDiscardClick = { state.eventSink(ChangeRolesEvent.Exit) }, + onDismiss = { state.eventSink(ChangeRolesEvent.CloseDialog) }, ) } is ConfirmingModifyingOwners -> { diff --git a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/root/RolesAndPermissionsNode.kt b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/root/RolesAndPermissionsNode.kt index 4469eb8f378..da69ee52a9a 100644 --- a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/root/RolesAndPermissionsNode.kt +++ b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/root/RolesAndPermissionsNode.kt @@ -11,7 +11,6 @@ package io.element.android.features.rolesandpermissions.impl.root import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable import androidx.compose.ui.Modifier -import androidx.lifecycle.lifecycleScope import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.plugin.Plugin @@ -20,14 +19,6 @@ import dev.zacsweers.metro.AssistedInject import io.element.android.annotations.ContributesNode import io.element.android.libraries.architecture.callback import io.element.android.libraries.di.RoomScope -import io.element.android.libraries.matrix.api.room.BaseRoom -import io.element.android.libraries.matrix.api.room.RoomMember -import io.element.android.libraries.matrix.ui.model.roleOf -import kotlinx.coroutines.flow.collect -import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.onEach -import kotlinx.coroutines.flow.take -import kotlinx.coroutines.launch @ContributesNode(RoomScope::class) @AssistedInject @@ -35,7 +26,6 @@ class RolesAndPermissionsNode( @Assisted buildContext: BuildContext, @Assisted plugins: List, private val presenter: RolesAndPermissionsPresenter, - private val room: BaseRoom, ) : Node(buildContext, plugins = plugins), RolesAndPermissionsNavigator { interface Callback : Plugin, RolesAndPermissionsNavigator { override fun openAdminList() @@ -54,22 +44,6 @@ class RolesAndPermissionsNode( } } - override fun onBuilt() { - super.onBuilt() - - // If the user is not an admin anymore, exit this section since they won't have permissions to use it - lifecycleScope.launch { - room.roomInfoFlow - .filter { info -> - val role = info.roleOf(room.sessionId) - role != RoomMember.Role.Admin && role !is RoomMember.Role.Owner - } - .take(1) - .onEach { navigateUp() } - .collect() - } - } - @Composable override fun View(modifier: Modifier) { val state = presenter.present() diff --git a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/root/RolesAndPermissionsPresenter.kt b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/root/RolesAndPermissionsPresenter.kt index 2ade971a663..dd3a59b99ef 100644 --- a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/root/RolesAndPermissionsPresenter.kt +++ b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/root/RolesAndPermissionsPresenter.kt @@ -22,14 +22,13 @@ import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.runUpdatingState import io.element.android.libraries.core.coroutine.CoroutineDispatchers -import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.room.JoinedRoom -import io.element.android.libraries.matrix.api.room.RoomInfo import io.element.android.libraries.matrix.api.room.RoomMember -import io.element.android.libraries.matrix.api.room.activeRoomMembers import io.element.android.libraries.matrix.api.room.powerlevels.UserRoleChange +import io.element.android.libraries.matrix.api.room.powerlevels.userCountWithRole import io.element.android.libraries.matrix.ui.model.roleOf import io.element.android.services.analytics.api.AnalyticsService +import kotlinx.collections.immutable.persistentListOf import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -43,33 +42,24 @@ class RolesAndPermissionsPresenter( override fun present(): RolesAndPermissionsState { val coroutineScope = rememberCoroutineScope() val roomInfo by room.roomInfoFlow.collectAsState() - val roomMembers by room.membersStateFlow.collectAsState() - // Get the list of active room members (joined or invited), in order to filter members present in the power - // level state Event. - val activeRoomMemberIds by remember { - derivedStateOf { - roomMembers.activeRoomMembers().map { it.userId } - } - } val moderatorCount by remember { - derivedStateOf { - roomInfo.userCountWithRole(activeRoomMemberIds, RoomMember.Role.Moderator) - } - } + room.userCountWithRole { role -> role is RoomMember.Role.Moderator } + }.collectAsState(null) + val adminCount by remember { + room.userCountWithRole { role -> role is RoomMember.Role.Admin || role is RoomMember.Role.Owner } + }.collectAsState(null) + + val availableDemoteActions by remember { derivedStateOf { - val admins = roomInfo.userCountWithRole(activeRoomMemberIds, RoomMember.Role.Admin) - val ownersCount = if (roomInfo.privilegedCreatorRole) { - val superAdmins = roomInfo.userCountWithRole(activeRoomMemberIds, RoomMember.Role.Owner(isCreator = false)) - val creators = roomInfo.userCountWithRole(activeRoomMemberIds, RoomMember.Role.Owner(isCreator = true)) - superAdmins + creators - } else { - 0 + val currentRole = roomInfo.roleOf(room.sessionId) + when (currentRole) { + is RoomMember.Role.Admin -> persistentListOf(SelfDemoteAction.ToModerator, SelfDemoteAction.ToMember) + is RoomMember.Role.Moderator -> persistentListOf(SelfDemoteAction.ToMember) + else -> persistentListOf() } - admins + ownersCount } } - val canDemoteSelf = remember { derivedStateOf { roomInfo.roleOf(room.sessionId) !is RoomMember.Role.Owner } } val changeOwnRoleAction = remember { mutableStateOf>(AsyncAction.Uninitialized) } val resetPermissionsAction = remember { mutableStateOf>(AsyncAction.Uninitialized) } @@ -98,7 +88,7 @@ class RolesAndPermissionsPresenter( roomSupportsOwnerRole = roomInfo.privilegedCreatorRole, adminCount = adminCount, moderatorCount = moderatorCount, - canDemoteSelf = canDemoteSelf.value, + availableSelfDemoteActions = availableDemoteActions, changeOwnRoleAction = changeOwnRoleAction.value, resetPermissionsAction = resetPermissionsAction.value, eventSink = ::handleEvent, @@ -122,8 +112,4 @@ class RolesAndPermissionsPresenter( room.resetPowerLevels() } } - - private fun RoomInfo.userCountWithRole(userIds: List, role: RoomMember.Role): Int { - return usersWithRole(role).filter { it in userIds }.size - } } diff --git a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/root/RolesAndPermissionsState.kt b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/root/RolesAndPermissionsState.kt index 3fc94f99e9f..626ad3b6999 100644 --- a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/root/RolesAndPermissionsState.kt +++ b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/root/RolesAndPermissionsState.kt @@ -8,14 +8,24 @@ package io.element.android.features.rolesandpermissions.impl.root +import io.element.android.features.rolesandpermissions.impl.R import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.api.room.RoomMember +import kotlinx.collections.immutable.ImmutableList data class RolesAndPermissionsState( val roomSupportsOwnerRole: Boolean, - val adminCount: Int, - val moderatorCount: Int, - val canDemoteSelf: Boolean, + val adminCount: Int?, + val moderatorCount: Int?, + val availableSelfDemoteActions: ImmutableList, val changeOwnRoleAction: AsyncAction, val resetPermissionsAction: AsyncAction, val eventSink: (RolesAndPermissionsEvents) -> Unit, -) +) { + val canSelfDemote = availableSelfDemoteActions.isNotEmpty() +} + +enum class SelfDemoteAction(val role: RoomMember.Role, val titleRes: Int) { + ToModerator(RoomMember.Role.Moderator, R.string.screen_room_roles_and_permissions_change_role_demote_to_moderator), + ToMember(RoomMember.Role.User, R.string.screen_room_roles_and_permissions_change_role_demote_to_member) +} diff --git a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/root/RolesAndPermissionsStateProvider.kt b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/root/RolesAndPermissionsStateProvider.kt index 23448c03514..45bd72db190 100644 --- a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/root/RolesAndPermissionsStateProvider.kt +++ b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/root/RolesAndPermissionsStateProvider.kt @@ -10,6 +10,7 @@ package io.element.android.features.rolesandpermissions.impl.root import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.libraries.architecture.AsyncAction +import kotlinx.collections.immutable.toImmutableList class RolesAndPermissionsStateProvider : PreviewParameterProvider { override val values: Sequence @@ -46,7 +47,7 @@ class RolesAndPermissionsStateProvider : PreviewParameterProvider = listOf(SelfDemoteAction.ToModerator, SelfDemoteAction.ToMember), changeOwnRoleAction: AsyncAction = AsyncAction.Uninitialized, resetPermissionsAction: AsyncAction = AsyncAction.Uninitialized, eventSink: (RolesAndPermissionsEvents) -> Unit = {}, ) = RolesAndPermissionsState( roomSupportsOwnerRole = roomSupportsOwners, adminCount = adminCount, - canDemoteSelf = canDemoteSelf, + availableSelfDemoteActions = availableSelfDemoteActions.toImmutableList(), moderatorCount = moderatorCount, changeOwnRoleAction = changeOwnRoleAction, resetPermissionsAction = resetPermissionsAction, diff --git a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/root/RolesAndPermissionsView.kt b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/root/RolesAndPermissionsView.kt index 189ad83a5c8..269fdee6641 100644 --- a/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/root/RolesAndPermissionsView.kt +++ b/features/rolesandpermissions/impl/src/main/kotlin/io/element/android/features/rolesandpermissions/impl/root/RolesAndPermissionsView.kt @@ -39,8 +39,8 @@ import io.element.android.libraries.designsystem.theme.components.ListSectionHea import io.element.android.libraries.designsystem.theme.components.ModalBottomSheet import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.hide -import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.collections.immutable.ImmutableList @Composable fun RolesAndPermissionsView( @@ -63,16 +63,20 @@ fun RolesAndPermissionsView( ListItem( headlineContent = { Text(adminsTitle) }, leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Admin())), - trailingContent = ListItemContent.Text("${state.adminCount}"), + trailingContent = state.adminCount?.let { adminCount -> + ListItemContent.Text("$adminCount") + }, onClick = { rolesAndPermissionsNavigator.openAdminList() }, ) ListItem( headlineContent = { Text(stringResource(R.string.screen_room_roles_and_permissions_moderators)) }, leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.ChatProblem())), - trailingContent = ListItemContent.Text("${state.moderatorCount}"), + trailingContent = state.moderatorCount?.let { moderationCount -> + ListItemContent.Text("$moderationCount") + }, onClick = { rolesAndPermissionsNavigator.openModeratorList() }, ) - if (state.canDemoteSelf) { + if (state.canSelfDemote) { ListItem( headlineContent = { Text(stringResource(R.string.screen_room_roles_and_permissions_change_my_role)) }, onClick = { state.eventSink(RolesAndPermissionsEvents.ChangeOwnRole) }, @@ -113,6 +117,7 @@ fun RolesAndPermissionsView( when (state.changeOwnRoleAction) { is AsyncAction.Confirming -> { ChangeOwnRoleBottomSheet( + availableDemoteActions = state.availableSelfDemoteActions, eventSink = state.eventSink, ) } @@ -132,6 +137,7 @@ fun RolesAndPermissionsView( @OptIn(ExperimentalMaterial3Api::class) @Composable private fun ChangeOwnRoleBottomSheet( + availableDemoteActions: ImmutableList, eventSink: (RolesAndPermissionsEvents) -> Unit, ) { val coroutineScope = rememberCoroutineScope() @@ -160,24 +166,17 @@ private fun ChangeOwnRoleBottomSheet( style = ElementTheme.typography.fontBodyLgRegular, color = ElementTheme.colors.textPrimary, ) - ListItem( - headlineContent = { Text(stringResource(R.string.screen_room_roles_and_permissions_change_role_demote_to_moderator)) }, - onClick = { - sheetState.hide(coroutineScope) { - eventSink(RolesAndPermissionsEvents.DemoteSelfTo(RoomMember.Role.Moderator)) - } - }, - style = ListItemStyle.Destructive, - ) - ListItem( - headlineContent = { Text(stringResource(R.string.screen_room_roles_and_permissions_change_role_demote_to_member)) }, - onClick = { - sheetState.hide(coroutineScope) { - eventSink(RolesAndPermissionsEvents.DemoteSelfTo(RoomMember.Role.User)) - } - }, - style = ListItemStyle.Destructive, - ) + for (demoteAction in availableDemoteActions) { + ListItem( + headlineContent = { Text(stringResource(demoteAction.titleRes)) }, + onClick = { + sheetState.hide(coroutineScope) { + eventSink(RolesAndPermissionsEvents.DemoteSelfTo(demoteAction.role)) + } + }, + style = ListItemStyle.Destructive, + ) + } ListItem( headlineContent = { Text(stringResource(CommonStrings.action_cancel)) }, onClick = ::dismiss, diff --git a/features/rolesandpermissions/impl/src/main/res/values-cs/translations.xml b/features/rolesandpermissions/impl/src/main/res/values-cs/translations.xml index 3df8e0afbbf..87936de3a71 100644 --- a/features/rolesandpermissions/impl/src/main/res/values-cs/translations.xml +++ b/features/rolesandpermissions/impl/src/main/res/values-cs/translations.xml @@ -2,9 +2,12 @@ "Správce" "Vykázat lidi" + "Změnit nastavení" "Odstranit zprávy" "Člen" "Pozvat přátele" + "Správa prostoru" + "Spravovat místnosti" "Spravovat členy" "Zprávy a obsah" "Moderátor" @@ -14,6 +17,7 @@ "Změnit název místnosti" "Změnit téma místnosti" "Odeslat zprávy" + "Oprávnění" "Upravit správce" "Tuto akci nebudete moci vrátit zpět. Upravujete oprávnění uživatele, tak aby měl stejnou úroveň jako vy." "Přidat správce?" diff --git a/features/rolesandpermissions/impl/src/main/res/values-da/translations.xml b/features/rolesandpermissions/impl/src/main/res/values-da/translations.xml index 4a0b5d179d4..31aee3436e9 100644 --- a/features/rolesandpermissions/impl/src/main/res/values-da/translations.xml +++ b/features/rolesandpermissions/impl/src/main/res/values-da/translations.xml @@ -1,17 +1,23 @@ - "Kun admins" + "Administrator" "Spær brugere" + "Skift indstillinger" "Fjern beskeder" - "Invitér personer og acceptér anmodninger om at deltage" + "Medlem" + "Invitér andre" + "Administrér gruppe" + "Administrer rum" + "Administrer medlemmer" "Beskeder og indhold" - "Admins og moderatorer" - "Fjern personer og afvis anmodninger om at deltage" + "Moderator" + "Fjern personer" "Skift rummets avatar" - "Rediger rum" + "Redigér detaljer" "Skift rummets navn" "Skift emne for rummet" "Send beskeder" + "Tilladelser" "Redigér admins" "Du kan ikke fortryde denne handling. Du forfremmer brugeren til at have samme magtniveau som dig." "Tilføj Admin?" @@ -32,6 +38,12 @@ "Du har ændringer, der ikke er gemt." "Gem ændringer?" "Der er ingen spærrede brugere i dette rum." + + "%1$d Spærret" + "%1$d Spærret" + + "Tjek stavningen eller prøv en ny søgning" + "Ingen resultater for \"%1$s\"" "%1$d person" "%1$d personer" @@ -43,8 +55,13 @@ "Fjern brugerens spærring fra rummet" "Spærret" "Medlemmer" - "Kun admins" - "Admins og moderatorer" + + "%1$d Inviteret" + "%1$d Inviteret" + + "Afventer" + "Administrator" + "Moderator" "Ejeren" "Medlemmer af rummet" "Ophæver spærring af %1$s" @@ -57,10 +74,12 @@ "Beskeder og indhold" "Moderatorer" "Ejere" + "Tilladelser" "Nulstil tilladelser" "Når du nulstiller tilladelserne, mister du de nuværende indstillinger." "Nulstil tilladelser?" "Roller" "Detaljer om rummet" + "Detaljer om gruppe" "Roller og tilladelser" diff --git a/features/rolesandpermissions/impl/src/main/res/values-de/translations.xml b/features/rolesandpermissions/impl/src/main/res/values-de/translations.xml index 2767781d2ba..f51f96672b8 100644 --- a/features/rolesandpermissions/impl/src/main/res/values-de/translations.xml +++ b/features/rolesandpermissions/impl/src/main/res/values-de/translations.xml @@ -1,17 +1,23 @@ - "Nur Admins" + "Admin" "Mitglieder sperren" + "Einstellungen ändern" "Nachrichten entfernen" - "Personen einladen und Beitrittsanfragen annehmen" + "Mitglied" + "Mitglieder hinzufügen" + "Space konfigurieren" + "Chats und Gruppen konfigurieren" + "Mitglieder verwalten" "Nachrichten senden & löschen" - "Admins und Moderatoren" - "Personen entfernen und Beitrittsanfragen ablehnen" + "Moderator" + "Mitglieder entfernen" "Avatar ändern" "Chat bearbeiten" "Chat-Namen ändern" "Chat Thema ändern" "Nachrichten senden" + "Berechtigungen" "Admins bearbeiten" "Du kannst diese Aktion nicht mehr rückgängig machen. Du vergibst dieselbe Rolle, die du auch hast." "Als Admin hinzufügen?" @@ -32,6 +38,12 @@ "Du hast nicht gespeicherte Änderungen." "Änderungen speichern?" "Es gibt keine gesperrten Nutzer." + + "%1$d gesperrt" + "%1$d gesperrt" + + "Überprüfe die Schreibweise oder versuch\'s mit einer neuen Suche" + "Keine Ergebnisse für „%1$s“" "%1$d Person" "%1$d Personen" @@ -43,8 +55,13 @@ "Sperre für diesen Chat aufheben" "Gesperrt" "Mitglieder" - "Nur Admins" - "Admins und Moderatoren" + + "%1$d eingeladen" + "%1$d eingeladen" + + "Ausstehend" + "Admin" + "Moderator" "Eigentümer" "Mitglieder" "%1$s wird entsperrt." @@ -57,10 +74,12 @@ "Nachrichten senden & löschen" "Moderatoren" "Eigentümer" - "Rollen und Berechtigungen zurücksetzen" + "Berechtigungen" + "Berechtigungen zurücksetzen" "Sobald du die Berechtigungen zurücksetzt, verlierst du die aktuellen Einstellungen." "Berechtigungen zurücksetzen?" "Rollen" "Chat-Details anpassen" + "Details zum Space" "Rollen und Berechtigungen" diff --git a/features/rolesandpermissions/impl/src/main/res/values-et/translations.xml b/features/rolesandpermissions/impl/src/main/res/values-et/translations.xml index 7924e7ce7ba..1c1f490580d 100644 --- a/features/rolesandpermissions/impl/src/main/res/values-et/translations.xml +++ b/features/rolesandpermissions/impl/src/main/res/values-et/translations.xml @@ -2,9 +2,12 @@ "Peakasutajad" "Suhtluskeelu seadmine" + "Muuda seadistusi" "Eemalda sõnumid" "Liikmed" "Osalejate kutsumine" + "Halda kogukonda" + "Halda jututuba" "Liikmete haldus" "Sõnumid ja sisu" "Moderaatorid" @@ -14,6 +17,7 @@ "Jututoa nime muutmine" "Jututoa teema muutmine" "Sõnumite saatmine" + "Õigused" "Muuda peakasutajaid" "Kuna sa annad teisele kasutajale sinu õigustega võrreldes samad õigused, siis sa ei saa seda muudatust hiljem tagasi pöörata." "Lisame peakasutaja?" @@ -34,6 +38,10 @@ "Sul on salvestamata muudatusi" "Kas salvestame muudatused?" "Suhtluskeeluga kasutajaid pole" + + "%1$d suhtluskeeluga kasutaja" + "%1$d suhtluskeeluga kasutajat" + "Palun kontrolli otsingusõna korrektsust ja proovi siis uuesti" "Otsingul „%1$s“ pole tulemusi" @@ -47,6 +55,10 @@ "Eemalda suhtluskeeld jututoas" "Suhtluskeeluga kasutajad" "Liikmed" + + "%1$d saatis kutse" + "%1$d saatis kutse" + "Ootel" "Peakasutajad" "Moderaatorid" diff --git a/features/rolesandpermissions/impl/src/main/res/values-fi/translations.xml b/features/rolesandpermissions/impl/src/main/res/values-fi/translations.xml index 1322089dc8a..29331bcd136 100644 --- a/features/rolesandpermissions/impl/src/main/res/values-fi/translations.xml +++ b/features/rolesandpermissions/impl/src/main/res/values-fi/translations.xml @@ -2,9 +2,12 @@ "Ylläpitäjä" "Porttikieltojen antaminen" + "Asetusten muuttaminen" "Viestien poistaminen" "Jäsen" "Ihmisten kutsuminen ja liittymispyyntöjen hyväksyminen" + "Tilan hallitseminen" + "Huoneiden hallitseminen" "Jäsenien hallinta" "Viestit ja sisältö" "Valvoja" @@ -14,6 +17,7 @@ "Huoneen nimen vaihtaminen" "Huoneen aiheen vaihtaminen" "Viestien lähettäminen" + "Oikeudet" "Muokkaa ylläpitäjiä" "Et voi peruuttaa tätä toimenpidettä. Ylennät käyttäjän samalle oikeustasolle kuin sinä." "Lisätäänkö ylläpitäjä?" diff --git a/features/rolesandpermissions/impl/src/main/res/values-fr/translations.xml b/features/rolesandpermissions/impl/src/main/res/values-fr/translations.xml index f44072366df..874b6710cd9 100644 --- a/features/rolesandpermissions/impl/src/main/res/values-fr/translations.xml +++ b/features/rolesandpermissions/impl/src/main/res/values-fr/translations.xml @@ -2,9 +2,12 @@ "Administrateurs" "Bannir des participants" + "Changer les paramètres" "Supprimer des messages" "Membre" "Inviter des personnes" + "Gérer l’espace" + "Gérer les salons" "Gérer les membres" "Messages et contenus" "Modérateurs" @@ -14,6 +17,7 @@ "Changer le nom du salon" "Changer le sujet du salon" "Envoyer des messages" + "Autorisations" "Modifier les administrateurs" "Vous ne pourrez pas annuler cette action. Vous êtes en train de promouvoir l’utilisateur pour qu’il ait le même niveau que vous." "Ajouter un administrateur ?" diff --git a/features/rolesandpermissions/impl/src/main/res/values-hr/translations.xml b/features/rolesandpermissions/impl/src/main/res/values-hr/translations.xml new file mode 100644 index 00000000000..7f24535124e --- /dev/null +++ b/features/rolesandpermissions/impl/src/main/res/values-hr/translations.xml @@ -0,0 +1,88 @@ + + + "Administrator" + "Zabrana pristupa osobama" + "Promijeni postavke" + "Uklanjanje poruka" + "Član" + "Pozivanje osoba" + "Upravljaj prostorom" + "Upravljaj sobama" + "Upravljanje članovima" + "Poruke i sadržaj" + "Moderator" + "Uklanjanje osoba" + "Promjena avatara" + "Uredi pojedinosti" + "Promjena imena" + "Promjena teme" + "Slanje poruka" + "Dopuštenja" + "Uredi administratore" + "Nećete moći poništiti ovu radnju. Postavit ćete da korisnik ima isti položaj kao i vi." + "Dodati administratora?" + "Nećete moći poništiti ovu radnju. Prenosite vlasništvo na odabrane korisnike. Nakon što odete, to će biti trajno." + "Želite li prenijeti vlasništvo?" + "Degradiraj" + "Nećete moći poništiti ovu promjenu jer sami sebe degradirate. Ako ste posljednji privilegirani korisnik u sobi, nećete moći ponovno dobiti privilegije." + "Želite li se degradirati?" + "%1$s (na čekanju)" + "(na čekanju)" + "Administratori automatski imaju moderatorske ovlasti" + "Vlasnici automatski imaju administratorske ovlasti." + "Uredi moderatore" + "Odaberi vlasnike" + "Administratori" + "Moderatori" + "Članovi" + "Niste spremili sve promjene." + "Želite li spremiti promjene?" + "Nema zabranjenih korisnika." + + "%1$d zabranjen" + "%1$d zabranjena" + "%1$d zabranjenih" + + "Provjerite pravopis ili pokušajte s novim pretraživanjem" + "Nema rezultata za “%1$s”" + + "%1$d osoba" + "%1$d osobe" + "%1$d ljudi" + + "Zabrani korisnika" + "Samo ukloni člana" + "Poništi zabranu" + "Moći će se ponovno pridružiti ovoj sobi ako budu pozvani." + "Poništi zabranu pristupa korisniku" + "Zabranjeni" + "Članovi" + + "%1$d pozvan" + "%1$d pozvana" + "%1$d pozvanih" + + "Na čekanju" + "Administrator" + "Moderator" + "Vlasnik" + "Članovi sobe" + "Uklanja se zabrana korisniku %1$s" + "Administratori" + "Administratori i vlasnici" + "Promijeni moju ulogu" + "Degradiraj u člana" + "Degradiraj u moderatora" + "Moderiranje članova" + "Poruke i sadržaj" + "Moderatori" + "Vlasnici" + "Dopuštenja" + "Poništi dopuštenja" + "Nakon što poništite dopuštenja, izgubit ćete trenutačne postavke." + "Želite li poništiti dopuštenja?" + "Uloge" + "Pojedinosti o sobi" + "Pojedinosti o prostoru" + "Uloge i dopuštenja" + diff --git a/features/rolesandpermissions/impl/src/main/res/values-hu/translations.xml b/features/rolesandpermissions/impl/src/main/res/values-hu/translations.xml index 77e6e3b389c..4160039a0a2 100644 --- a/features/rolesandpermissions/impl/src/main/res/values-hu/translations.xml +++ b/features/rolesandpermissions/impl/src/main/res/values-hu/translations.xml @@ -2,9 +2,12 @@ "Adminisztrátor" "Emberek kitiltása" + "Beállítások módosítása" "Üzenetek eltávolítása" "Tag" "Emberek meghívása" + "Tér kezelése" + "Szobák kezelése" "Tagok kezelése" "Üzenetek és tartalom" "Moderátor" @@ -14,6 +17,7 @@ "Szoba nevének módosítása" "Szoba témájának módosítása" "Üzenetek küldése" + "Jogosultságok" "Adminisztrátorok szerkesztése" "Ezt a műveletet nem fogja tudja visszavonni. Ugyanarra a szintre lépteti elő a felhasználót, mint amellyel Ön is rendelkezik." "Adminisztrátor hozzáadása?" @@ -34,6 +38,8 @@ "Mentetlen módosításai vannak." "Menti a módosításokat?" "Ebben a szobában nincsenek kitiltott felhasználók." + "Ellenőrizze a helyesírást, vagy próbáljon meg egy új keresést" + "Nincs találat a következőre: „%1$s\"" "%1$d személy" "%1$d személy" diff --git a/features/rolesandpermissions/impl/src/main/res/values-it/translations.xml b/features/rolesandpermissions/impl/src/main/res/values-it/translations.xml index 2b439a6c616..b1dea12151f 100644 --- a/features/rolesandpermissions/impl/src/main/res/values-it/translations.xml +++ b/features/rolesandpermissions/impl/src/main/res/values-it/translations.xml @@ -2,9 +2,12 @@ "Amministratore" "Escludi membri" + "Modifica impostazioni" "Rimuovi messaggi" "Membro" "Invita persone" + "Gestire lo spazio" + "Gestisci le stanze" "Gestisci membri" "Messaggi e contenuti" "Moderatore" @@ -14,6 +17,7 @@ "Cambia il nome della stanza" "Cambiare l\'argomento della stanza" "Inviare messaggi" + "Autorizzazioni" "Modifica amministratori" "Non potrai annullare questa azione. Stai promuovendo l\'utente al tuo stesso livello di potere." "Aggiungi amministratore?" diff --git a/features/rolesandpermissions/impl/src/main/res/values-nb/translations.xml b/features/rolesandpermissions/impl/src/main/res/values-nb/translations.xml index 65664c187ae..41f475012f9 100644 --- a/features/rolesandpermissions/impl/src/main/res/values-nb/translations.xml +++ b/features/rolesandpermissions/impl/src/main/res/values-nb/translations.xml @@ -1,14 +1,16 @@ - "Kun for administratorer" + "Admin" "Forby folk" "Fjern meldinger" - "Inviter folk og godta forespørsler om å bli med" + "Medlem" + "Inviter folk" + "Administrer medlemmer" "Meldinger og innhold" - "Administratorer og moderatorer" - "Fjern folk og avslå forespørsler om å bli med" + "Moderator" + "Fjern folk" "Endre romavatar" - "Rediger rom" + "Rediger detaljer" "Endre romnavn" "Endre temaet til rommet" "Send meldinger" @@ -31,7 +33,7 @@ "Medlemmer" "Du har endringer som ikke er lagret." "Lagre endringer?" - "Det er ingen utestengte brukere i dette rommet." + "Det er ingen utestengte brukere." "%1$d person" "%1$d personer" @@ -43,8 +45,8 @@ "Fjern utestengelsen fra rommet" "Utestengt" "Medlemmer" - "Kun for administratorer" - "Administratorer og moderatorer" + "Admin" + "Moderator" "Eier" "Medlemmer av rommet" "Oppheve utestengelsen av %1$s" diff --git a/features/rolesandpermissions/impl/src/main/res/values-pt-rBR/translations.xml b/features/rolesandpermissions/impl/src/main/res/values-pt-rBR/translations.xml index 886d3c541d0..43fa8d83f20 100644 --- a/features/rolesandpermissions/impl/src/main/res/values-pt-rBR/translations.xml +++ b/features/rolesandpermissions/impl/src/main/res/values-pt-rBR/translations.xml @@ -2,9 +2,12 @@ "Administradores" "Banir pessoas" + "Alterar configurações" "Remover mensagens" "Membro" "Convidar pessoas" + "Gerenciar espaço" + "Gerenciar salas" "Gerenciar membros" "Mensagens e conteúdo" "Moderador" @@ -14,6 +17,7 @@ "Alterar nome da sala" "Alterar tópico da sala" "Enviar mensagens" + "Permissões" "Editar administradores" "Você não poderá desfazer essa ação. Você está promovendo o usuário a ter o mesmo nível de poder que você." "Adicionar administrador?" 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 c2b248f2649..ac9ec710a2e 100644 --- a/features/rolesandpermissions/impl/src/main/res/values-ro/translations.xml +++ b/features/rolesandpermissions/impl/src/main/res/values-ro/translations.xml @@ -1,17 +1,23 @@ - "Doar administratori" + "Administrator" "Interziceți persoane" + "Modificați setările" "Ștergeți mesajele" - "Invitați persoane și acceptați cereri de alaturare" + "Membru" + "Invitați persoane" + "Gestionați spațiul" + "Gestionați camerele" + "Gestionați membrii" "Mesaje și conținut" - "Administratori și moderatori" - "Îndepărtați persoane și refuzați cereri de alăturare" + "Moderator" + "Îndepărtați persoane" "Schimbați avatarul camerei" - "Editați camera" + "Editați detaliile" "Schimbă numele camerei" "Schimbați subiectul camerei" "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?" @@ -31,9 +37,17 @@ "Membri" "Aveți modificări nesalvate." "Salvați modificările?" - "Nu există utilizatori interziși în această cameră." + "Nu există utilizatori interziși." + + "%1$d Interzis" + "%1$d Interziși" + "%1$d Interziși" + + "Verificați ortografia sau încercați o căutare nouă" + "Niciun rezultat pentru “%1$s”" - "o persoană" + "%1$d persoană" + "%1$d persoane" "%1$d persoane" "Îndepărtați și interziceți membrul" @@ -43,8 +57,14 @@ "Revocati excluderea din camera" "Excluși" "Membri" - "Doar administratori" - "Administratori și moderatori" + + "%1$d Invitat" + "%1$d Invitați" + "%1$d Invitați" + + "În așteptare" + "Administrator" + "Moderator" "Proprietar" "Membrii camerei" "Se anulează interzicerea lui %1$s" @@ -57,10 +77,12 @@ "Mesaje și conținut" "Moderatori" "Proprietari" + "Permisiuni" "Resetați permisiunile" "După ce resetați permisiunile, veți pierde setările curente." "Resetați permisiunile?" "Roluri" "Detaliile camerei" + "Detalii spațiu" "Roluri și permisiuni" diff --git a/features/rolesandpermissions/impl/src/main/res/values-ru/translations.xml b/features/rolesandpermissions/impl/src/main/res/values-ru/translations.xml index f54b984ea30..d922e585506 100644 --- a/features/rolesandpermissions/impl/src/main/res/values-ru/translations.xml +++ b/features/rolesandpermissions/impl/src/main/res/values-ru/translations.xml @@ -2,9 +2,12 @@ "Только администраторы" "Блокировать людей могут" + "Изменить настройки" "Удалить сообщения" "Участник" "Пригласить людей" + "Управление пространством" + "Управление комнатами" "Список участников" "Сообщения и содержание" "Модератор" @@ -14,6 +17,7 @@ "Менять название комнаты могут" "Менять тему комнаты могут" "Отправлять сообщения могут" + "Разрешения" "Редактировать роль администраторов" "Вы не сможете отменить это действие. Вы устанавливаете уровень пользователю соответствующий вашему." "Добавить администратора?" diff --git a/features/rolesandpermissions/impl/src/main/res/values-sk/translations.xml b/features/rolesandpermissions/impl/src/main/res/values-sk/translations.xml index a707b48bc55..d159fb03f93 100644 --- a/features/rolesandpermissions/impl/src/main/res/values-sk/translations.xml +++ b/features/rolesandpermissions/impl/src/main/res/values-sk/translations.xml @@ -1,17 +1,23 @@ - "Iba správcovia" + "Správca" "Zakázať ľudí" + "Zmeniť nastavenia" "Odstrániť správy" - "Pozvite ľudí a prijmite žiadosti o pripojenie" + "Člen" + "Pozvať ľudí" + "Spravovať priestor" + "Spravovať miestnosti" + "Spravovať členov" "Správy a obsah" - "Správcovia a moderátori" - "Odstrániť ľudí a odmietnuť žiadosti o pripojenie" + "Moderátor" + "Odstrániť ľudí" "Zmeniť obrázok miestnosti" - "Upraviť miestnosť" + "Upraviť podrobnosti" "Zmeniť názov miestnosti" "Zmeniť tému miestnosti" "Odoslať správy" + "Povolenia" "Upraviť správcov" "Túto akciu nebudete môcť vrátiť späť. Zvyšujete úroveň používateľa na rovnakú úroveň výkonu ako máte vy." "Pridať správcu?" @@ -32,6 +38,13 @@ "Máte neuložené zmeny." "Uložiť zmeny?" "Neexistujú žiadni zablokovaní používatelia." + + "%1$d zakázaný" + "%1$d zakázaní" + "%1$d zakázaných" + + "Skontrolujte preklepy alebo skúste nové vyhľadávanie" + "Žiadne výsledky pre „%1$s“" "%1$d osoba" "%1$d osoby" @@ -44,8 +57,14 @@ "Zrušiť zákaz prístupu do miestnosti" "Zakázaní" "Členovia" - "Iba správcovia" - "Správcovia a moderátori" + + "%1$d pozvaný" + "%1$d pozvaní" + "%1$d pozvaných" + + "Čaká na schválenie" + "Správca" + "Moderátor" "Vlastník" "Členovia miestnosti" "Zrušenie zákazu %1$s" @@ -58,6 +77,7 @@ "Správy a obsah" "Moderátori" "Vlastníci" + "Povolenia" "Obnoviť povolenia" "Po obnovení oprávnení prídete o aktuálne nastavenia." "Obnoviť oprávnenia?" diff --git a/features/rolesandpermissions/impl/src/main/res/values-uz/translations.xml b/features/rolesandpermissions/impl/src/main/res/values-uz/translations.xml index f56210a29a5..03f94969b57 100644 --- a/features/rolesandpermissions/impl/src/main/res/values-uz/translations.xml +++ b/features/rolesandpermissions/impl/src/main/res/values-uz/translations.xml @@ -4,11 +4,12 @@ "Odamlarni taqiqlash" "Xabarlarni olib tashlash" "Odamlarni taklif qiling va qo‘shilish so‘rovlarini qabul qiling" + "A’zolarni boshqarish" "Xabarlar va kontent" "Adminlar va moderatorlar" "Odamlarni olib tashlash va qoʻshilish soʻrovlarini rad etish" "Xona avatarini oʻzgartirish" - "Xonani tahrirlash" + "Tafsilotlarni tahrirlash" "Xona nomini oʻzgartirish" "Xona mavzusini almashtirish" "Xabarlar yuborish" diff --git a/features/rolesandpermissions/impl/src/main/res/values-zh-rTW/translations.xml b/features/rolesandpermissions/impl/src/main/res/values-zh-rTW/translations.xml index 50555bf9edf..7bc662e2fc5 100644 --- a/features/rolesandpermissions/impl/src/main/res/values-zh-rTW/translations.xml +++ b/features/rolesandpermissions/impl/src/main/res/values-zh-rTW/translations.xml @@ -2,9 +2,12 @@ "管理員" "管理黑名單" + "變更設定" "移除訊息" "成員" "邀請夥伴" + "管理空間" + "管理聊天室" "管理成員" "訊息與內容" "版主" @@ -14,6 +17,7 @@ "變更聊天室名稱" "變更聊天室主題" "傳送訊息" + "權限" "編輯管理員" "您將無法復原此動作。您正將使用者提昇至與您相同的權力等級。" "要新增管理員嗎?" diff --git a/features/rolesandpermissions/impl/src/main/res/values/localazy.xml b/features/rolesandpermissions/impl/src/main/res/values/localazy.xml index 70efa48e5ac..e5ab3f1cd71 100644 --- a/features/rolesandpermissions/impl/src/main/res/values/localazy.xml +++ b/features/rolesandpermissions/impl/src/main/res/values/localazy.xml @@ -2,9 +2,12 @@ "Admin" "Ban people" + "Change settings" "Remove messages" "Member" "Invite people" + "Manage space" + "Manage rooms" "Manage members" "Messages and content" "Moderator" @@ -14,6 +17,7 @@ "Change name" "Change topic" "Send messages" + "Permissions" "Edit Admins" "You will not be able to undo this action. You are promoting the user to have the same power level as you." "Add Admin?" diff --git a/features/rolesandpermissions/impl/src/test/kotlin/io/element/android/features/rolesandpermissions/impl/permissions/ChangeRoomPermissionsPresenterTest.kt b/features/rolesandpermissions/impl/src/test/kotlin/io/element/android/features/rolesandpermissions/impl/permissions/ChangeRoomPermissionsPresenterTest.kt index 7c328c9bad3..ba7d47adb27 100644 --- a/features/rolesandpermissions/impl/src/test/kotlin/io/element/android/features/rolesandpermissions/impl/permissions/ChangeRoomPermissionsPresenterTest.kt +++ b/features/rolesandpermissions/impl/src/test/kotlin/io/element/android/features/rolesandpermissions/impl/permissions/ChangeRoomPermissionsPresenterTest.kt @@ -16,13 +16,18 @@ import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import im.vector.app.features.analytics.plan.RoomModeration import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMember.Role.Admin import io.element.android.libraries.matrix.api.room.RoomMember.Role.Moderator +import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevels import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevelsValues +import io.element.android.libraries.matrix.test.A_SESSION_ID 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.test.room.defaultRoomPowerLevelValues import io.element.android.services.analytics.test.FakeAnalyticsService +import kotlinx.collections.immutable.persistentMapOf import kotlinx.coroutines.test.runTest import org.junit.Test @@ -39,7 +44,6 @@ class ChangeRoomPermissionsPresenterTest { assertThat(this.itemsBySection).isNotEmpty() assertThat(this.hasChanges).isFalse() assertThat(this.saveAction).isEqualTo(AsyncAction.Uninitialized) - assertThat(this.confirmExitAction).isEqualTo(AsyncAction.Uninitialized) } // Updated state, permissions loaded @@ -54,7 +58,7 @@ class ChangeRoomPermissionsPresenterTest { presenter.present() }.test { val itemsBySection = awaitUpdatedItem().itemsBySection - assertThat(itemsBySection[RoomPermissionsSection.RoomDetails]).containsExactly( + assertThat(itemsBySection[RoomPermissionsSection.EditDetails]).containsExactly( RoomPermissionType.ROOM_NAME, RoomPermissionType.ROOM_AVATAR, RoomPermissionType.ROOM_TOPIC, @@ -63,7 +67,7 @@ class ChangeRoomPermissionsPresenterTest { RoomPermissionType.SEND_EVENTS, RoomPermissionType.REDACT_EVENTS, ) - assertThat(itemsBySection[RoomPermissionsSection.MembershipModeration]).containsExactly( + assertThat(itemsBySection[RoomPermissionsSection.ManageMembers]).containsExactly( RoomPermissionType.INVITE, RoomPermissionType.KICK, RoomPermissionType.BAN, @@ -71,6 +75,28 @@ class ChangeRoomPermissionsPresenterTest { } } + @Test + fun `present - check canChangePermissions and selectableOptions for moderator`() = runTest { + val room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + initialRoomInfo = initialRoomInfo(role = Moderator), + powerLevelsResult = { Result.success(defaultPermissions()) } + ), + ) + val presenter = createChangeRoomPermissionsPresenter(room = room) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val state = awaitUpdatedItem() + assertThat(state.selectableRoles).containsExactly(SelectableRole.Moderator, SelectableRole.Everyone) + for (sectionItems in state.itemsBySection.values) { + for (permissionType in sectionItems) { + assertThat(state.canChangePermission(permissionType)).isTrue() + } + } + } + } + @Test fun `present - ChangeMinimumRoleForAction updates the current permissions and hasChanges`() = runTest { val presenter = createChangeRoomPermissionsPresenter() @@ -78,13 +104,13 @@ class ChangeRoomPermissionsPresenterTest { presenter.present() }.test { val state = awaitUpdatedItem() - assertThat(state.currentPermissions?.roomName).isEqualTo(Admin.powerLevel) + assertThat(state.currentPermissions?.roomName).isEqualTo(Moderator.powerLevel) assertThat(state.hasChanges).isFalse() - state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_NAME, SelectableRole.Moderator)) + state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_NAME, SelectableRole.Admin)) awaitItem().run { - assertThat(currentPermissions?.roomName).isEqualTo(Moderator.powerLevel) + assertThat(currentPermissions?.roomName).isEqualTo(Admin.powerLevel) assertThat(hasChanges).isTrue() } } @@ -116,8 +142,9 @@ class ChangeRoomPermissionsPresenterTest { invite = Moderator.powerLevel, kick = Moderator.powerLevel, ban = Moderator.powerLevel, + stateDefault = Moderator.powerLevel, redactEvents = Moderator.powerLevel, - sendEvents = Moderator.powerLevel, + eventsDefault = Moderator.powerLevel, roomName = Moderator.powerLevel, roomAvatar = Moderator.powerLevel, roomTopic = Moderator.powerLevel, @@ -142,14 +169,14 @@ class ChangeRoomPermissionsPresenterTest { presenter.present() }.test { val state = awaitUpdatedItem() - assertThat(state.currentPermissions?.roomName).isEqualTo(Admin.powerLevel) + assertThat(state.currentPermissions?.roomName).isEqualTo(Moderator.powerLevel) assertThat(state.hasChanges).isFalse() - state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_NAME, SelectableRole.Moderator)) - state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_AVATAR, SelectableRole.Moderator)) - state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_TOPIC, SelectableRole.Moderator)) - state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.SEND_EVENTS, SelectableRole.Moderator)) - state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.REDACT_EVENTS, SelectableRole.Everyone)) + state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_NAME, SelectableRole.Admin)) + state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_AVATAR, SelectableRole.Admin)) + state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_TOPIC, SelectableRole.Admin)) + state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.SEND_EVENTS, SelectableRole.Admin)) + state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.REDACT_EVENTS, SelectableRole.Admin)) state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.KICK, SelectableRole.Admin)) state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.BAN, SelectableRole.Admin)) state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.INVITE, SelectableRole.Admin)) @@ -161,16 +188,16 @@ class ChangeRoomPermissionsPresenterTest { assertThat(awaitItem().saveAction).isEqualTo(AsyncAction.Loading) assertThat(awaitItem().hasChanges).isFalse() awaitItem().run { - assertThat(currentPermissions?.roomName).isEqualTo(Moderator.powerLevel) - assertThat(saveAction).isEqualTo(AsyncAction.Success(Unit)) + assertThat(currentPermissions?.roomName).isEqualTo(Admin.powerLevel) + assertThat(saveAction).isEqualTo(AsyncAction.Success(true)) } assertThat(analyticsService.capturedEvents).containsExactlyElementsIn( listOf( - RoomModeration(RoomModeration.Action.ChangePermissionsRoomName, RoomModeration.Role.Moderator), - RoomModeration(RoomModeration.Action.ChangePermissionsRoomAvatar, RoomModeration.Role.Moderator), - RoomModeration(RoomModeration.Action.ChangePermissionsRoomTopic, RoomModeration.Role.Moderator), - RoomModeration(RoomModeration.Action.ChangePermissionsSendMessages, RoomModeration.Role.Moderator), - RoomModeration(RoomModeration.Action.ChangePermissionsRedactMessages, RoomModeration.Role.User), + RoomModeration(RoomModeration.Action.ChangePermissionsRoomName, RoomModeration.Role.Administrator), + RoomModeration(RoomModeration.Action.ChangePermissionsRoomAvatar, RoomModeration.Role.Administrator), + RoomModeration(RoomModeration.Action.ChangePermissionsRoomTopic, RoomModeration.Role.Administrator), + RoomModeration(RoomModeration.Action.ChangePermissionsSendMessages, RoomModeration.Role.Administrator), + RoomModeration(RoomModeration.Action.ChangePermissionsRedactMessages, RoomModeration.Role.Administrator), RoomModeration(RoomModeration.Action.ChangePermissionsKickMembers, RoomModeration.Role.Administrator), RoomModeration(RoomModeration.Action.ChangePermissionsBanMembers, RoomModeration.Role.Administrator), RoomModeration(RoomModeration.Action.ChangePermissionsInviteUsers, RoomModeration.Role.Administrator), @@ -207,17 +234,17 @@ class ChangeRoomPermissionsPresenterTest { presenter.present() }.test { val state = awaitUpdatedItem() - assertThat(state.currentPermissions?.roomName).isEqualTo(Admin.powerLevel) + assertThat(state.currentPermissions?.roomName).isEqualTo(Moderator.powerLevel) assertThat(state.hasChanges).isFalse() - state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_NAME, SelectableRole.Moderator)) + state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_NAME, SelectableRole.Admin)) assertThat(awaitItem().hasChanges).isTrue() state.eventSink(ChangeRoomPermissionsEvent.Save) assertThat(awaitItem().saveAction).isEqualTo(AsyncAction.Loading) awaitItem().run { - assertThat(currentPermissions?.roomName).isEqualTo(Moderator.powerLevel) + assertThat(currentPermissions?.roomName).isEqualTo(Admin.powerLevel) // Couldn't save the changes, so they're still pending assertThat(hasChanges).isTrue() assertThat(saveAction).isInstanceOf(AsyncAction.Failure::class.java) @@ -225,7 +252,7 @@ class ChangeRoomPermissionsPresenterTest { state.eventSink(ChangeRoomPermissionsEvent.ResetPendingActions) awaitItem().run { - assertThat(currentPermissions?.roomName).isEqualTo(Moderator.powerLevel) + assertThat(currentPermissions?.roomName).isEqualTo(Admin.powerLevel) assertThat(saveAction).isEqualTo(AsyncAction.Uninitialized) assertThat(hasChanges).isTrue() } @@ -239,14 +266,14 @@ class ChangeRoomPermissionsPresenterTest { presenter.present() }.test { val state = awaitUpdatedItem() - state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_NAME, SelectableRole.Moderator)) + state.eventSink(ChangeRoomPermissionsEvent.ChangeMinimumRoleForAction(RoomPermissionType.ROOM_NAME, SelectableRole.Admin)) assertThat(awaitItem().hasChanges).isTrue() state.eventSink(ChangeRoomPermissionsEvent.Exit) - assertThat(awaitItem().confirmExitAction).isEqualTo(AsyncAction.ConfirmingNoParams) + assertThat(awaitItem().saveAction).isEqualTo(AsyncAction.ConfirmingCancellation) state.eventSink(ChangeRoomPermissionsEvent.Exit) - assertThat(awaitItem().confirmExitAction).isEqualTo(AsyncAction.Success(Unit)) + assertThat(awaitItem().saveAction).isEqualTo(AsyncAction.Success(false)) } } @@ -260,13 +287,16 @@ class ChangeRoomPermissionsPresenterTest { state.eventSink(ChangeRoomPermissionsEvent.Exit) - assertThat(awaitItem().confirmExitAction).isEqualTo(AsyncAction.Success(Unit)) + assertThat(awaitItem().saveAction).isEqualTo(AsyncAction.Success(false)) } } private fun createChangeRoomPermissionsPresenter( room: FakeJoinedRoom = FakeJoinedRoom( - baseRoom = FakeBaseRoom(powerLevelsResult = { Result.success(defaultPermissions()) }), + baseRoom = FakeBaseRoom( + initialRoomInfo = initialRoomInfo(), + powerLevelsResult = { Result.success(defaultPermissions()) } + ), ), analyticsService: FakeAnalyticsService = FakeAnalyticsService(), ) = ChangeRoomPermissionsPresenter( @@ -274,6 +304,13 @@ class ChangeRoomPermissionsPresenterTest { analyticsService = analyticsService, ) + private fun initialRoomInfo(role: RoomMember.Role = Admin) = aRoomInfo( + roomPowerLevels = RoomPowerLevels( + values = defaultPermissions(), + users = persistentMapOf(A_SESSION_ID to role.powerLevel), + ) + ) + private fun defaultPermissions() = defaultRoomPowerLevelValues() private suspend fun TurbineTestContext.awaitUpdatedItem(): ChangeRoomPermissionsState { diff --git a/features/rolesandpermissions/impl/src/test/kotlin/io/element/android/features/rolesandpermissions/impl/permissions/ChangeRoomPermissionsViewTest.kt b/features/rolesandpermissions/impl/src/test/kotlin/io/element/android/features/rolesandpermissions/impl/permissions/ChangeRoomPermissionsViewTest.kt index 915359cfcf9..f28c9c150fa 100644 --- a/features/rolesandpermissions/impl/src/test/kotlin/io/element/android/features/rolesandpermissions/impl/permissions/ChangeRoomPermissionsViewTest.kt +++ b/features/rolesandpermissions/impl/src/test/kotlin/io/element/android/features/rolesandpermissions/impl/permissions/ChangeRoomPermissionsViewTest.kt @@ -18,7 +18,6 @@ import io.element.android.libraries.ui.strings.CommonStrings 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.clickOnFirst import io.element.android.tests.testutils.ensureCalledOnceWithParam import io.element.android.tests.testutils.pressBack import io.element.android.tests.testutils.pressBackKey @@ -76,7 +75,7 @@ class ChangeRoomPermissionsViewTest { rule.setChangeRoomPermissionsRule( state = aChangeRoomPermissionsState( hasChanges = true, - confirmExitAction = AsyncAction.ConfirmingNoParams, + saveAction = AsyncAction.ConfirmingCancellation, eventSink = recorder, ), ) @@ -90,11 +89,11 @@ class ChangeRoomPermissionsViewTest { rule.setChangeRoomPermissionsRule( state = aChangeRoomPermissionsState( hasChanges = true, - confirmExitAction = AsyncAction.ConfirmingNoParams, + saveAction = AsyncAction.ConfirmingCancellation, eventSink = recorder, ), ) - rule.clickOnFirst(CommonStrings.action_save) + rule.clickOn(CommonStrings.action_save, inDialog = true) recorder.assertSingle(ChangeRoomPermissionsEvent.Save) } @@ -105,7 +104,7 @@ class ChangeRoomPermissionsViewTest { state = aChangeRoomPermissionsState( itemsBySection = persistentMapOf( // Makes sure there is only one item to click on - RoomPermissionsSection.RoomDetails to persistentListOf(RoomPermissionType.ROOM_NAME) + RoomPermissionsSection.EditDetails to persistentListOf(RoomPermissionType.ROOM_NAME) ), eventSink = recorder, ) @@ -136,9 +135,23 @@ class ChangeRoomPermissionsViewTest { rule.setChangeRoomPermissionsRule( state = aChangeRoomPermissionsState( hasChanges = true, - saveAction = AsyncAction.Success(Unit), + saveAction = AsyncAction.Success(true), ), - onComplete = callback + onComplete = callback, + ) + rule.clickOn(CommonStrings.action_save) + } + } + + @Test + fun `a cancellation exits the screen`() { + ensureCalledOnceWithParam(false) { callback -> + rule.setChangeRoomPermissionsRule( + state = aChangeRoomPermissionsState( + hasChanges = true, + saveAction = AsyncAction.Success(false), + ), + onComplete = callback, ) rule.clickOn(CommonStrings.action_save) } diff --git a/features/rolesandpermissions/impl/src/test/kotlin/io/element/android/features/rolesandpermissions/impl/roles/ChangeRolesPresenterTest.kt b/features/rolesandpermissions/impl/src/test/kotlin/io/element/android/features/rolesandpermissions/impl/roles/ChangeRolesPresenterTest.kt index 471e07d4a55..e96f353383d 100644 --- a/features/rolesandpermissions/impl/src/test/kotlin/io/element/android/features/rolesandpermissions/impl/roles/ChangeRolesPresenterTest.kt +++ b/features/rolesandpermissions/impl/src/test/kotlin/io/element/android/features/rolesandpermissions/impl/roles/ChangeRolesPresenterTest.kt @@ -19,7 +19,9 @@ import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMembersState import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevels +import io.element.android.libraries.matrix.api.room.toMatrixUser 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.A_USER_ID import io.element.android.libraries.matrix.test.A_USER_ID_2 import io.element.android.libraries.matrix.test.A_USER_ID_3 @@ -28,11 +30,13 @@ 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.test.room.aRoomMember +import io.element.android.libraries.matrix.test.room.anAlice import io.element.android.libraries.matrix.test.room.defaultRoomPowerLevelValues import io.element.android.libraries.previewutils.room.aRoomMemberList import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.tests.testutils.test import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentMapOf import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableMap @@ -65,7 +69,7 @@ class ChangeRolesPresenterTest { } val presenter = createChangeRolesPresenter(room = room) presenter.test { - skipItems(1) + skipItems(2) assertThat(awaitItem().searchResults).isInstanceOf(SearchBarResultState.Results::class.java) } } @@ -163,13 +167,13 @@ class ChangeRolesPresenterTest { } @Test - fun `present - when modifying admins, creators are displayed too`() = runTest { + fun `present - when modifying admins, creators are displayed too - privilegedCreatorRole is true`() = runTest { val room = FakeJoinedRoom().apply { val creatorUserId = UserId("@creator:matrix.org") val memberList = aRoomMemberList() .plus(aRoomMember(displayName = "CREATOR", role = RoomMember.Role.Owner(isCreator = true), userId = creatorUserId)) .toImmutableList() - givenRoomInfo(aRoomInfo(roomCreators = listOf(creatorUserId))) + givenRoomInfo(aRoomInfo(roomCreators = listOf(creatorUserId), privilegedCreatorRole = true)) givenRoomMembersState(RoomMembersState.Ready(memberList)) } val presenter = createChangeRolesPresenter(room = room) @@ -192,6 +196,7 @@ class ChangeRolesPresenterTest { } val presenter = createChangeRolesPresenter(room = room) presenter.test { + skipItems(1) val initialState = awaitItem() initialState.eventSink(ChangeRolesEvent.ToggleSearchActive) @@ -209,6 +214,7 @@ class ChangeRolesPresenterTest { } val presenter = createChangeRolesPresenter(room = room) presenter.test { + skipItems(1) val initialState = awaitItem() val initialResults = (awaitItem().searchResults as? SearchBarResultState.Results)?.results assertThat(initialResults?.members).hasSize(8) @@ -233,7 +239,7 @@ class ChangeRolesPresenterTest { } val presenter = createChangeRolesPresenter(room = room) presenter.test { - skipItems(1) + skipItems(2) val initialResults = (awaitItem().searchResults as? SearchBarResultState.Results)?.results assertThat(initialResults?.members).hasSize(8) assertThat(initialResults?.moderators).hasSize(1) @@ -252,44 +258,48 @@ class ChangeRolesPresenterTest { @Test fun `present - UserSelectionToggle adds and removes users from the selected user list`() = runTest { + val roomMemberList = aRoomMemberList() val room = FakeJoinedRoom().apply { - givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList())) - givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsWithRole(RoomMember.Role.Admin))) + givenRoomMembersState(RoomMembersState.Ready(roomMemberList)) + givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsFromRoomMemberList(roomMemberList))) } val presenter = createChangeRolesPresenter(room = room) + val userMember = roomMemberList.first { it.role == RoomMember.Role.User } presenter.test { skipItems(1) val initialState = awaitItem() assertThat(initialState.selectedUsers).hasSize(1) - initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(MatrixUser(A_USER_ID_2))) + initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(userMember.toMatrixUser())) assertThat(awaitItem().selectedUsers).hasSize(2) - initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(MatrixUser(A_USER_ID_2))) + initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(userMember.toMatrixUser())) assertThat(awaitItem().selectedUsers).hasSize(1) } } @Test fun `present - hasPendingChanges is true when the initial selected users don't match the new ones`() = runTest { + val roomMemberList = aRoomMemberList() val room = FakeJoinedRoom().apply { - givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList())) - givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsWithRole(RoomMember.Role.Admin))) + givenRoomMembersState(RoomMembersState.Ready(roomMemberList)) + givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsFromRoomMemberList(roomMemberList))) } val presenter = createChangeRolesPresenter(room = room) + val userMember = roomMemberList.first { it.role == RoomMember.Role.User } presenter.test { skipItems(1) val initialState = awaitItem() assertThat(initialState.hasPendingChanges).isFalse() assertThat(initialState.selectedUsers).hasSize(1) - initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(MatrixUser(A_USER_ID_2))) + initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(userMember.toMatrixUser())) with(awaitItem()) { assertThat(selectedUsers).hasSize(2) assertThat(hasPendingChanges).isTrue() } - initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(MatrixUser(A_USER_ID_2))) + initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(userMember.toMatrixUser())) with(awaitItem()) { assertThat(selectedUsers).hasSize(1) assertThat(hasPendingChanges).isFalse() @@ -299,9 +309,10 @@ class ChangeRolesPresenterTest { @Test fun `present - Exit will display success false if no pending changes`() = runTest { + val roomMemberList = aRoomMemberList() val room = FakeJoinedRoom().apply { - givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList())) - givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsWithRole(RoomMember.Role.Admin))) + givenRoomMembersState(RoomMembersState.Ready(roomMemberList)) + givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsFromRoomMemberList(roomMemberList))) } val presenter = createChangeRolesPresenter(room = room) presenter.test { @@ -317,9 +328,10 @@ class ChangeRolesPresenterTest { @Test fun `present - CloseDialog will remove exit confirmation`() = runTest { + val roomMemberList = aRoomMemberList() val room = FakeJoinedRoom().apply { - givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList())) - givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsWithRole(RoomMember.Role.Admin))) + givenRoomMembersState(RoomMembersState.Ready(roomMemberList)) + givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsFromRoomMemberList(roomMemberList))) } val presenter = createChangeRolesPresenter(room = room) presenter.test { @@ -341,9 +353,10 @@ class ChangeRolesPresenterTest { @Test fun `present - Exit will display a confirmation dialog if there are pending changes, calling it again will actually exit`() = runTest { + val roomMemberList = aRoomMemberList() val room = FakeJoinedRoom().apply { - givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList())) - givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsWithRole(RoomMember.Role.Admin))) + givenRoomMembersState(RoomMembersState.Ready(roomMemberList)) + givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsFromRoomMemberList(roomMemberList))) } val presenter = createChangeRolesPresenter(room = room) presenter.test { @@ -367,12 +380,13 @@ class ChangeRolesPresenterTest { @Test fun `present - Save will display a confirmation when adding admins`() = runTest { + val roomMemberList = aRoomMemberList() val room = FakeJoinedRoom( updateUserRoleResult = { Result.success(Unit) }, baseRoom = FakeBaseRoom(updateMembersResult = { Result.success(Unit) }), ).apply { - givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList())) - givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsWithRole(RoomMember.Role.Admin))) + givenRoomMembersState(RoomMembersState.Ready(roomMemberList)) + givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsFromRoomMemberList(roomMemberList))) } val presenter = createChangeRolesPresenter(role = RoomMember.Role.Admin, room = room) presenter.test { @@ -391,9 +405,10 @@ class ChangeRolesPresenterTest { @Test fun `present - CloseDialog will remove the confirmation dialog`() = runTest { + val roomMemberList = aRoomMemberList() val room = FakeJoinedRoom().apply { - givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList())) - givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsWithRole(RoomMember.Role.Admin))) + givenRoomMembersState(RoomMembersState.Ready(roomMemberList)) + givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsFromRoomMemberList(roomMemberList))) } val presenter = createChangeRolesPresenter(role = RoomMember.Role.Admin, room = room) presenter.test { @@ -415,25 +430,27 @@ class ChangeRolesPresenterTest { @Test fun `present - Save will just save the data for moderators`() = runTest { val analyticsService = FakeAnalyticsService() + val roomMemberList = aRoomMemberList() val room = FakeJoinedRoom( updateUserRoleResult = { Result.success(Unit) }, baseRoom = FakeBaseRoom(updateMembersResult = { Result.success(Unit) }), ).apply { - givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList())) - givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsWithRole(RoomMember.Role.Moderator))) + givenRoomMembersState(RoomMembersState.Ready(roomMemberList)) + givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsFromRoomMemberList(roomMemberList))) } val presenter = createChangeRolesPresenter( role = RoomMember.Role.Moderator, room = room, analyticsService = analyticsService ) + val userMember = roomMemberList.first { it.role == RoomMember.Role.User } presenter.test { - skipItems(1) + skipItems(2) val initialState = awaitItem() - assertThat(initialState.selectedUsers).isEmpty() - initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(MatrixUser(A_USER_ID_2))) + assertThat(initialState.selectedUsers).hasSize(1) + initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(userMember.toMatrixUser())) awaitItem().also { - assertThat(it.selectedUsers).hasSize(1) + assertThat(it.selectedUsers).hasSize(2) it.eventSink(ChangeRolesEvent.Save) } assertThat(awaitItem().savingState).isInstanceOf(AsyncAction.Loading::class.java) @@ -480,17 +497,14 @@ class ChangeRolesPresenterTest { @Test fun `present - Save will just save the changes if the current user is a room creator and the selected users are not`() = runTest { val analyticsService = FakeAnalyticsService() + val alice = anAlice() + val me = aRoomMember(displayName = "CREATOR", role = RoomMember.Role.Owner(isCreator = true), userId = A_SESSION_ID) val room = FakeJoinedRoom( updateUserRoleResult = { Result.success(Unit) }, baseRoom = FakeBaseRoom(updateMembersResult = { Result.success(Unit) }), ).apply { - givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList())) - givenRoomInfo( - aRoomInfo( - roomCreators = listOf(sessionId), - roomPowerLevels = roomPowerLevelsWithRole(role = RoomMember.Role.Admin, userId = A_USER_ID_2) - ) - ) + val roomMemberList = persistentListOf(alice, me) + givenRoomMembersState(RoomMembersState.Ready(roomMemberList)) } val presenter = createChangeRolesPresenter( role = RoomMember.Role.Admin, @@ -501,7 +515,7 @@ class ChangeRolesPresenterTest { skipItems(2) val initialState = awaitItem() assertThat(initialState.selectedUsers).hasSize(2) - initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(MatrixUser(A_USER_ID_2))) + initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(alice.toMatrixUser())) awaitItem().also { assertThat(it.selectedUsers).hasSize(1) it.eventSink(ChangeRolesEvent.Save) @@ -515,20 +529,22 @@ class ChangeRolesPresenterTest { @Test fun `present - Save can handle failures and CloseDialog clears them`() = runTest { + val roomMemberList = aRoomMemberList() val room = FakeJoinedRoom( updateUserRoleResult = { Result.failure(IllegalStateException("Failed")) } ).apply { - givenRoomMembersState(RoomMembersState.Ready(aRoomMemberList())) - givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsWithRole(role = RoomMember.Role.Moderator, userId = A_USER_ID))) + givenRoomMembersState(RoomMembersState.Ready(roomMemberList)) + givenRoomInfo(aRoomInfo(roomPowerLevels = roomPowerLevelsFromRoomMemberList(roomMemberList))) } val presenter = createChangeRolesPresenter(role = RoomMember.Role.Moderator, room = room) + val userMember = roomMemberList.first { it.role == RoomMember.Role.User } presenter.test { - skipItems(1) + skipItems(2) val initialState = awaitItem() - assertThat(initialState.selectedUsers).isEmpty() - initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(MatrixUser(A_USER_ID_2))) + assertThat(initialState.selectedUsers).hasSize(1) + initialState.eventSink(ChangeRolesEvent.UserSelectionToggled(userMember.toMatrixUser())) awaitItem().also { - assertThat(it.selectedUsers).hasSize(1) + assertThat(it.selectedUsers).hasSize(2) it.eventSink(ChangeRolesEvent.Save) } val loadingState = awaitItem() @@ -552,13 +568,12 @@ class ChangeRolesPresenterTest { } } - private fun roomPowerLevelsWithRole( - role: RoomMember.Role, - userId: UserId = A_USER_ID, + private fun roomPowerLevelsFromRoomMemberList( + roomMemberList: List, ): RoomPowerLevels { return RoomPowerLevels( values = defaultRoomPowerLevelValues(), - users = persistentMapOf(userId to role.powerLevel) + users = roomMemberList.associate { it.userId to it.role.powerLevel }.toImmutableMap() ) } diff --git a/features/rolesandpermissions/impl/src/test/kotlin/io/element/android/features/rolesandpermissions/impl/roles/ChangeRolesViewTest.kt b/features/rolesandpermissions/impl/src/test/kotlin/io/element/android/features/rolesandpermissions/impl/roles/ChangeRolesViewTest.kt index fd45e5408cb..39967f91607 100644 --- a/features/rolesandpermissions/impl/src/test/kotlin/io/element/android/features/rolesandpermissions/impl/roles/ChangeRolesViewTest.kt +++ b/features/rolesandpermissions/impl/src/test/kotlin/io/element/android/features/rolesandpermissions/impl/roles/ChangeRolesViewTest.kt @@ -119,7 +119,7 @@ class ChangeRolesViewTest { } @Test - fun `exit confirmation dialog - submit exits the screen`() { + fun `exit confirmation dialog - discard exits the screen`() { val eventsRecorder = EventsRecorder() rule.setChangeRolesContent( state = aChangeRolesState( @@ -128,12 +128,12 @@ class ChangeRolesViewTest { eventSink = eventsRecorder, ), ) - rule.clickOn(CommonStrings.action_ok) + rule.clickOn(CommonStrings.action_discard) eventsRecorder.assertSingle(ChangeRolesEvent.Exit) } @Test - fun `exit confirmation dialog - cancel removes the dialog`() { + fun `exit confirmation dialog - save emits the save event`() { val eventsRecorder = EventsRecorder() rule.setChangeRolesContent( state = aChangeRolesState( @@ -142,8 +142,8 @@ class ChangeRolesViewTest { eventSink = eventsRecorder, ), ) - rule.clickOn(CommonStrings.action_cancel) - eventsRecorder.assertSingle(ChangeRolesEvent.CloseDialog) + rule.clickOn(CommonStrings.action_save) + eventsRecorder.assertSingle(ChangeRolesEvent.Save) } @Test diff --git a/features/rolesandpermissions/impl/src/test/kotlin/io/element/android/features/rolesandpermissions/impl/root/RolesAndPermissionPresenterTest.kt b/features/rolesandpermissions/impl/src/test/kotlin/io/element/android/features/rolesandpermissions/impl/root/RolesAndPermissionPresenterTest.kt index 3eaacb9c3bb..54bd6b79874 100644 --- a/features/rolesandpermissions/impl/src/test/kotlin/io/element/android/features/rolesandpermissions/impl/root/RolesAndPermissionPresenterTest.kt +++ b/features/rolesandpermissions/impl/src/test/kotlin/io/element/android/features/rolesandpermissions/impl/root/RolesAndPermissionPresenterTest.kt @@ -8,16 +8,17 @@ package io.element.android.features.rolesandpermissions.impl.root -import app.cash.molecule.RecompositionMode -import app.cash.molecule.moleculeFlow -import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import im.vector.app.features.analytics.plan.RoomModeration import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.matrix.api.room.RoomMember +import io.element.android.libraries.matrix.api.room.RoomMembersState +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.aRoomMemberList import io.element.android.services.analytics.test.FakeAnalyticsService +import io.element.android.tests.testutils.test import io.element.android.tests.testutils.testCoroutineDispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.StandardTestDispatcher @@ -30,12 +31,10 @@ class RolesAndPermissionPresenterTest { @Test fun `present - initial state`() = runTest { val presenter = createRolesAndPermissionsPresenter() - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { + presenter.test { with(awaitItem()) { - assertThat(adminCount).isEqualTo(0) - assertThat(moderatorCount).isEqualTo(0) + assertThat(adminCount).isNull() + assertThat(moderatorCount).isNull() assertThat(changeOwnRoleAction).isEqualTo(AsyncAction.Uninitialized) } } @@ -44,12 +43,9 @@ class RolesAndPermissionPresenterTest { @Test fun `present - ChangeOwnRole presents a confirmation dialog`() = runTest { val presenter = createRolesAndPermissionsPresenter() - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { + presenter.test { val initialState = awaitItem() initialState.eventSink(RolesAndPermissionsEvents.ChangeOwnRole) - assertThat(awaitItem().changeOwnRoleAction).isEqualTo(AsyncAction.ConfirmingNoParams) } } @@ -60,12 +56,11 @@ class RolesAndPermissionPresenterTest { val presenter = createRolesAndPermissionsPresenter( dispatchers = testCoroutineDispatchers(), room = FakeJoinedRoom( + baseRoom = FakeBaseRoom(updateMembersResult = {}), updateUserRoleResult = { Result.success(Unit) } ), ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { + presenter.test { val initialState = awaitItem() initialState.eventSink(RolesAndPermissionsEvents.DemoteSelfTo(RoomMember.Role.Moderator)) @@ -81,12 +76,11 @@ class RolesAndPermissionPresenterTest { @Test fun `present - DemoteSelfTo can handle failures and clean them`() = runTest(StandardTestDispatcher()) { val room = FakeJoinedRoom( + baseRoom = FakeBaseRoom(updateMembersResult = {}), updateUserRoleResult = { Result.failure(Exception("Failed to update role")) } ) val presenter = createRolesAndPermissionsPresenter(room = room, dispatchers = testCoroutineDispatchers()) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { + presenter.test { val initialState = awaitItem() initialState.eventSink(RolesAndPermissionsEvents.DemoteSelfTo(RoomMember.Role.Moderator)) @@ -104,9 +98,7 @@ class RolesAndPermissionPresenterTest { @Test fun `present - CancelPendingAction dismisses confirmation dialog too`() = runTest { val presenter = createRolesAndPermissionsPresenter() - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { + presenter.test { val initialState = awaitItem() initialState.eventSink(RolesAndPermissionsEvents.ChangeOwnRole) awaitItem().eventSink(RolesAndPermissionsEvents.CancelPendingAction) @@ -121,12 +113,11 @@ class RolesAndPermissionPresenterTest { val presenter = createRolesAndPermissionsPresenter( analyticsService = analyticsService, room = FakeJoinedRoom( + baseRoom = FakeBaseRoom(updateMembersResult = {}), resetPowerLevelsResult = { Result.success(Unit) } ) ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { + presenter.test { val initialState = awaitItem() initialState.eventSink(RolesAndPermissionsEvents.ResetPermissions) // Confirmation @@ -141,9 +132,7 @@ class RolesAndPermissionPresenterTest { @Test fun `present - ResetPermissions confirmation can be cancelled`() = runTest { val presenter = createRolesAndPermissionsPresenter() - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { + presenter.test { val initialState = awaitItem() initialState.eventSink(RolesAndPermissionsEvents.ResetPermissions) awaitItem().eventSink(RolesAndPermissionsEvents.CancelPendingAction) @@ -152,8 +141,26 @@ class RolesAndPermissionPresenterTest { } } + @Test + fun `present - admins and moderator counts are updated when members changes`() = runTest { + val room = FakeJoinedRoom( + baseRoom = FakeBaseRoom(updateMembersResult = {}), + ) + val presenter = createRolesAndPermissionsPresenter(room = room) + presenter.test { + val initialState = awaitItem() + assertThat(initialState.adminCount).isNull() + assertThat(initialState.moderatorCount).isNull() + room.givenRoomMembersState(state = RoomMembersState.Ready(aRoomMemberList())) + skipItems(1) + val finalState = awaitItem() + assertThat(finalState.adminCount).isEqualTo(1) + assertThat(finalState.moderatorCount).isEqualTo(1) + } + } + private fun TestScope.createRolesAndPermissionsPresenter( - room: FakeJoinedRoom = FakeJoinedRoom(), + room: FakeJoinedRoom = FakeJoinedRoom(baseRoom = FakeBaseRoom(updateMembersResult = {})), dispatchers: CoroutineDispatchers = testCoroutineDispatchers(), analyticsService: FakeAnalyticsService = FakeAnalyticsService() ): RolesAndPermissionsPresenter { 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/changeroommemberroles/test/FakeRolesAndPermissionsEntryPoint.kt index 01f2787778a..62cf3285f35 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/changeroommemberroles/test/FakeRolesAndPermissionsEntryPoint.kt @@ -14,7 +14,7 @@ import io.element.android.features.rolesandpermissions.api.RolesAndPermissionsEn import io.element.android.tests.testutils.lambda.lambdaError class FakeRolesAndPermissionsEntryPoint : RolesAndPermissionsEntryPoint { - override fun createNode(parentNode: Node, buildContext: BuildContext): Node { + override fun createNode(parentNode: Node, buildContext: BuildContext, callback: RolesAndPermissionsEntryPoint.Callback): Node { lambdaError() } } diff --git a/features/roomaliasresolver/impl/src/main/res/values-hr/translations.xml b/features/roomaliasresolver/impl/src/main/res/values-hr/translations.xml new file mode 100644 index 00000000000..2d1e42c4059 --- /dev/null +++ b/features/roomaliasresolver/impl/src/main/res/values-hr/translations.xml @@ -0,0 +1,5 @@ + + + "Nismo mogli prikazati pregled ove sobe" + "Nije uspjelo razrješavanje aliasa sobe." + 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 5de47f9ff2a..b18a2772a34 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 @@ -21,7 +21,8 @@ import io.element.android.features.enterprise.api.SessionEnterpriseService import io.element.android.features.roomcall.api.RoomCallState import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.matrix.api.room.JoinedRoom -import io.element.android.libraries.matrix.ui.room.canCall +import io.element.android.libraries.matrix.api.room.powerlevels.canCall +import io.element.android.libraries.matrix.api.room.powerlevels.permissionsAsState @Inject class RoomCallStatePresenter( @@ -35,8 +36,7 @@ class RoomCallStatePresenter( value = sessionEnterpriseService.isElementCallAvailable() } val roomInfo by room.roomInfoFlow.collectAsState() - val syncUpdateFlow = room.syncUpdateFlow.collectAsState() - val canJoinCall by room.canCall(updateKey = syncUpdateFlow.value) + val canJoinCall by room.permissionsAsState(false) { perms -> perms.canCall() } val isUserInTheCall by remember { derivedStateOf { room.sessionId in roomInfo.activeRoomCallParticipants 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 1aceee227a7..bdececf584b 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 @@ -15,9 +15,12 @@ import io.element.android.features.call.test.FakeCurrentCallService import io.element.android.features.enterprise.test.FakeSessionEnterpriseService import io.element.android.features.roomcall.api.RoomCallState import io.element.android.libraries.matrix.api.room.JoinedRoom +import io.element.android.libraries.matrix.api.room.StateEventType 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.test.room.powerlevels.FakeRoomPermissions +import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.test import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest @@ -28,7 +31,7 @@ class RoomCallStatePresenterTest { fun `present - initial state`() = runTest { val room = FakeJoinedRoom( baseRoom = FakeBaseRoom( - canUserJoinCallResult = { Result.success(false) }, + roomPermissions = roomPermissions(false), ) ) val presenter = createRoomCallStatePresenter(joinedRoom = room) @@ -47,7 +50,7 @@ class RoomCallStatePresenterTest { fun `present - element call not available`() = runTest { val room = FakeJoinedRoom( baseRoom = FakeBaseRoom( - canUserJoinCallResult = { Result.success(false) }, + roomPermissions = roomPermissions(false), ) ) val presenter = createRoomCallStatePresenter( @@ -66,7 +69,7 @@ class RoomCallStatePresenterTest { fun `present - initial state - user can join call`() = runTest { val room = FakeJoinedRoom( baseRoom = FakeBaseRoom( - canUserJoinCallResult = { Result.success(true) }, + roomPermissions = roomPermissions(true), ) ) val presenter = createRoomCallStatePresenter(joinedRoom = room) @@ -85,7 +88,7 @@ class RoomCallStatePresenterTest { fun `present - call is disabled if user cannot join it even if there is an ongoing call`() = runTest { val room = FakeJoinedRoom( baseRoom = FakeBaseRoom( - canUserJoinCallResult = { Result.success(false) }, + roomPermissions = roomPermissions(false), initialRoomInfo = aRoomInfo(hasRoomCall = true), ) ) @@ -106,7 +109,7 @@ class RoomCallStatePresenterTest { fun `present - user has joined the call on another session`() = runTest { val room = FakeJoinedRoom( baseRoom = FakeBaseRoom( - canUserJoinCallResult = { Result.success(true) }, + roomPermissions = roomPermissions(true), ).apply { givenRoomInfo( aRoomInfo( @@ -133,7 +136,7 @@ class RoomCallStatePresenterTest { fun `present - user has joined the call locally`() = runTest { val room = FakeJoinedRoom( baseRoom = FakeBaseRoom( - canUserJoinCallResult = { Result.success(true) }, + roomPermissions = roomPermissions(true), ).apply { givenRoomInfo( aRoomInfo( @@ -163,7 +166,7 @@ class RoomCallStatePresenterTest { fun `present - user leaves the call`() = runTest { val room = FakeJoinedRoom( baseRoom = FakeBaseRoom( - canUserJoinCallResult = { Result.success(true) }, + roomPermissions = roomPermissions(true), ).apply { givenRoomInfo( aRoomInfo( @@ -223,6 +226,17 @@ class RoomCallStatePresenterTest { } } + private fun roomPermissions(canJoinCall: Boolean): FakeRoomPermissions { + return FakeRoomPermissions( + canSendState = { stateEvent -> + when (stateEvent) { + StateEventType.CallMember -> canJoinCall + else -> lambdaError() + } + } + ) + } + private fun createRoomCallStatePresenter( joinedRoom: JoinedRoom, currentCallService: CurrentCallService = FakeCurrentCallService(), diff --git a/features/roomdetails/impl/build.gradle.kts b/features/roomdetails/impl/build.gradle.kts index 0f29a8cee9b..a7fb7dcf3b6 100644 --- a/features/roomdetails/impl/build.gradle.kts +++ b/features/roomdetails/impl/build.gradle.kts @@ -60,6 +60,7 @@ dependencies { implementation(projects.features.roommembermoderation.api) implementation(projects.features.rolesandpermissions.api) implementation(projects.features.securityandprivacy.api) + implementation(projects.features.roomdetailsedit.api) implementation(projects.features.invitepeople.api) testCommonDependencies(libs, true) @@ -74,6 +75,7 @@ dependencies { testImplementation(projects.features.call.test) testImplementation(projects.features.rolesandpermissions.test) testImplementation(projects.features.securityandprivacy.test) + testImplementation(projects.features.roomdetailsedit.test) testImplementation(projects.features.knockrequests.test) testImplementation(projects.features.messages.test) testImplementation(projects.features.poll.test) diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsFlowNode.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsFlowNode.kt index c8ef60513c8..03adbded1b1 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsFlowNode.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsFlowNode.kt @@ -35,11 +35,11 @@ import io.element.android.features.rolesandpermissions.api.ChangeRoomMemberRoles import io.element.android.features.rolesandpermissions.api.ChangeRoomMemberRolesListType import io.element.android.features.rolesandpermissions.api.RolesAndPermissionsEntryPoint import io.element.android.features.roomdetails.api.RoomDetailsEntryPoint -import io.element.android.features.roomdetails.impl.edit.RoomDetailsEditNode import io.element.android.features.roomdetails.impl.invite.RoomInviteMembersNode import io.element.android.features.roomdetails.impl.members.RoomMemberListNode import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsNode import io.element.android.features.roomdetails.impl.notificationsettings.RoomNotificationSettingsNode +import io.element.android.features.roomdetailsedit.api.RoomDetailsEditEntryPoint import io.element.android.features.securityandprivacy.api.SecurityAndPrivacyEntryPoint import io.element.android.features.userprofile.shared.UserProfileNodeHelper import io.element.android.features.verifysession.api.OutgoingVerificationEntryPoint @@ -85,6 +85,7 @@ class RoomDetailsFlowNode( private val changeRoomMemberRolesEntryPoint: ChangeRoomMemberRolesEntryPoint, private val rolesAndPermissionsEntryPoint: RolesAndPermissionsEntryPoint, private val securityAndPrivacyEntryPoint: SecurityAndPrivacyEntryPoint, + private val roomDetailsEditEntryPoint: RoomDetailsEditEntryPoint, ) : BaseFlowNode( backstack = BackStack( initialElement = plugins.filterIsInstance().first().initialElement.toNavTarget(), @@ -256,7 +257,7 @@ class RoomDetailsFlowNode( } NavTarget.RoomDetailsEdit -> { - createNode(buildContext) + roomDetailsEditEntryPoint.createNode(this, buildContext) } NavTarget.InviteMembers -> { @@ -348,7 +349,16 @@ class RoomDetailsFlowNode( } is NavTarget.AdminSettings -> { - rolesAndPermissionsEntryPoint.createNode(this, buildContext) + val callback = object : RolesAndPermissionsEntryPoint.Callback { + override fun onDone() { + backstack.pop() + } + } + rolesAndPermissionsEntryPoint.createNode( + parentNode = this, + buildContext = buildContext, + callback = callback, + ) } NavTarget.PinnedMessagesList -> { val params = MessagesEntryPoint.Params( @@ -382,7 +392,16 @@ class RoomDetailsFlowNode( knockRequestsListEntryPoint.createNode(this, buildContext) } NavTarget.SecurityAndPrivacy -> { - securityAndPrivacyEntryPoint.createNode(this, buildContext) + val callback = object : SecurityAndPrivacyEntryPoint.Callback { + override fun onDone() { + backstack.pop() + } + } + securityAndPrivacyEntryPoint.createNode( + parentNode = this, + buildContext = buildContext, + callback = callback, + ) } is NavTarget.VerifyUser -> { val params = OutgoingVerificationEntryPoint.Params( 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 2a6baaa1477..c4b36cf3e75 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 @@ -10,6 +10,7 @@ package io.element.android.features.roomdetails.impl import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.State import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue @@ -18,11 +19,16 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import dev.zacsweers.metro.Inject import im.vector.app.features.analytics.plan.Interaction +import io.element.android.features.knockrequests.api.KnockRequestPermissions +import io.element.android.features.knockrequests.api.knockRequestPermissions import io.element.android.features.leaveroom.api.LeaveRoomEvent import io.element.android.features.leaveroom.api.LeaveRoomState import io.element.android.features.roomcall.api.RoomCallState import io.element.android.features.roomdetails.impl.members.details.RoomMemberDetailsPresenter -import io.element.android.features.securityandprivacy.api.securityAndPrivacyPermissionsAsState +import io.element.android.features.roomdetailsedit.api.RoomDetailsEditPermissions +import io.element.android.features.roomdetailsedit.api.roomDetailsEditPermissions +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.architecture.Presenter import io.element.android.libraries.core.coroutine.CoroutineDispatchers @@ -36,17 +42,13 @@ import io.element.android.libraries.matrix.api.encryption.identity.IdentityState import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.room.RoomMember -import io.element.android.libraries.matrix.api.room.RoomMembersState -import io.element.android.libraries.matrix.api.room.StateEventType +import io.element.android.libraries.matrix.api.room.isDm import io.element.android.libraries.matrix.api.room.join.JoinRule -import io.element.android.libraries.matrix.api.room.powerlevels.canInvite -import io.element.android.libraries.matrix.api.room.powerlevels.canSendState +import io.element.android.libraries.matrix.api.room.powerlevels.canEditRolesAndPermissions +import io.element.android.libraries.matrix.api.room.powerlevels.permissionsAsState import io.element.android.libraries.matrix.api.room.roomNotificationSettings -import io.element.android.libraries.matrix.ui.room.canHandleKnockRequestsAsState import io.element.android.libraries.matrix.ui.room.getCurrentRoomMember import io.element.android.libraries.matrix.ui.room.getDirectRoomMember -import io.element.android.libraries.matrix.ui.room.isDmAsState -import io.element.android.libraries.matrix.ui.room.isOwnUserAdmin import io.element.android.libraries.matrix.ui.room.roomMemberIdentityStateChange import io.element.android.libraries.preferences.api.store.AppPreferencesStore import io.element.android.libraries.ui.strings.CommonStrings @@ -77,8 +79,6 @@ class RoomDetailsPresenter( val scope = rememberCoroutineScope() val leaveRoomState = leaveRoomPresenter.present() val roomInfo by room.roomInfoFlow.collectAsState() - val isUserAdmin = room.isOwnUserAdmin() - val syncUpdateFlow = room.syncUpdateFlow.collectAsState() val roomAvatar by remember { derivedStateOf { roomInfo.avatarUrl } } val roomName by remember { derivedStateOf { roomInfo.name?.trim().orEmpty() } } @@ -93,8 +93,8 @@ class RoomDetailsPresenter( observeNotificationSettings() } + val isDm = roomInfo.isDm val membersState by room.membersStateFlow.collectAsState() - val canInvite by getCanInvite(membersState) // TCHAP external user val isOpenToExternalUsers by remember { derivedStateOf { roomInfo.isOpenToExternalUsers } } @@ -103,12 +103,9 @@ class RoomDetailsPresenter( featureFlagService.isFeatureEnabledFlow(FeatureFlags.ShowMatrixId) }.collectAsState(false) + val permissions by getPermissions() val canonicalAlias by remember { derivedStateOf { roomInfo.canonicalAlias } } val isEncrypted by remember { derivedStateOf { roomInfo.isEncrypted == true } } - val isDm by room.isDmAsState() - val canEditName by getCanSendState(membersState, StateEventType.ROOM_NAME) - val canEditAvatar by getCanSendState(membersState, StateEventType.ROOM_AVATAR) - val canEditTopic by getCanSendState(membersState, StateEventType.ROOM_TOPIC) val dmMember by room.getDirectRoomMember(membersState) val currentMember by room.getCurrentRoomMember(membersState) val roomMemberDetailsPresenter = roomMemberDetailsPresenter(dmMember) @@ -116,16 +113,15 @@ class RoomDetailsPresenter( val roomCallState = roomCallStatePresenter.present() val joinedMemberCount by remember { derivedStateOf { roomInfo.joinedMembersCount } } - val topicState = remember(canEditTopic, roomTopic, roomType) { + val topicState = remember(permissions.editDetailsPermissions.canEditTopic, roomTopic, roomType) { val topic = roomTopic when { !topic.isNullOrBlank() -> RoomTopicState.ExistingTopic(topic) - canEditTopic && roomType is RoomDetailsType.Room -> RoomTopicState.CanAddTopic + permissions.editDetailsPermissions.canEditTopic && roomType is RoomDetailsType.Room -> RoomTopicState.CanAddTopic else -> RoomTopicState.Hidden } } - val canHandleKnockRequests by room.canHandleKnockRequestsAsState(syncUpdateFlow.value) val isKnockRequestsEnabled by remember { featureFlagService.isFeatureEnabledFlow(FeatureFlags.Knock) }.collectAsState(false) @@ -133,7 +129,10 @@ class RoomDetailsPresenter( room.knockRequestsFlow.collect { value = it.size } } val canShowKnockRequests by remember { - derivedStateOf { isKnockRequestsEnabled && canHandleKnockRequests && joinRule == JoinRule.Knock } + derivedStateOf { isKnockRequestsEnabled && permissions.knockRequestsPermissions.hasAny && joinRule == JoinRule.Knock } + } + val canShowSecurityAndPrivacy by remember { + derivedStateOf { !isDm && permissions.securityAndPrivacyPermissions.hasAny(isSpace = false, joinRule = joinRule) } } val isDeveloperModeEnabled by remember { appPreferencesStore.isDeveloperModeEnabledFlow() @@ -169,13 +168,6 @@ class RoomDetailsPresenter( val roomMemberDetailsState = roomMemberDetailsPresenter?.present() - val securityAndPrivacyPermissions = room.securityAndPrivacyPermissionsAsState(syncUpdateFlow.value) - val canShowSecurityAndPrivacy by remember { - derivedStateOf { - roomType is RoomDetailsType.Room && securityAndPrivacyPermissions.value.hasAny - } - } - val hasMemberVerificationViolations by produceState(false) { room.roomMemberIdentityStateChange(waitForEncryption = true) .onEach { identities -> value = identities.any { it.identityState == IdentityState.VerificationViolation } } @@ -195,15 +187,15 @@ class RoomDetailsPresenter( roomTopic = topicState, memberCount = joinedMemberCount, isEncrypted = isEncrypted, - canInvite = canInvite, - canEdit = (canEditAvatar || canEditName || canEditTopic) && roomType == RoomDetailsType.Room, + canInvite = permissions.canInvite, + canEdit = roomType == RoomDetailsType.Room && permissions.editDetailsPermissions.hasAny, roomCallState = roomCallState, roomType = roomType, roomMemberDetailsState = roomMemberDetailsState, leaveRoomState = leaveRoomState, roomNotificationSettings = roomNotificationSettingsState.roomNotificationSettings(), isFavorite = isFavorite, - displayRolesAndPermissionsSettings = !isDm && isUserAdmin, + displayRolesAndPermissionsSettings = !isDm && permissions.canEditRolesAndPermissions, isPublic = joinRule == JoinRule.Public, heroes = roomInfo.heroes.toImmutableList(), pinnedMessagesCount = pinnedMessagesCount, @@ -242,14 +234,25 @@ class RoomDetailsPresenter( } } - @Composable - private fun getCanInvite(membersState: RoomMembersState) = produceState(false, membersState) { - value = room.canInvite().getOrElse { false } - } + private data class Permissions( + val canInvite: Boolean = false, + val editDetailsPermissions: RoomDetailsEditPermissions = RoomDetailsEditPermissions.DEFAULT, + val knockRequestsPermissions: KnockRequestPermissions = KnockRequestPermissions.DEFAULT, + val securityAndPrivacyPermissions: SecurityAndPrivacyPermissions = SecurityAndPrivacyPermissions.DEFAULT, + val canEditRolesAndPermissions: Boolean = false, + ) @Composable - private fun getCanSendState(membersState: RoomMembersState, type: StateEventType) = produceState(false, membersState) { - value = room.canSendState(type).getOrElse { false } + private fun getPermissions(): State { + return room.permissionsAsState(Permissions()) { perms -> + Permissions( + canInvite = perms.canOwnUserInvite(), + editDetailsPermissions = perms.roomDetailsEditPermissions(), + knockRequestsPermissions = perms.knockRequestPermissions(), + canEditRolesAndPermissions = perms.canEditRolesAndPermissions(), + securityAndPrivacyPermissions = perms.securityAndPrivacyPermissions(), + ) + } } private fun CoroutineScope.observeNotificationSettings() { 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 95965de0d30..c83aa76ad1d 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 @@ -57,6 +57,7 @@ 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.PreferenceSwitch import io.element.android.libraries.designsystem.modifiers.niceClickable +import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.PreviewWithLargeHeight @@ -403,7 +404,7 @@ private fun RoomHeaderSection( }.toImmutableList(), isTombstoned = isTombstoned, ), - contentDescription = avatarUrl?.let { stringResource(CommonStrings.a11y_room_avatar) }, + contentDescription = stringResource(CommonStrings.a11y_room_avatar), modifier = Modifier .clickable( enabled = avatarUrl != null, @@ -713,6 +714,7 @@ private fun DebugInfoSection( ) { val context = LocalContext.current PreferenceCategory(showTopDivider = true) { + val toastMessage = stringResource(CommonStrings.common_copied_to_clipboard) ListItem( headlineContent = { Text("Internal room ID") @@ -728,8 +730,8 @@ private fun DebugInfoSection( trailingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Copy())), onClick = { context.copyToClipboard( - roomId.value, - context.getString(CommonStrings.common_copied_to_clipboard) + text = roomId.value, + toastMessage = toastMessage, ) }, ) @@ -759,6 +761,14 @@ internal fun RoomDetailsPreview(@PreviewParameter(RoomDetailsStateProvider::clas internal fun RoomDetailsDarkPreview(@PreviewParameter(RoomDetailsStateProvider::class) state: RoomDetailsState) = ElementPreviewDark { ContentToPreview(state) } +@PreviewWithLargeHeight +@Composable +internal fun RoomDetailsA11yPreview() = ElementPreview { + ContentToPreview( + state = aRoomDetailsState(displayAdminSettings = true) + ) +} + @ExcludeFromCoverage @Composable private fun ContentToPreview(state: RoomDetailsState) { diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenter.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenter.kt index 492a676748a..bfb021b452c 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenter.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenter.kt @@ -34,10 +34,10 @@ import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMembersState import io.element.android.libraries.matrix.api.room.RoomMembershipState +import io.element.android.libraries.matrix.api.room.powerlevels.permissionsAsState import io.element.android.libraries.matrix.api.room.roomMembers import io.element.android.libraries.matrix.api.room.toMatrixUser import io.element.android.libraries.matrix.ui.room.PowerLevelRoomMemberComparator -import io.element.android.libraries.matrix.ui.room.canInviteAsState import io.element.android.libraries.matrix.ui.room.roomMemberIdentityStateChange import kotlinx.collections.immutable.ImmutableMap import kotlinx.collections.immutable.persistentMapOf @@ -61,8 +61,7 @@ class RoomMemberListPresenter( override fun present(): RoomMemberListState { var searchQuery by rememberSaveable { mutableStateOf("") } val membersState by room.membersStateFlow.collectAsState() - val syncUpdateFlow = room.syncUpdateFlow.collectAsState() - val canInvite by room.canInviteAsState(syncUpdateFlow.value) + val canInvite by room.permissionsAsState(false) { perms -> perms.canOwnUserInvite() } val roomModerationState = roomMembersModerationPresenter.present() val showMatrixId by remember { diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListState.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListState.kt index 179a6ba666e..f89f7f0fd6e 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListState.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListState.kt @@ -27,7 +27,7 @@ data class RoomMemberListState( val moderationState: RoomMemberModerationState, val eventSink: (RoomMemberListEvents) -> Unit, ) { - val showBannedSection: Boolean = moderationState.canBan && roomMembers.dataOrNull()?.banned?.isNotEmpty() == true + val showBannedSection: Boolean = moderationState.permissions.canBan && roomMembers.dataOrNull()?.banned?.isNotEmpty() == true } enum class SelectedSection { diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListStateProvider.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListStateProvider.kt index 4386af1aa2e..2e112b3df73 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListStateProvider.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListStateProvider.kt @@ -10,6 +10,7 @@ package io.element.android.features.roomdetails.impl.members import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.features.roommembermoderation.api.RoomMemberModerationEvents +import io.element.android.features.roommembermoderation.api.RoomMemberModerationPermissions import io.element.android.features.roommembermoderation.api.RoomMemberModerationState import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.map @@ -100,8 +101,10 @@ fun aRoomMemberModerationState( canKick: Boolean = false, ): RoomMemberModerationState { return object : RoomMemberModerationState { - override val canKick: Boolean = canKick - override val canBan: Boolean = canBan + override val permissions: RoomMemberModerationPermissions = RoomMemberModerationPermissions( + canBan = canBan, + canKick = canKick, + ) override val eventSink: (RoomMemberModerationEvents) -> Unit = {} } } diff --git a/features/roomdetails/impl/src/main/res/values-be/translations.xml b/features/roomdetails/impl/src/main/res/values-be/translations.xml index fe337177df3..9b89f2cef06 100644 --- a/features/roomdetails/impl/src/main/res/values-be/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-be/translations.xml @@ -51,7 +51,6 @@ "Замацаваныя паведамленні" "Профіль" "Ролі і дазволы" - "Назва пакоя" "Бяспека" "Падзяліцца пакоем" "Інфармацыя аб пакоі" @@ -100,7 +99,4 @@ "Ролі" "Дэталі пакоя" "Ролі і дазволы" - "Папрасіце далучыцца" - "Хто заўгодна" - "Хто заўгодна" diff --git a/features/roomdetails/impl/src/main/res/values-bg/translations.xml b/features/roomdetails/impl/src/main/res/values-bg/translations.xml index 4ea6d24af61..9273f7c38aa 100644 --- a/features/roomdetails/impl/src/main/res/values-bg/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-bg/translations.xml @@ -42,7 +42,6 @@ "Закачени съобщения" "Профил" "Роли и разрешения" - "Име на стаята" "Защита и поверителност" "Защита" "Споделяне на стаята" @@ -86,14 +85,12 @@ "Шифроване" "Включване на шифроване от край до край" "Всеки може да намери и да се присъедини" - "Всеки" "Хората могат да се присъединят само ако са поканени" "Само с покана" "Достъп до стаята" "Членове на пространството" "Пространствата в момента не се поддържат" "Видима в директорията на обществените стаи" - "Всеки" "Кой може да чете историята" "Само за членове откакто са поканени" "Само за членове от избирането на тази опция" 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 fc19bf400e7..78a8a9f703c 100644 --- a/features/roomdetails/impl/src/main/res/values-cs/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-cs/translations.xml @@ -63,7 +63,7 @@ "Profil" "Žádosti o vstup" "Role a oprávnění" - "Název místnosti" + "Název" "Zabezpečení a soukromí" "Zabezpečení" "Sdílet místnost" @@ -132,8 +132,10 @@ "Podrobnosti místnosti" "Role a oprávnění" "Přidat adresu" + "Připojit se může kdokoli v autorizovaných prostorách, ale všichni ostatní musí o přístup požádat." "Všichni musí požádat o přístup." - "Požádat o připojení" + "Požádat o vstup" + "Kdokoli v %1$s se může připojit, ale všichni ostatní musí o přístup požádat." "Ano, povolit šifrování" "Po aktivaci nelze šifrování místnosti deaktivovat. Historie zpráv bude viditelná pouze pro členy místnosti od doby, kdy byli pozváni nebo od té doby, co do místnosti vstoupili. Nikdo kromě členů místnosti nebude moci číst zprávy. To může bránit správnému fungování robotů a propojení. @@ -144,7 +146,8 @@ Nedoporučujeme povolovat šifrování pro místnosti, které může kdokoli naj "Povolit koncové šifrování" "Vstoupit může kdokoli." "Kdokoliv" - "Vyberte, kteří členové prostorů se k této místnosti mohou připojit bez pozvánky. %1$s" + "Vyberte, kteří členové prostorů mohou vstoupit do této místnosti bez pozvánky. %1$s" + "Spravovat prostory" "Vstoupit mohou pouze pozvaní lidé." "Pouze pro zvané" "Přístup" @@ -157,10 +160,11 @@ Nedoporučujeme povolovat šifrování pro místnosti, které může kdokoli naj "Umožněte nalezení této místnosti prohledáním adresáře veřejných místností na %1$s" "Umožnit nalezení vyhledáváním ve veřejném adresáři." "Viditelné ve veřejném adresáři" - "Kdokoliv" + "Kdokoli (historie je veřejná)" + "Změny neovlivní starší zprávy, pouze nové. %1$s" "Kdo může číst historii" - "Pouze členové od té doby, co byli pozváni" - "Pouze členové od výběru této možnosti" + "Členové od pozvání" + "Členové (úplná historie)" "Adresy místností představují způsoby, jak najít místnosti a získat k nim přístup. Díky tomu můžete svoji místnost snadno sdílet s ostatními. Můžete se rozhodnout publikovat svou místnost ve veřejném adresáři místnosti vašeho domovského serveru." "Publikování místnosti" diff --git a/features/roomdetails/impl/src/main/res/values-cy/translations.xml b/features/roomdetails/impl/src/main/res/values-cy/translations.xml index 97d39509fa8..79de35224ec 100644 --- a/features/roomdetails/impl/src/main/res/values-cy/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-cy/translations.xml @@ -63,7 +63,6 @@ "Proffil" "Ceisiadau i ymuno" "Rolau a chaniatâd" - "Enw\'r ystafell" "Diogelwch a phreifatrwydd" "Diogelwch" "Rhannu ystafell" diff --git a/features/roomdetails/impl/src/main/res/values-da/translations.xml b/features/roomdetails/impl/src/main/res/values-da/translations.xml index c2ed3caaca7..d646b03acbc 100644 --- a/features/roomdetails/impl/src/main/res/values-da/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-da/translations.xml @@ -1,19 +1,21 @@ - "Du skal bruge en rum-adresse for at gøre den synlig i kataloget." - "Rummets adresse" + "Du skal bruge en adresse for at gøre det synligt i det offentlige register." + "Redigér adresse" "Der opstod en fejl under opdatering af notifikationsindstillingen." "Din hjemmeserver understøtter ikke denne mulighed i krypterede rum, og derfor er det muligt at du ikke får besked i alle rum." "Afstemninger" - "Kun admins" + "Administrator" "Spær brugere" "Fjern beskeder" - "Invitér personer og acceptér anmodninger om at deltage" + "Medlem" + "Invitér andre" + "Administrer medlemmer" "Beskeder og indhold" - "Admins og moderatorer" - "Fjern personer og afvis anmodninger om at deltage" + "Moderator" + "Fjern personer" "Skift rummets avatar" - "Rediger rum" + "Redigér detaljer" "Skift rummets navn" "Skift emne for rummet" "Send beskeder" @@ -40,7 +42,7 @@ "Krypteret" "Ikke krypteret" "Offentligt rum" - "Rediger rum" + "Redigér detaljer" "Der opstod en ukendt fejl, og oplysningerne kunne ikke ændres." "Rummet kunne ikke opdateres" "Beskeder er sikret med låse. Kun du og modtagerne har de unikke nøgler til at låse dem op." @@ -61,7 +63,6 @@ "Profil" "Anmodninger om at deltage" "Roller og tilladelser" - "Navn på rum" "Sikkerhed og privatliv" "Sikkerhed" "Del rum" @@ -69,6 +70,12 @@ "Emne" "Opdaterer rum…" "Der er ingen spærrede brugere i dette rum." + + "%1$d Spærret" + "%1$d Spærret" + + "Tjek stavningen eller prøv en ny søgning" + "Ingen resultater for \"%1$s\"" "%1$d person" "%1$d personer" @@ -80,8 +87,13 @@ "Fjern brugerens spærring fra rummet" "Spærret" "Medlemmer" - "Kun admins" - "Admins og moderatorer" + + "%1$d Inviteret" + "%1$d Inviteret" + + "Afventer" + "Administrator" + "Moderator" "Ejeren" "Medlemmer af rummet" "Ophæver spærring af %1$s" @@ -108,15 +120,15 @@ "Beskeder og indhold" "Moderatorer" "Ejere" + "Tilladelser" "Nulstil tilladelser" "Når du nulstiller tilladelserne, mister du de nuværende indstillinger." "Nulstil tilladelser?" "Roller" "Detaljer om rummet" "Roller og tilladelser" - "Tilføj adresse på rum" - "Alle kan bede om at deltage i lokalet, men en administrator eller moderator skal acceptere anmodningen." - "Spørg om at deltage" + "Tilføj adresse" + "Alle skal anmode om adgang." "Ja, aktivér kryptering" "Når det først er aktiveret, kan kryptering for et rum ikke deaktiveres igen. Beskedhistorik vil kun være synlig for rummedlemmer, siden de blev inviteret, eller siden de blev medlem af rummet. Ingen udover medlemmer af rummet vil være i stand til at læse beskeder. Dette kan forhindre bots og broer i at fungere korrekt. @@ -125,22 +137,23 @@ Vi anbefaler ikke at aktivere kryptering for rum, som alle kan finde og deltage "Når kryptering først er aktiveret, kan den ikke deaktiveres igen." "Kryptering" "Aktivér end-to-end-kryptering" - "Alle kan finde og deltage" - "Enhver" - "Andre kan kun deltage, hvis de bliver inviteret" - "Kun med invitation" - "Adgang til rummet" + "Alle kan være med." + "Kun inviterede personer kan deltage i dette rum." + "Kun inviterede" + "Adgang" "Medlemmer af gruppen" "Grupper understøttes ikke i øjeblikket" - "Du skal bruge en rum-adresse for at gøre den synlig i kataloget." + "Du skal bruge en adresse for at gøre det synligt i det offentlige register." + "Adresse" "Tillad, at dette rum kan findes ved at søge i %1$s fortegnelse over offentlige rum" - "Synlig i det offentlige register over rum" - "Enhver" + "Gør det muligt at blive fundet ved søgninger i det offentlige register." + "Synlig i det offentlige register" "Hvem kan læse historikken?" "Kun medlemmer, efter de blev inviteret" "Kun medlemmer siden valg af denne mulighed" "Rum-adresser er en måde at finde og få adgang til værelser på. Dette sikrer også, at du nemt kan dele dit rum med andre. Du kan vælge at offentliggøre dit rum i din hjemmeservers offentlige katalog over rum." "Udgivelse af rum" + "Synlighed" "Sikkerhed og privatliv" diff --git a/features/roomdetails/impl/src/main/res/values-de/translations.xml b/features/roomdetails/impl/src/main/res/values-de/translations.xml index 89ef69c173a..0cd1d187a32 100644 --- a/features/roomdetails/impl/src/main/res/values-de/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-de/translations.xml @@ -1,17 +1,19 @@ - "Du benötigst eine Chat-Adresse, um den Chat im Verzeichnis sichtbar zu machen." - "Chat-Adresse" + "Du benötigst eine Chat-Adresse, um den Chat im öffentlichen Verzeichnis sichtbar zu machen." + "Chat-Adresse bearbeiten" "Beim Aktualisieren der Benachrichtigungseinstellungen ist ein Fehler aufgetreten." "Dein Homeserver unterstützt diese Option in verschlüsselten Chats nicht. In einigen Chats erhältst du möglicherweise keine Benachrichtigungen." "Umfragen" - "Nur Admins" + "Admin" "Mitglieder sperren" "Nachrichten entfernen" - "Personen einladen und Beitrittsanfragen annehmen" + "Mitglied" + "Mitglieder hinzufügen" + "Mitglieder verwalten" "Nachrichten senden & löschen" - "Admins und Moderatoren" - "Personen entfernen und Beitrittsanfragen ablehnen" + "Moderator" + "Mitglieder entfernen" "Avatar ändern" "Chat bearbeiten" "Chat-Namen ändern" @@ -61,7 +63,7 @@ "Profil" "Beitrittsanfragen" "Rollen und Berechtigungen" - "Chat-Name" + "Name" "Sicherheit & Datenschutz" "Sicherheit" "Teilen" @@ -69,6 +71,12 @@ "Thema" "Chat wird aktualisiert…" "Es gibt keine gesperrten Nutzer." + + "%1$d gesperrt" + "%1$d gesperrt" + + "Überprüfe die Schreibweise oder versuch\'s mit einer neuen Suche" + "Keine Ergebnisse für „%1$s“" "%1$d Person" "%1$d Personen" @@ -80,8 +88,13 @@ "Sperre für diesen Chat aufheben" "Gesperrt" "Mitglieder" - "Nur Admins" - "Admins und Moderatoren" + + "%1$d eingeladen" + "%1$d eingeladen" + + "Ausstehend" + "Admin" + "Moderator" "Eigentümer" "Mitglieder" "%1$s wird entsperrt." @@ -108,15 +121,18 @@ "Nachrichten senden & löschen" "Moderatoren" "Eigentümer" - "Rollen und Berechtigungen zurücksetzen" + "Berechtigungen" + "Berechtigungen zurücksetzen" "Sobald du die Berechtigungen zurücksetzt, verlierst du die aktuellen Einstellungen." "Berechtigungen zurücksetzen?" "Rollen" "Chat-Details anpassen" "Rollen und Berechtigungen" "Chat-Adresse hinzufügen" - "Jeder kann den Beitritt zum Chat anfragen, aber ein Admin oder Moderator müssen die Anfrage akzeptieren." - "Beitritt beantragen" + "Jedes Mitglied eines autorisierten Space kann beitreten, aber alle anderen müssen einen Beitritt anfragen." + "Zugang nur auf Anfrage." + "Bitte um Beitritt" + "Jeder in %1$s kann beitreten, aber alle anderen müssen den Beitritt anfragen." "Ja, Verschlüsselung aktivieren" "Einmal angeschaltet kann die Verschlüsselung für einen Chat nicht mehr deaktiviert werden. Der Nachrichtenverlauf ist für Mitglieder nur sichtbar, seit sie eingeladen wurden oder dem Chat beigetreten sind. Niemand außer den Chat Mitgliedern kann Nachrichten lesen. Dies kann verhindern, dass Bots und Bridges richtig funktionieren. @@ -125,24 +141,31 @@ Wir empfehlen keine Verschlüsselung für Chats zu aktivieren, die jeder finden "Einmal angeschaltet kann die Verschlüsselung nicht mehr deaktiviert werden." "Verschlüsselung" "Ende-zu-Ende-Verschlüsselung aktivieren" - "Jeder kann diesen Chat finden und ihm beitreten" + "Jeder kann beitreten." "Jeder" - "Personen können nur beitreten, wenn sie eingeladen werden." + "Wähle aus, welche Spaces ihren Mitgliedern ermöglichen sollen, dieser Gruppe ohne Einladung beitreten zu können. %1$s" + "Spaces verwalten" + "Nur eingeladene Personen können beitreten" "Nur auf Einladung" - "Chat Zugang" + "Zugang" + "Jeder in autorisierten Spaces kann beitreten." + "Jeder in %1$s kann beitreten." "Spacemitglieder" "Spaces werden zur Zeit nicht unterstützt." - "Du benötigst eine Chat-Adresse, um den Chat im Verzeichnis sichtbar zu machen." - "Chatroomadresse" + "Du benötigst eine Chat-Adresse, um den Chat im öffentlichen Verzeichnis sichtbar zu machen." + "Adresse" "Erlaube das Auffinden dieses Chats durch Suche im öffentlichen Verzeichnis von %1$s" + "Lass dich über die Suche im öffentlichen Verzeichnis finden." "Sichtbar im öffentlichen Verzeichnis" - "Jeder" + "Jeder (Nachrichtenverlauf ist öffentlich)" + "Änderungen wirken sich nicht auf alte Nachrichten aus, sondern nur auf neue. %1$s" "Wer hat Zugriff auf den Nachrichtenverlauf" "Nur Mitglieder, aber erst seit deren Einladung" - "Nur Mitglieder seit Auswahl dieser Option" + "Mitglieder (voller Nachrichtenverlauf)" "Chat-Adressen machen es möglich, Chats zu finden und ihnen beizutreten. Dies erleichtert es, Chats mit anderen zu teilen. Auf Wunsch kannst du deinen Chat im öffentlichen Verzeichnis deines Homeservers veröffentlichen." "Veröffentlichung von Chats" - "Chatroomsichtbarkeit." + "Adressen ermöglichen es, Gruppen und Spaces zu finden und zu betreten. Dadurch wird auch sichergestellt, dass diese problemlos mit anderen geteilt werden können." + "Sichtbarkeit" "Sicherheit & Datenschutz" 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 a2deb1c7067..eeba5d5bf72 100644 --- a/features/roomdetails/impl/src/main/res/values-el/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-el/translations.xml @@ -55,7 +55,6 @@ "Προφίλ" "Αιτήματα συμμετοχής" "Ρόλοι και δικαιώματα" - "Όνομα αίθουσας" "Ασφάλεια & απόρρητο" "Ασφάλεια" "Κοινή χρήση αίθουσας" @@ -126,7 +125,6 @@ "Θα χρειαστείτε μια διεύθυνση αίθουσας για να την κάνετε ορατή στον κατάλογο." "Επιστρέψτε την εύρεση αυτής της αίθουσας με αναζήτηση στον κατάλογο %1$s δημοσίων αιθουσών" "Ορατή στον κατάλογο δημόσιων αιθουσών" - "Οποιοσδήποτε" "Ποιος μπορεί να διαβάσει το ιστορικό" "Μόνο μέλη από τη στιγμή που προσκλήθηκαν" "Μόνο για μέλη μετά από αυτήν την επιλογή" diff --git a/features/roomdetails/impl/src/main/res/values-en-rUS/translations.xml b/features/roomdetails/impl/src/main/res/values-en-rUS/translations.xml new file mode 100644 index 00000000000..9a4b56e2873 --- /dev/null +++ b/features/roomdetails/impl/src/main/res/values-en-rUS/translations.xml @@ -0,0 +1,5 @@ + + + "Anyone in authorized spaces can join, but everyone else must request access." + "Anyone in authorized spaces can join." + diff --git a/features/roomdetails/impl/src/main/res/values-es/translations.xml b/features/roomdetails/impl/src/main/res/values-es/translations.xml index c179a4d16eb..bd3bfabc794 100644 --- a/features/roomdetails/impl/src/main/res/values-es/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-es/translations.xml @@ -55,7 +55,6 @@ "Perfil" "Solicitudes de unión" "Roles y permisos" - "Nombre de la sala" "Seguridad y privacidad" "Seguridad" "Compartir sala" @@ -107,7 +106,6 @@ "Roles y permisos" "Agregar dirección de sala" "Cualquiera puede solicitar unirse a la sala, pero un administrador o moderador tendrá que aceptar la solicitud." - "Solicitud para unirse" "Sí, activar cifrado" "Una vez activado, el cifrado de una sala no se puede desactivar. El historial de mensajes solo será visible para los miembros de la sala desde que fueron invitados o desde que se unieron a la sala. Nadie más que los miembros de la sala podrán leer los mensajes. Esto puede impedir que los bots y los puentes funcionen correctamente. @@ -117,7 +115,6 @@ No recomendamos habilitar el cifrado para las salas que cualquiera pueda encontr "Cifrado" "Activar el cifrado de extremo a extremo" "Cualquiera puede encontrarla y unirse" - "Cualquiera" "Las personas solo pueden unirse si están invitadas" "Solo por invitación" "Acceso a la sala" @@ -126,7 +123,6 @@ No recomendamos habilitar el cifrado para las salas que cualquiera pueda encontr "Necesitarás una dirección de sala para que sea visible en el directorio." "Permite encontrar esta sala buscando en el directorio de salas públicas de %1$s" "Visible en el directorio de salas públicas" - "Cualquiera" "Quién puede leer el historial" "Solo participantes desde que fueron invitados" "Solo participantes desde que se selecciona esta opción" diff --git a/features/roomdetails/impl/src/main/res/values-et/translations.xml b/features/roomdetails/impl/src/main/res/values-et/translations.xml index 7a315de168f..65e39cf045f 100644 --- a/features/roomdetails/impl/src/main/res/values-et/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-et/translations.xml @@ -63,7 +63,7 @@ "Profiil" "Liitumispalved" "Rollid ja õigused" - "Jututoa nimi" + "Nimi" "Turvalisus ja privaatsus" "Turvalisus" "Jaga jututuba" @@ -71,6 +71,10 @@ "Teema" "Uuendame jututuba…" "Suhtluskeeluga kasutajaid pole" + + "%1$d suhtluskeeluga kasutaja" + "%1$d suhtluskeeluga kasutajat" + "Palun kontrolli otsingusõna korrektsust ja proovi siis uuesti" "Otsingul „%1$s“ pole tulemusi" @@ -84,6 +88,10 @@ "Eemalda suhtluskeeld jututoas" "Suhtluskeeluga kasutajad" "Liikmed" + + "%1$d saatis kutse" + "%1$d saatis kutse" + "Ootel" "Peakasutajad" "Moderaatorid" @@ -121,8 +129,10 @@ "Jututoa üksikasjad" "Rollid ja õigused" "Lisa aadress" + "Liituda saavad kõik volitatud kogukondade liikmed, kuid kõik teised peavad küsima võimalust ligipääsuks." "Kõik võivad paluda jututoaga liitumist." - "Küsi võimalust liitumiseks" + "Palu võimalust liituda" + "Liituda saavad kõik „%1$s“ kogukonna liikmed, kuid kõik teised peavad küsima võimalust ligipääsuks." "Jah, lülita krüptimine sisse" "Kui jututoa krüptimine on kord sisse lülitatud, siis seda välja lülitada ei saa. Sõnumite ajalugu on nähtav vaid jututoa liikmetele alates kutse saamise või liitumise hetkest. Keegi teine peale jututoa liikmete ei saa sõnumeid lugeda. See võib takistada suhtlusrobotite ja/või võrgusildade toimimist. @@ -132,10 +142,14 @@ Me ei soovita krüptimise kasutamist selliste avalike jututubade puhul, millega "Krüptimine" "Võta läbiv krüptimine kasutusele" "Kõik võivad jututoaga liituda" - "Kõik" + "Avalik" + "Vali kogukonnad, mille liikmed saavad selle jututoaga liituda ilma kutseta. %1$s" + "Halda kogukondi" "Liituda saab vaid kutse olemasolul" "Vaid kutsega" "Ligipääs" + "Liituda saavad kõik volitatud kogukondade liikmed." + "Liituda võivad kõik „%1$s“ liikmed." "Kogukonna liikmed" "Kogukondade tugi veel puudub" "Selleks, et jututuba oleks nähtav jututubade avalikus kataloogis, vajab ta aadressi." @@ -143,13 +157,15 @@ Me ei soovita krüptimise kasutamist selliste avalike jututubade puhul, millega "Võimalda leida seda jututuba avalikust kataloogist otsides „%1$s“" "Luba leitavus avaliku kataloogi otsingust." "Nähtav avalikus kataloogis" - "Kõik" + "Kõik (ajalugu on avalik)" + "Muudatused ei mõjuta varasemaid sõnumeid, ainult uusi. %1$s" "Kes võivad lugeda jututoa ajalugu" "Liikmed peale kutse saamist" - "Liikmed peale selle valiku sisselülitamist" + "Liikmed (terviklik ajalugu)" "Jututoa aadressid annavad võimaluse neid leida ning saada neile ligi. Samuti võimaldab see jututuba teistele huvilistele jagada. Lisaks võid sa jututoa avaldada oma koduserveri avalikus jututubade kataloogis." "Jututoa avaldamine" + "Aadressid on mugav viis jututubade ja kogukondade leidmiseks ning tagab mugava võimaluse nende jagamiseks." "Nähtavus" "Turvalisus ja privaatsus" diff --git a/features/roomdetails/impl/src/main/res/values-eu/translations.xml b/features/roomdetails/impl/src/main/res/values-eu/translations.xml index 5d0f61fb402..8f90f44a8ab 100644 --- a/features/roomdetails/impl/src/main/res/values-eu/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-eu/translations.xml @@ -53,7 +53,6 @@ "Profila" "Sartzeko eskaerak" "Rolak eta baimenak" - "Gelaren izena" "Segurtasuna eta pribatutasuna" "Segurtasuna" "Partekatu gela" @@ -104,7 +103,6 @@ "Bai, gaitu zifratzea" "Zifratzea" "Edonork aurkitu eta bat egin dezake" - "Edonork" "Gonbidatutako pertsonak bakarrik sartu ahal izango dira" "Gonbidapen bidez" "Gelarako sarbidea" @@ -112,7 +110,6 @@ "Gaur-gaurkoz ez da guneekin bateragarria" "Gelaren helbidea" "Gela publikoen direktorioan ikusgai" - "Edonork" "Nork irakur dezake historia" "Kideek bakarrik, gonbidatu zituztenetik" "Kideek bakarrik, aukera hau hautatu zenetik" diff --git a/features/roomdetails/impl/src/main/res/values-fa/translations.xml b/features/roomdetails/impl/src/main/res/values-fa/translations.xml index c04650417c3..7ae439b9835 100644 --- a/features/roomdetails/impl/src/main/res/values-fa/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-fa/translations.xml @@ -61,7 +61,6 @@ "نمایه" "درخواست‌های پیوستن" "نقش‌ها و اجازه‌ها" - "نام اتاق" "امنیت و محرمانگی" "امنیت" "هم‌رسانی اتاق" diff --git a/features/roomdetails/impl/src/main/res/values-fi/translations.xml b/features/roomdetails/impl/src/main/res/values-fi/translations.xml index b696660b629..e6a71050278 100644 --- a/features/roomdetails/impl/src/main/res/values-fi/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-fi/translations.xml @@ -63,7 +63,6 @@ "Profiili" "Liittymispyynnöt" "Roolit ja oikeudet" - "Huoneen nimi" "Turvallisuus ja yksityisyys" "Turvallisuus" "Jaa huone" @@ -143,7 +142,7 @@ Emme suosittele salauksen ottamista käyttöön huoneissa, jotka kuka tahansa vo "Kuka tahansa" "Kuka voi lukea viestihistoriaa" "Jäsenet vasta kutsusta lähtien" - "Jäsenet tämän vaihtoehdon valinnan jälkeen" + "Jäsenet (koko historia)" "Huoneosoitteet ovat tapoja löytää ja käyttää huoneita. Näin voit myös helposti jakaa huoneesi muiden kanssa. Voit halutessasi julkaista huoneesi kotipalvelimesi julkisessa huonehakemistossa." "Huoneen julkaiseminen" 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 d7df098bcff..36ea252eb36 100644 --- a/features/roomdetails/impl/src/main/res/values-fr/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-fr/translations.xml @@ -63,7 +63,7 @@ "Profil" "Demandes en attente" "Rôles & autorisations" - "Nom du salon" + "Nom" "Sécurité & confidentialité" "Sécurité" "Partager le salon" @@ -129,8 +129,10 @@ "Détails du salon" "Rôles & autorisations" "Ajouter une adresse" + "Toute personne se trouvant dans un espace autorisé peut participer, mais toutes les autres doivent demander l’accès." "Tout le monde doit demander un accès." "Demander à rejoindre" + "Tout membre de %1$s peut rejoindre l’espace, mais les autres doivent demander un accès." "Oui, activer le chiffrement" "Une fois activé, le chiffrement d’un salon ne peut pas être désactivé. L’historique des messages ne sera visible que pour les membres depuis qu’ils ont été invités ou depuis qu’ils ont rejoint le salon. Personne d’autre que les membres du salon ne pourra lire les messages. Cela peut empêcher les bots et les bridges de fonctionner correctement. @@ -141,9 +143,13 @@ Nous ne recommandons pas d’activer le chiffrement pour les salons que tout le "Activer le chiffrement de bout en bout" "Tout le monde peut rejoindre." "Tout le monde" + "Choisissez les espaces dont les membres peuvent rejoindre ce salon sans invitation. %1$s" + "Gérer les espaces" "Seules les personnes invitées peuvent rejoindre." "Sur invitation uniquement" "Accès" + "Toute personne se trouvant dans un espace autorisé peut joindre le salon." + "Toute personne de l’espace %1$s peut joindre le salon." "Membres de l’espace" "Les Espaces ne sont pas encore supportés" "Vous aurez besoin d’une adresse pour le rendre visible dans l’annuaire public." @@ -151,10 +157,11 @@ Nous ne recommandons pas d’activer le chiffrement pour les salons que tout le "Autoriser le salon à apparaître dans les résultats de recherche dans le répertoire %1$s des salons publics" "Permet d’être trouvé en recherchant dans l’annuaire public." "Visible dans l’annuaire public" - "Tout le monde" + "Tout le monde (l’historique est public)" + "Les changements n’affecteront pas les anciens messages, seulement les nouveaux. %1$s" "Qui peux lire l’historique" - "Les membres uniquement depuis qu’ils ont été invités" - "Les membres uniquement depuis la sélection de cette option" + "Seulement les membres, depuis leur invitation" + "Membres (historique complet)" "Les adresses de salon sont un moyen de trouver et d’accéder aux salons. Cela vous permet également de partager facilement votre salon avec d’autres personnes. Vous pouvez choisir de publier votre salon dans l’annuaire des salons publics de votre serveur d’accueil." "Publication du salon" diff --git a/features/roomdetails/impl/src/main/res/values-hr/translations.xml b/features/roomdetails/impl/src/main/res/values-hr/translations.xml new file mode 100644 index 00000000000..037b43be8a5 --- /dev/null +++ b/features/roomdetails/impl/src/main/res/values-hr/translations.xml @@ -0,0 +1,172 @@ + + + "Trebat će vam adresa kako bi bila vidljiva u javnom direktoriju." + "Uredi adresu" + "Došlo je do pogreške prilikom ažuriranja postavke obavijesti." + "Vaš matični poslužitelj ne podržava ovu mogućnost u šifriranim sobama; možda nećete dobiti obavijesti u nekim sobama." + "Ankete" + "Administrator" + "Zabrana pristupa osobama" + "Uklanjanje poruka" + "Član" + "Pozivanje osoba" + "Upravljanje članovima" + "Poruke i sadržaj" + "Moderator" + "Uklanjanje osoba" + "Promjena avatara" + "Uredi pojedinosti" + "Promjena imena" + "Promjena teme" + "Slanje poruka" + "Uredi administratore" + "Nećete moći poništiti ovu radnju. Postavit ćete da korisnik ima isti položaj kao i vi." + "Dodati administratora?" + "Nećete moći poništiti ovu radnju. Prenosite vlasništvo na odabrane korisnike. Nakon što odete, to će biti trajno." + "Želite li prenijeti vlasništvo?" + "Degradiraj" + "Nećete moći poništiti ovu promjenu jer sami sebe degradirate. Ako ste posljednji privilegirani korisnik u sobi, nećete moći ponovno dobiti privilegije." + "Želite li se degradirati?" + "%1$s (na čekanju)" + "(na čekanju)" + "Administratori automatski imaju moderatorske ovlasti" + "Vlasnici automatski imaju administratorske ovlasti." + "Uredi moderatore" + "Odaberi vlasnike" + "Administratori" + "Moderatori" + "Članovi" + "Niste spremili sve promjene." + "Želite li spremiti promjene?" + "Dodaj temu" + "Šifrirano" + "Nije šifrirano" + "Javna soba" + "Uredi pojedinosti" + "Došlo je do nepoznate pogreške i podatci se nisu mogli promijeniti." + "Nije moguće ažurirati sobu" + "Poruke su zaključane. Samo vi i primatelji imate jedinstvene ključeve za njihovo otključavanje." + "Omogućeno je šifriranje poruka" + "Došlo je do pogreške prilikom učitavanja postavki obavijesti." + "Isključivanje zvuka u ovoj sobi nije uspjelo, pokušajte ponovno." + "Uključivanje zvuka u ovoj sobi nije uspjelo, pokušajte ponovno." + "Ne zatvarajte aplikaciju dok se ne završi." + "Priprema pozivnica…" + "Pozovi osobe" + "Napusti razgovor" + "Napusti sobu" + "Mediji i datoteke" + "Prilagođeno" + "Zadano" + "Obavijesti" + "Prikvačene poruke" + "Profil" + "Zahtjevi za pridruživanje" + "Uloge i dopuštenja" + "Naziv" + "Sigurnost i privatnost" + "Sigurnost" + "Podijeli sobu" + "Informacije o sobi" + "Tema" + "Ažuriranje pojedinosti…" + "Nema zabranjenih korisnika." + + "%1$d zabranjen" + "%1$d zabranjena" + "%1$d zabranjenih" + + "Provjerite pravopis ili pokušajte s novim pretraživanjem" + "Nema rezultata za “%1$s”" + + "%1$d osoba" + "%1$d osobe" + "%1$d ljudi" + + "Zabrani korisnika" + "Samo ukloni člana" + "Poništi zabranu" + "Moći će se ponovno pridružiti ovoj sobi ako budu pozvani." + "Poništi zabranu pristupa korisniku" + "Zabranjeni" + "Članovi" + + "%1$d pozvan" + "%1$d pozvana" + "%1$d pozvanih" + + "Na čekanju" + "Administrator" + "Moderator" + "Vlasnik" + "Članovi sobe" + "Uklanja se zabrana korisniku %1$s" + "Dopusti prilagođenu postavku" + "Uključivanjem ovoga poništit ćete zadanu postavku" + "Obavijesti me u ovom razgovoru za" + "Možete to promijeniti u %1$s ." + "globalne postavke" + "Zadana postavka" + "Ukloni prilagođenu postavku" + "Došlo je do pogreške prilikom učitavanja postavki obavijesti." + "Vraćanje zadanog načina rada nije uspjelo, pokušajte ponovno." + "Postavljanje načina rada nije uspjelo, pokušajte ponovno." + "Vaš matični poslužitelj ne podržava ovu mogućnost u šifriranim sobama; nećete dobiti obavijest u ovoj sobi." + "Sve poruke" + "Samo spominjanja i ključne riječi" + "U ovoj sobi obavijesti me za" + "Administratori" + "Administratori i vlasnici" + "Promijeni moju ulogu" + "Degradiraj u člana" + "Degradiraj u moderatora" + "Moderiranje članova" + "Poruke i sadržaj" + "Moderatori" + "Vlasnici" + "Dopuštenja" + "Poništi dopuštenja" + "Nakon što poništite dopuštenja, izgubit ćete trenutačne postavke." + "Želite li poništiti dopuštenja?" + "Uloge" + "Pojedinosti o sobi" + "Uloge i dopuštenja" + "Dodaj adresu" + "Svatko tko se nalazi u ovlaštenim prostorima može se pridružiti, ali svi ostali moraju zatražiti pristup." + "Svi moraju zatražiti pristup." + "Svatko u %1$s može se pridružiti, ali svi ostali moraju zatražiti pristup." + "Da, omogući šifriranje" + "Nakon što se šifriranje za sobu omogući, više se neće moći onemogućiti. Povijest poruka bit će vidljiva samo članovima sobe otkad su pozvani ili otkad su joj se pridružili. +Nitko osim članova sobe neće moći čitati poruke. Zbog toga botovi i mostovi možda neće ispravno funkcionirati. +Ne preporučujemo omogućavanje šifriranja za sobe koje svatko može pronaći i pridružiti im se." + "Želite li omogućiti šifriranje?" + "Nakon što se šifriranje omogući, više se neće moći onemogućiti." + "Šifriranje" + "Omogući sveobuhvatno šifriranje" + "Svatko se može pridružiti." + "Odaberite iz kojih se prostora članovi mogu pridružiti ovoj sobi bez pozivnice. %1$s" + "Upravljaj prostorima" + "Samo pozvane osobe mogu se pridružiti." + "Samo s pozivnicom" + "Pristup" + "Svatko tko se nalazi u ovlaštenim prostorima može se pridružiti." + "Svatko u %1$s može se pridružiti." + "Članovi prostora" + "Prostori trenutačno nisu podržani" + "Trebat će vam adresa kako bi bila vidljiva u javnom direktoriju." + "Adresa" + "Omogući pronalazak ove sobe pretraživanjem %1$s javnog direktorija soba" + "Omogući pronalazak pretraživanjem javnog direktorija." + "Vidljivo u javnom direktoriju" + "Svatko (povijest je javna)" + "Promjene neće utjecati na prethodne poruke, samo na nove. %1$s" + "Tko zna čitati povijest" + "Samo za članove nakon što su pozvani" + "Članovi (cjelokupna povijest)" + "Adrese soba služe za pronalaženje i pristup sobama. Time se također osigurava jednostavno dijeljenje sobe s drugim korisnicima. +Možete odabrati objavljivanje svoje sobe u javnom direktoriju soba na matičnom poslužitelju." + "Objavljivanje sobe" + "Adrese služe za pronalaženje soba i prostora te pristup njima. Tako ih ujedno možete jednostavno dijeliti s drugima." + "Vidljivost" + "Sigurnost i privatnost" + 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 bbcf93fcf72..a549ef437c4 100644 --- a/features/roomdetails/impl/src/main/res/values-hu/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-hu/translations.xml @@ -63,7 +63,6 @@ "Profil" "Csatlakozási kérelem" "Szerepkörök és jogosultságok" - "Szoba neve" "Biztonság és adatvédelem" "Biztonság" "Szoba megosztása" @@ -71,6 +70,8 @@ "Téma" "Szoba frissítése…" "Ebben a szobában nincsenek kitiltott felhasználók." + "Ellenőrizze a helyesírást, vagy próbáljon meg egy új keresést" + "Nincs találat a következőre: „%1$s\"" "%1$d személy" "%1$d személy" @@ -130,7 +131,7 @@ Nem javasoljuk a titkosítás engedélyezését az olyan szobákban, amelyeket b "Titkosítás" "Végpontok közötti titkosítás engedélyezése" "Bárki csatlakozhat." - "Bárki" + "Nyilvános" "Csak a meghívott emberek léphetnek be." "Csak meghívásos" "Hozzáférés" diff --git a/features/roomdetails/impl/src/main/res/values-in/translations.xml b/features/roomdetails/impl/src/main/res/values-in/translations.xml index 097cd371f6b..27a1a5745ec 100644 --- a/features/roomdetails/impl/src/main/res/values-in/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-in/translations.xml @@ -55,7 +55,6 @@ "Profil" "Permintaan untuk bergabung" "Peran dan perizinan" - "Nama ruangan" "Keamanan & privasi" "Keamanan" "Bagikan ruangan" @@ -106,7 +105,6 @@ "Peran dan perizinan" "Tambahkan alamat ruangan" "Siapa pun dapat meminta untuk bergabung dengan ruangan tetapi administrator atau moderator harus menerima permintaan tersebut." - "Minta untuk bergabung" "Ya, aktifkan enkripsi" "Setelah diaktifkan, encryption untuk sebuah ruangan tidak dapat dinonaktifkan, Riwayat pesan hanya akan terlihat oleh anggota ruangan sejak mereka diundang atau sejak mereka bergabung dengan ruangan tersebut. Tidak ada orang lain selain anggota ruangan yang dapat membaca pesan. Hal ini dapat mencegah bot dan jembatan bekerja dengan benar. @@ -116,7 +114,6 @@ Kami tidak menyarankan untuk mengaktifkan enkripsi untuk ruangan yang dapat dite "Enkripsi" "Aktifkan enkripsi ujung ke ujung" "Siapa pun dapat menemukan dan bergabung" - "Siapa pun" "Orang hanya dapat bergabung jika mereka diundang" "Hanya undangan" "Akses ruangan" @@ -125,7 +122,6 @@ Kami tidak menyarankan untuk mengaktifkan enkripsi untuk ruangan yang dapat dite "Anda akan memerlukan alamat ruangan untuk membuatnya terlihat dalam direktori." "Izinkan ruangan ini ditemukan dengan mencari direktori ruangan %1$s publik" "Terlihat di direktori ruangan publik" - "Siapa pun" "Siapa yang bisa membaca riwayat" "Hanya anggota sejak mereka diundang" "Hanya anggota sejak memilih opsi ini" diff --git a/features/roomdetails/impl/src/main/res/values-it/translations.xml b/features/roomdetails/impl/src/main/res/values-it/translations.xml index 3d83dba3cb6..6d73fc83c95 100644 --- a/features/roomdetails/impl/src/main/res/values-it/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-it/translations.xml @@ -63,7 +63,6 @@ "Profilo" "Richieste di accesso" "Ruoli e autorizzazioni" - "Nome stanza" "Sicurezza e privacy" "Sicurezza" "Condividi stanza" diff --git a/features/roomdetails/impl/src/main/res/values-ka/translations.xml b/features/roomdetails/impl/src/main/res/values-ka/translations.xml index d9c56aa89c9..3ffc9626031 100644 --- a/features/roomdetails/impl/src/main/res/values-ka/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-ka/translations.xml @@ -46,7 +46,6 @@ "შეტყობინებები" "პროფილი" "როლები და ნებართვები" - "ოთახის სახელი" "უსაფრთხოება" "ოთახის გაზიარება" "ოთახის ინფორმაცია" diff --git a/features/roomdetails/impl/src/main/res/values-ko/translations.xml b/features/roomdetails/impl/src/main/res/values-ko/translations.xml index 22f3fe99e65..7c539216555 100644 --- a/features/roomdetails/impl/src/main/res/values-ko/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-ko/translations.xml @@ -59,7 +59,6 @@ "프로필" "참여 요청" "역할 및 권한" - "방 이름" "보안 및 개인정보 보호" "보안" "방 공유하기" @@ -113,7 +112,6 @@ "역할 및 권한" "방 주소 추가" "누구나 방에 참여 요청을 할 수 있지만, 관리자나 운영자가 요청을 수락해야 합니다." - "참가 요청" "예, 암호화 활성화" "일단 활성화되면, 방의 암호화는 비활성화할 수 없습니다. 메시지 기록은 방에 초대된 후 또는 방에 참여한 이후부터 방 구성원만 볼 수 있습니다. 방 구성원 외에는 아무도 메시지를 읽을 수 없습니다. 이로 인해 봇과 브리지가 제대로 작동하지 않을 수 있습니다. @@ -123,7 +121,6 @@ "암호화" "종단간 암호화 활성화" "누구나 찾을 수 있고 참여할 수 있습니다." - "누구나" "초대받은 사용자만 가입할 수 있습니다." "초대 전용" "방 액세스" @@ -132,7 +129,6 @@ "디렉토리에 표시하려면 방 주소가 필요합니다." "%1$s 공개 방 디렉토리에서 이 방을 검색할 수 있도록 허용합니다" "공개 룸 디렉토리에 표시됨" - "누구나" "누가 기록을 읽을 수 있는가" "초대받은 회원만 이용 가능합니다" "이 옵션을 선택한 회원만 이용 가능합니다." 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 727875d6965..fc0e84f6ccc 100644 --- a/features/roomdetails/impl/src/main/res/values-lt/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-lt/translations.xml @@ -10,7 +10,6 @@ "Pakviesti žmonių" "Palikti pokalbį" "Palikti kambarį" - "Kambario pavadinimas" "Saugumas" "Bendrinti kambarį" "Tema" diff --git a/features/roomdetails/impl/src/main/res/values-nb/translations.xml b/features/roomdetails/impl/src/main/res/values-nb/translations.xml index 7d23507a8b5..d28c16f0caf 100644 --- a/features/roomdetails/impl/src/main/res/values-nb/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-nb/translations.xml @@ -1,19 +1,21 @@ - "Du trenger en adresse til rommet for å gjøre det synlig i katalogen." - "Romadresse" + "Du trenger en adresse for å gjøre den synlig i den offentlige katalogen." + "Rediger adresse" "Det oppstod en feil under oppdatering av varslingsinnstillingen." "Hjemmeserveren din støtter ikke dette alternativet i krypterte rom, og det kan hende at du ikke blir varslet i enkelte rom." "Avstemninger" - "Kun for administratorer" + "Admin" "Forby folk" "Fjern meldinger" - "Inviter folk og godta forespørsler om å bli med" + "Medlem" + "Inviter folk" + "Administrer medlemmer" "Meldinger og innhold" - "Administratorer og moderatorer" - "Fjern folk og avslå forespørsler om å bli med" + "Moderator" + "Fjern folk" "Endre romavatar" - "Rediger rom" + "Rediger detaljer" "Endre romnavn" "Endre temaet til rommet" "Send meldinger" @@ -40,7 +42,7 @@ "Kryptert" "Ikke kryptert" "Offentlig rom" - "Rediger rom" + "Rediger detaljer" "Det oppstod en ukjent feil, og informasjonen kunne ikke endres." "Kan ikke oppdatere rommet" "Meldingene er krypterte. Det er bare du og mottakerne som har de unike nøklene til å låse dem opp." @@ -61,14 +63,13 @@ "Profil" "Forespørsler om å bli med" "Roller og tillatelser" - "Romnavn" "Sikkerhet og personvern" "Sikkerhet" "Del rom" "Informasjon om rommet" "Emne" "Oppdaterer rommet …" - "Det er ingen utestengte brukere i dette rommet." + "Det er ingen utestengte brukere." "%1$d person" "%1$d personer" @@ -80,8 +81,8 @@ "Fjern utestengelsen fra rommet" "Utestengt" "Medlemmer" - "Kun for administratorer" - "Administratorer og moderatorer" + "Admin" + "Moderator" "Eier" "Medlemmer av rommet" "Oppheve utestengelsen av %1$s" @@ -114,9 +115,8 @@ "Roller" "Romdetaljer" "Roller og tillatelser" - "Legg til romadresse" - "Alle kan be om å bli med i rommet, men en administrator eller moderator må godta forespørselen." - "Be om å bli med" + "Legg til adresse" + "Alle må be om tilgang." "Ja, aktiver kryptering" "Når kryptering for et rom er aktivert, kan den ikke deaktiveres. Meldingshistorikken vil bare være synlig for rommedlemmer siden de ble invitert eller siden de ble med i rommet. Ingen andre enn rommedlemmene vil kunne lese meldingene. Dette kan føre til at bots og broer ikke fungerer som de skal. @@ -125,22 +125,23 @@ Vi anbefaler ikke å aktivere kryptering for rom som hvem som helst kan finne og "Når kryptering er aktivert, kan det ikke deaktiveres." "Kryptering" "Aktiver ende-til-ende-kryptering" - "Alle kan finne og bli med" - "Alle" - "Folk kan bare bli med hvis de er invitert" + "Alle kan bli med." + "Bare inviterte personer kan bli med." "Kun for inviterte" - "Tilgang til rom" + "Tilgang" "Medlemmer av område" "Områder støttes ikke for øyeblikket" - "Du trenger en adresse til rommet for å gjøre det synlig i katalogen." + "Du trenger en adresse for å gjøre den synlig i den offentlige katalogen." + "Adresse" "Tillat at dette rommet blir funnet ved å søke %1$s offentlig romkatalog" - "Synlig i offentlig romkatalog" - "Alle" + "Synlig i offentlig katalog" + "Alle (historikken er offentlig)" "Hvem kan lese historikk" - "Medlemmer bare siden de ble invitert" - "Kun medlemmer siden du valgte dette alternativet" + "Medlemmer siden de ble invitert" + "Medlemmer (full historikk)" "Romadresser er måter å finne og få tilgang til rom på. Dette sikrer også at du enkelt kan dele rommet ditt med andre. Du kan velge å publisere rommet ditt i hjemeserverens offentlige romkatalog." "Publisering av rom" + "Synlighet" "Sikkerhet og personvern" diff --git a/features/roomdetails/impl/src/main/res/values-nl/translations.xml b/features/roomdetails/impl/src/main/res/values-nl/translations.xml index 73e7b7f8c69..f6655f690d5 100644 --- a/features/roomdetails/impl/src/main/res/values-nl/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-nl/translations.xml @@ -51,7 +51,6 @@ "Vastgezette berichten" "Profiel" "Rollen en rechten" - "Naam van de kamer" "Beveiliging" "Kamer delen" "Kamer info" @@ -99,7 +98,4 @@ "Rollen" "Kamergegevens" "Rollen en rechten" - "Vraag om toe te treden" - "Iedereen" - "Iedereen" 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 a24774c7367..551e4cf5c86 100644 --- a/features/roomdetails/impl/src/main/res/values-pl/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-pl/translations.xml @@ -61,7 +61,6 @@ "Profil" "Prośby o dołączenie" "Role i uprawnienia" - "Nazwa pokoju" "Bezpieczeństwo i prywatność" "Bezpieczeństwo" "Udostępnij pokój" @@ -127,7 +126,7 @@ Odradzamy włączanie szyfrowania dla pokoi, które każdy może znaleźć i do "Szyfrowanie" "Włącz szyfrowanie end-to-end" "Każdy może znaleźć i dołączyć" - "Wszyscy" + "Każdy" "Tylko osoby z zaproszeniem mogą dołączyć" "Tylko zaproszenie" "Dostęp do pokoju" @@ -137,7 +136,7 @@ Odradzamy włączanie szyfrowania dla pokoi, które każdy może znaleźć i do "Adres pokoju" "Zezwól na znalezienie tego pokoju wyszukując %1$s w katalogu pokoi publicznych" "Widoczny w katalogu pokoi publicznych" - "Wszyscy" + "Ktokolwiek" "Kto może czytać historię" "Od momentu kiedy członkowie zostali zaproszeni" "Członkowie od momentu włączenia tej opcji" diff --git a/features/roomdetails/impl/src/main/res/values-pt-rBR/translations.xml b/features/roomdetails/impl/src/main/res/values-pt-rBR/translations.xml index da19da98edf..b24f4e57169 100644 --- a/features/roomdetails/impl/src/main/res/values-pt-rBR/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-pt-rBR/translations.xml @@ -63,7 +63,7 @@ "Perfil" "Pedidos de entrada" "Cargos e permissões" - "Nome da sala" + "Nome" "Segurança e privacidade" "Segurança" "Compartilhar sala" @@ -129,8 +129,9 @@ "Detalhes da sala" "Cargos e permissões" "Adicionar endereço" + "Qualquer um nos espaços autorizados podem entrar, mas todos os outros devem pedir acesso." "Qualquer um pode pedir acesso, mas um administrador terá que aceitar o pedido." - "Pedir para entrar" + "Qualquer um em %1$s pode entrar, mas todos os outros devem pedir acesso." "Sim, ativar a criptografia" "Uma vez ativada, a criptografia de uma sala não pode ser desativada. O histórico de mensagens só será visível para os membros da sala desde que foram convidados ou desde que entraram na sala. Ninguém além dos membros da sala poderá ler as mensagens. Isso pode impedir que os bots e as pontes funcionem corretamente. @@ -140,10 +141,13 @@ Não recomendamos que você ative a criptografia para salas que qualquer pessoa "Criptografia" "Ativar a criptografia de ponta a ponta" "Qualquer um pode entrar" - "Qualquer pessoa" + "Escolha os espaços dos quais os membros podem entrar nesta sala sem um convite. %1$s" + "Gerenciar espaços" "Apenas pessoas convidadas podem entrar." "Privado" "Acesso" + "Qualquer um em espaços autorizados podem entrar." + "Qualquer pessoa em %1$s pode participar." "Membros do espaço" "No momento, não há suporte aos espaços" "Você precisará de um endereço para torná-la visível no diretório." @@ -151,10 +155,11 @@ Não recomendamos que você ative a criptografia para salas que qualquer pessoa "Permitir que esta sala seja encontrada pesquisando diretório de salas públicas de %1$s" "Permite que seja encontrada ao buscar no diretório público." "Visível no diretório público" - "Qualquer pessoa" + "Qualquer um (histórico público)" + "As alterações não afetarão mensagens anteriores, somente as novas. %1$s" "Quem pode ler o histórico" - "Somente membros, desde que foram convidados" - "Somente para membros após selecionar esta opção" + "Membros desde o convite" + "Membros (histórico completo)" "Os endereços das salas são formas de encontrar e acessar as salas. Isso também garante que você possa compartilhar facilmente sua sala com outras pessoas. Você pode optar por publicar sua sala no diretório público de salas do seu servidor-casa." "Publicação da sala" diff --git a/features/roomdetails/impl/src/main/res/values-pt/translations.xml b/features/roomdetails/impl/src/main/res/values-pt/translations.xml index b682c0597dd..6e75c110ee1 100644 --- a/features/roomdetails/impl/src/main/res/values-pt/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-pt/translations.xml @@ -61,7 +61,6 @@ "Perfil" "Pedidos de entrada" "Cargos e permissões" - "Nome da sala" "Segurança e privacidade" "Segurança" "Partilhar sala" @@ -116,7 +115,7 @@ "Cargos e permissões" "Adicionar endereço de sala" "Qualquer pessoa pode pedir para entrar na sala, mas um administrador ou moderador tem que aceitar o pedido." - "Pedir para participar" + "Pedir para entrar" "Sim, ativar cifragem" "Uma vez ativada, a cifragem não pode ser desativada. O histórico de mensagens só será visível a membros a partir do momento em que foram convidados ou que entraram na sala. Ninguém além dos membros poderão ler quaisquer mensagens. Isto pode impedir que robôs (\"bots\") e pontes (\"bridges\") funcionem devidamente. 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 f0c43e53a5a..3ac69c59a5e 100644 --- a/features/roomdetails/impl/src/main/res/values-ro/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-ro/translations.xml @@ -1,19 +1,21 @@ - "Veți avea nevoie de o adresă de cameră pentru a o face vizibilă în director." - "Adresa camerei" + "Veți avea nevoie de o adresă pentru a o face vizibilă în directorul public." + "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." "Sondaje" - "Doar administratori" + "Administrator" "Interziceți persoane" "Ștergeți mesajele" - "Invitați persoane și acceptați cereri de alaturare" + "Membru" + "Invitați persoane" + "Gestionați membrii" "Mesaje și conținut" - "Administratori și moderatori" - "Îndepărtați persoane și refuzați cereri de alăturare" + "Moderator" + "Îndepărtați persoane" "Schimbați avatarul camerei" - "Editați camera" + "Editați detaliile" "Schimbă numele camerei" "Schimbați subiectul camerei" "Trimiteți mesaje" @@ -40,7 +42,7 @@ "Criptat" "Necriptat" "Cameră publică" - "Editați camera" + "Editați detaliile" "A apărut o eroare la actualizarea detaliilor camerei" "Nu s-a putut actualiza camera" "Mesajele sunt securizate cu încuietori. Doar dumneavoastră și destinatarii aveți cheile unice pentru a le debloca." @@ -61,16 +63,24 @@ "Profil" "Cereri de alăturare" "Roluri și permisiuni" - "Numele camerei" + "Nume" "Securitate & confidențialitate" "Securitate" "Partajați camera" "Informatii camera" "Subiect" "Se actualizează camera…" - "Nu există utilizatori interziși în această cameră." + "Nu există utilizatori interziși." + + "%1$d Interzis" + "%1$d Interziși" + "%1$d Interziși" + + "Verificați ortografia sau încercați o căutare nouă" + "Niciun rezultat pentru “%1$s”" - "o persoană" + "%1$d persoană" + "%1$d persoane" "%1$d persoane" "Îndepărtați și interziceți membrul" @@ -80,8 +90,14 @@ "Revocati excluderea din camera" "Excluși" "Membri" - "Doar administratori" - "Administratori și moderatori" + + "%1$d Invitat" + "%1$d Invitați" + "%1$d Invitați" + + "În așteptare" + "Administrator" + "Moderator" "Proprietar" "Membrii camerei" "Se anulează interzicerea lui %1$s" @@ -108,15 +124,17 @@ "Mesaje și conținut" "Moderatori" "Proprietari" + "Permisiuni" "Resetați permisiunile" "După ce resetați permisiunile, veți pierde setările curente." "Resetați permisiunile?" "Roluri" "Detaliile camerei" "Roluri și permisiuni" - "Adăugați adresa camerei" - "Oricine poate cere să se alăture camerei, dar un administrator sau moderator va trebui să accepte cererea." - "Cereți să vă alăturați" + "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." + "Oricine în %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. @@ -125,22 +143,28 @@ Nu recomandăm activarea criptării pentru camerele pe care oricine le poate gă "Odată activată, criptarea nu poate fi dezactivată." "Criptare" "Activați criptarea end-to-end" - "Oricine poate găsi și alătura camerei" - "Oricine" - "Persoanele se pot alătura numai dacă invitate" + "Oricine se poate alătura." + "Alegeți membrii căror spații 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" - "Acces la cameră" + "Acces" + "Oricine se află într-un spațiu autorizat poate participa." + "Oricine din %1$s se poate alătura." "Membrii spațiului" "Spațiile nu sunt momentan suportate." - "Veți avea nevoie de o adresă de cameră pentru a o face vizibilă în director." + "Veți avea nevoie de o adresă pentru a o face vizibilă în directorul public." + "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" - "Oricine" "Cine poate citi mesajele anterioare" "Doar pentru membri, de la momentul în care au fost invitați" "Doar pentru membri, după selectarea acestei opțiuni" "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ă." "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" diff --git a/features/roomdetails/impl/src/main/res/values-ru/translations.xml b/features/roomdetails/impl/src/main/res/values-ru/translations.xml index 47de4c388c1..e618bd8dc1a 100644 --- a/features/roomdetails/impl/src/main/res/values-ru/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-ru/translations.xml @@ -63,7 +63,6 @@ "Профиль" "Запросы на вступление" "Роли и разрешения" - "Название комнаты" "Безопасность и конфиденциальность" "Безопасность" "Поделиться комнатой" @@ -123,7 +122,7 @@ "Роли и разрешения" "Добавить адрес" "Каждый должен запросить доступ." - "Попросить присоединиться" + "Присоединиться" "Да, включить шифрование" "Шифрование комнаты нельзя будет отключить, история сообщений будет видна только участникам комнаты с момента их приглашения или с момента присоединения к комнате. Никто, кроме членов комнаты, не сможет читать сообщения. Это может помешать ботам и мостам работать корректно. @@ -133,7 +132,7 @@ "Шифрование" "Включить сквозное шифрование" "Любой желающий может найти и присоединиться" - "Любой" + "Публичный" "Присоединиться могут только приглашенные люди." "Только по приглашению" "Доступ" diff --git a/features/roomdetails/impl/src/main/res/values-sk/translations.xml b/features/roomdetails/impl/src/main/res/values-sk/translations.xml index 477b4197888..7cd14bc7a55 100644 --- a/features/roomdetails/impl/src/main/res/values-sk/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-sk/translations.xml @@ -1,19 +1,21 @@ - "Budete potrebovať adresu miestnosti, aby bola viditeľná v adresári." - "Adresa miestnosti" + "Budete potrebovať adresu, aby sa zobrazovala vo verejnom adresári." + "Upraviť adresu" "Pri aktualizácii nastavenia oznámenia došlo k chybe." "Váš domovský server nepodporuje túto možnosť v šifrovaných miestnostiach, v niektorých miestnostiach nemusíte dostať upozornenie." "Ankety" - "Iba správcovia" + "Správca" "Zakázať ľudí" "Odstrániť správy" - "Pozvite ľudí a prijmite žiadosti o pripojenie" + "Člen" + "Pozvať ľudí" + "Spravovať členov" "Správy a obsah" - "Správcovia a moderátori" - "Odstrániť ľudí a odmietnuť žiadosti o pripojenie" + "Moderátor" + "Odstrániť ľudí" "Zmeniť obrázok miestnosti" - "Upraviť miestnosť" + "Upraviť podrobnosti" "Zmeniť názov miestnosti" "Zmeniť tému miestnosti" "Odoslať správy" @@ -40,7 +42,7 @@ "Zašifrované" "Nešifrované" "Verejná miestnosť" - "Upraviť miestnosť" + "Upraviť podrobnosti" "Vyskytla sa neznáma chyba a informácie nebolo možné zmeniť." "Nepodarilo sa aktualizovať miestnosť" "Správy sú zabezpečené zámkami. Jedine vy a príjemcovia máte jedinečné kľúče na ich odomknutie." @@ -61,7 +63,6 @@ "Profil" "Žiadosti o vstup" "Roly a povolenia" - "Názov miestnosti" "Bezpečnosť a súkromie" "Bezpečnosť" "Zdieľať miestnosť" @@ -69,6 +70,13 @@ "Téma" "Aktualizácia miestnosti…" "Neexistujú žiadni zablokovaní používatelia." + + "%1$d zakázaný" + "%1$d zakázaní" + "%1$d zakázaných" + + "Skontrolujte preklepy alebo skúste nové vyhľadávanie" + "Žiadne výsledky pre „%1$s“" "%1$d osoba" "%1$d osoby" @@ -81,8 +89,14 @@ "Zrušiť zákaz prístupu do miestnosti" "Zakázaní" "Členovia" - "Iba správcovia" - "Správcovia a moderátori" + + "%1$d pozvaný" + "%1$d pozvaní" + "%1$d pozvaných" + + "Čaká na schválenie" + "Správca" + "Moderátor" "Vlastník" "Členovia miestnosti" "Zrušenie zákazu %1$s" @@ -109,14 +123,15 @@ "Správy a obsah" "Moderátori" "Vlastníci" + "Povolenia" "Obnoviť povolenia" "Po obnovení oprávnení prídete o aktuálne nastavenia." "Obnoviť oprávnenia?" "Roly" "Podrobnosti o miestnosti" "Roly a povolenia" - "Pridať adresu miestnosti" - "Ktokoľvek môže požiadať o pripojenie do miestnosti, ale správca alebo moderátor bude musieť žiadosť prijať." + "Pridať adresu" + "Všetci musia požiadať o prístup." "Požiadať o pripojenie" "Áno, povoliť šifrovanie" "Po aktivácii nie je možné zakázať šifrovanie pre miestnosť. História správ bude viditeľná len pre členov miestnosti, odkedy boli pozvaní alebo keď vstúpili do miestnosti. @@ -126,17 +141,22 @@ To môže brániť správnemu fungovaniu robotov a premostení. Neodporúčame p "Po zapnutí už šifrovanie nie je možné vypnúť." "Šifrovanie" "Povoliť end-to-end šifrovanie" - "Ktokoľvek môže nájsť a pripojiť sa" + "Pripojiť sa môže ktokoľvek." "Ktokoľvek" - "Ľudia sa môžu pripojiť len vtedy, ak sú pozvaní" + "Vyberte, ktorých členovia priestorov sa môžu pripojiť k tejto miestnosti bez pozvánky. %1$s" + "Spravovať priestory" + "Pripojiť sa môžu iba pozvaní ľudia." "Iba na pozvánku" - "Prístup do miestnosti" + "Prístup" + "Ktokoľvek v povolených priestoroch sa môže pripojiť." + "Ktokoľvek v %1$s sa môže pripojiť." "Členovia priestoru" "Priestory momentálne nie sú podporované" - "Budete potrebovať adresu miestnosti, aby bola viditeľná v adresári." - "Adresa miestnosti" + "Budete potrebovať adresu, aby sa zobrazovala vo verejnom adresári." + "Adresa" "Umožniť vyhľadanie tejto miestnosti v adresári verejných miestností %1$s" - "Viditeľné v adresári verejných miestností" + "Umožniť nájdenie vyhľadávaním vo verejnom adresári." + "Viditeľné vo verejnom adresári" "Ktokoľvek" "Kto môže čítať históriu" "Len pre členov, odkedy boli pozvaní" @@ -144,6 +164,7 @@ To môže brániť správnemu fungovaniu robotov a premostení. Neodporúčame p "Adresy miestností predstavujú spôsoby, ako nájsť a získať prístup k miestnostiam. To tiež zaisťuje, že môžete jednoducho zdieľať svoju miestnosť s ostatnými. Môžete sa rozhodnúť zverejniť svoju miestnosť v adresári verejných miestností vášho domovského servera." "Zverejnenie miestnosti" - "Viditeľnosť miestnosti" + "Adresy sú spôsob, ako nájsť a získať prístup do miestností a priestorov. To tiež zabezpečuje, že ich môžete jednoducho zdieľať s ostatnými." + "Viditeľnosť" "Bezpečnosť a súkromie" diff --git a/features/roomdetails/impl/src/main/res/values-sv/translations.xml b/features/roomdetails/impl/src/main/res/values-sv/translations.xml index 2dc590c2f41..0d7ade3f392 100644 --- a/features/roomdetails/impl/src/main/res/values-sv/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-sv/translations.xml @@ -59,7 +59,6 @@ "Profil" "Begäran om att gå med" "Roller och behörigheter" - "Rumsnamn" "Säkerhet och sekretess" "Säkerhet" "Dela rum" @@ -133,7 +132,6 @@ Vi rekommenderar inte att aktivera kryptering för rum som vem som helst kan hit "Du behöver en rumsadress för att göra den synlig i katalogen." "Tillåt att detta rum hittas genom att söka i den offentliga rumskatalogen på %1$s" "Synlig i katalogen för offentliga rum" - "Vem som helst" "Vem kan läsa historik" "Endast medlemmar sedan de bjöds in" "Endast medlemmar sedan det här alternativet har valts" diff --git a/features/roomdetails/impl/src/main/res/values-tr/translations.xml b/features/roomdetails/impl/src/main/res/values-tr/translations.xml index 01cac3a6cb2..ab6c02fdf9d 100644 --- a/features/roomdetails/impl/src/main/res/values-tr/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-tr/translations.xml @@ -55,7 +55,6 @@ "Profil" "Katılma istekleri" "Roller ve izinler" - "Oda adı" "Güvenlik ve gizlilik" "Güvenlik" "Oda paylaş" @@ -106,7 +105,7 @@ "Roller ve izinler" "Oda adresi ekle" "Herkes odaya katılma isteğinde bulunabilir ancak bir yönetici veya moderatörün isteği kabul etmesi gerekir." - "Katılmak için sor" + "Katılma isteği gönder" "Evet, şifrelemeyi etkinleştir" "Etkinleştirildikten sonra, bir oda için şifreleme devre dışı bırakılamaz, Mesaj geçmişi yalnızca davet edildiklerinden veya odaya katıldıklarından beri oda üyeleri için görünür olacaktır. Oda üyeleri dışında hiç kimse mesajları okuyamayacaktır. Bu, botların ve köprülerin düzgün çalışmasını engelleyebilir. 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 57a604bfe8d..65e424398d5 100644 --- a/features/roomdetails/impl/src/main/res/values-uk/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-uk/translations.xml @@ -59,7 +59,6 @@ "Профіль" "Запити на приєднання" "Ролі та дозволи" - "Назва кімнати" "Безпека й приватність" "Безпека" "Поділитися кімнатою" @@ -115,7 +114,7 @@ "Ролі та дозволи" "Додати адресу кімнати" "Будь-хто може надіслати запит приєднатися до кімнати, але адміністратор або модератор повинні прийняти запит." - "Запросити приєднатися" + "Запит на приєднання" "Так, увімкнути шифрування" "Після ввімкнення шифрування кімнати, його неможливо вимкнути, історію повідомлень бачитимуть лише учасники кімнати, яких було запрошено або які приєдналися до кімнати. Ніхто, крім учасників кімнати, не зможе прочитати повідомлення. Це може перешкоджати коректній роботі ботів і мостів. @@ -125,7 +124,7 @@ "Шифрування" "Увімкнути наскрізне шифрування" "Будь-хто може знайти та приєднатися." - "Кожний" + "Будь-хто" "Люди можуть приєднатися, лише якщо їх запросили" "Лише запрошені" "Доступ до кімнати" @@ -135,7 +134,7 @@ "Адреса кімнати" "Дозвольте, щоб цю кімнату можна було знайти за допомогою пошуку в каталозі загальнодоступних кімнат %1$s " "Видима в каталозі загальнодоступних кімнат" - "Кожний" + "Будь-хто" "Хто може читати історію" "Лише учасники з моменту запрошення" "Лише учасники після вибору цього параметра" diff --git a/features/roomdetails/impl/src/main/res/values-ur/translations.xml b/features/roomdetails/impl/src/main/res/values-ur/translations.xml index 524afe515db..3715bb91ffa 100644 --- a/features/roomdetails/impl/src/main/res/values-ur/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-ur/translations.xml @@ -51,7 +51,6 @@ "مثبوتہ پیغامات" "نمایہ" "کردارہا اور اجازتیں" - "کمرے کا نام" "حفاظت" "کمرے کا اشتراک کریں" "کمرے کی معلومات" diff --git a/features/roomdetails/impl/src/main/res/values-uz/translations.xml b/features/roomdetails/impl/src/main/res/values-uz/translations.xml index 401d015735f..d401c7e371c 100644 --- a/features/roomdetails/impl/src/main/res/values-uz/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-uz/translations.xml @@ -9,11 +9,12 @@ "Odamlarni taqiqlash" "Xabarlarni olib tashlash" "Odamlarni taklif qiling va qo‘shilish so‘rovlarini qabul qiling" + "A’zolarni boshqarish" "Xabarlar va kontent" "Adminlar va moderatorlar" "Odamlarni olib tashlash va qoʻshilish soʻrovlarini rad etish" "Xona avatarini oʻzgartirish" - "Xonani tahrirlash" + "Tafsilotlarni tahrirlash" "Xona nomini oʻzgartirish" "Xona mavzusini almashtirish" "Xabarlar yuborish" @@ -40,7 +41,7 @@ "Shifrlangan" "Shifrlanmagan" "Jamoat xonasi" - "Xonani tahrirlash" + "Tafsilotlarni tahrirlash" "Nomaʼlum xatolik yuz berdi va maʼlumotni oʻzgartirib boʻlmadi." "Xonani yangilab bo‘lmadi" "Xabarlar qulflar bilan himoyalangan. Faqat siz va qabul qiluvchilar ularni qulfdan chiqarish uchun noyob kalitlarga ega." @@ -59,7 +60,6 @@ "Profil" "Qo‘shilish uchun so‘rovlar" "Rollar va ruxsatlar" - "Xona nomi" "Xavfsizlik va maxfiylik" "Xavfsizlik" "Xonani baham ko\'ring" @@ -114,7 +114,6 @@ "Rollar va ruxsatlar" "Xona manzilini kiritish" "Xonaga qo‘shilishni istalgan kishi so‘rashi mumkin, lekin administrator yoki moderator so‘rovni qabul qilishi kerak" - "Qo‘shilishni so‘rang" "Ha, shifrlashni yoqish" "Yoqilgandan so‘ng, xona uchun shifrlashni o‘chirib bo‘lmaydi. Xabarlar tarixi faqat xona a’zolari taklif qilinganidan yoki xonaga qo‘shilganidan keyingi davrdan boshlab ko‘rinadi. Xona a’zolaridan tashqari hech kim xabarlarni o‘qiy olmaydi. Bu botlar va ko‘priklarning to‘g‘ri ishlashiga to‘sqinlik qilishi mumkin. Shu sababli, har kim topishi va qo‘shilishi mumkin bo‘lgan xonalar uchun shifrlashni yoqishni tavsiya etmaymiz." @@ -123,7 +122,6 @@ Shu sababli, har kim topishi va qo‘shilishi mumkin bo‘lgan xonalar uchun shi "Shifrlash" "End-to-end shifrlashni yoqish" "Istalgan kishi topishi va qo‘shilishi mumkin" - "Har kim" "Odamlar faqat taklif qilingan taqdirdagina qo‘shilishi mumkin" "Faqat taklif qilish" "Xonaga kirish huquqi" @@ -132,7 +130,6 @@ Shu sababli, har kim topishi va qo‘shilishi mumkin bo‘lgan xonalar uchun shi "Katalogda ko‘rinadigan qilish uchun xona manzili kerak bo‘ladi." "Bu xonani %1$s umumiy xonalar ro‘yxatidan qidirib topish imkoniyatini berish" "Umumiy xona ro‘yxatida ko‘rinadi" - "Har kim" "Tarixni kim o‘qiy oladi" "Taklif qilinganidan buyon faqat a’zolar" "A’zolar faqat bu parametr tanlanganidan keyin" diff --git a/features/roomdetails/impl/src/main/res/values-zh-rTW/translations.xml b/features/roomdetails/impl/src/main/res/values-zh-rTW/translations.xml index 0332c0ffd50..8649a29ba66 100644 --- a/features/roomdetails/impl/src/main/res/values-zh-rTW/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-zh-rTW/translations.xml @@ -63,7 +63,6 @@ "個人檔案" "請求加入" "角色與權限" - "聊天室名稱" "安全與隱私" "安全性" "分享聊天室" 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 f925bdca2a5..2568b550a30 100644 --- a/features/roomdetails/impl/src/main/res/values-zh/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-zh/translations.xml @@ -61,7 +61,6 @@ "个人资料" "申请加入" "角色与权限" - "聊天室名称" "安全与隐私" "安全" "分享聊天室" diff --git a/features/roomdetails/impl/src/main/res/values/localazy.xml b/features/roomdetails/impl/src/main/res/values/localazy.xml index c582f6f6fba..ca00571dd1e 100644 --- a/features/roomdetails/impl/src/main/res/values/localazy.xml +++ b/features/roomdetails/impl/src/main/res/values/localazy.xml @@ -63,13 +63,13 @@ "Profile" "Requests to join" "Roles & permissions" - "Room name" + "Name" "Security & privacy" "Security" "Share room" "Room info" "Topic" - "Updating room…" + "Updating details…" "There are no banned users." "%1$d Banned" @@ -129,8 +129,10 @@ "Room details" "Roles & permissions" "Add address" + "Anyone in authorised spaces can join, but everyone else must request access." "Everyone must request access." "Ask to join" + "Anyone in %1$s can join, but everyone else must request access." "Yes, enable encryption" "Once enabled, encryption for a room cannot be disabled, Message history will only be visible for room members since they were invited or since they joined the room. No one besides the room members will be able to read messages. This may prevent bots and bridges to work correctly. @@ -141,12 +143,12 @@ We do not recommend enabling encryption for rooms that anyone can find and join. "Enable end-to-end encryption" "Anyone can join." "Anyone" - "Choose which spaces’ members can join this room without an invitation. %1$s" + "Choose which spaces’ members can join this room without an invitation. %1$s" "Manage spaces" "Only invited people can join." "Invite only" "Access" - "Anyone in authorized spaces can join." + "Anyone in authorised spaces can join." "Anyone in %1$s can join." "Space members" "Spaces are not currently supported" @@ -155,10 +157,11 @@ We do not recommend enabling encryption for rooms that anyone can find and join. "Allow for this room to be found by searching %1$s public room directory" "Allow to be found by searching the public directory." "Visible in public directory" - "Anyone" + "Anyone (history is public)" + "Changes won\'t affect past messages, only new ones. %1$s" "Who can read history" - "Members only since they were invited" - "Members only since selecting this option" + "Members since invited" + "Members (full history)" "Room addresses are ways to find and access rooms. This also ensures you can easily share your room with others. You can choose to publish your room in your homeserver public room directory." "Room publishing" 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 cd2af112a7d..5042f942b66 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 @@ -20,6 +20,7 @@ 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.roomdetails.api.RoomDetailsEntryPoint +import io.element.android.features.roomdetailsedit.test.FakeRoomDetailsEditEntryPoint import io.element.android.features.securityandprivacy.test.FakeSecurityAndPrivacyEntryPoint import io.element.android.features.verifysession.test.FakeOutgoingVerificationEntryPoint import io.element.android.libraries.matrix.api.core.EventId @@ -63,6 +64,7 @@ class DefaultRoomDetailsEntryPointTest { changeRoomMemberRolesEntryPoint = FakeChangeRoomMemberRolesEntryPoint(), rolesAndPermissionsEntryPoint = FakeRolesAndPermissionsEntryPoint(), securityAndPrivacyEntryPoint = FakeSecurityAndPrivacyEntryPoint(), + roomDetailsEditEntryPoint = FakeRoomDetailsEditEntryPoint(), ) } val callback = object : RoomDetailsEntryPoint.Callback { diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/MatrixRoomFixture.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/MatrixRoomFixture.kt index 5043aea88c9..2d857443459 100644 --- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/MatrixRoomFixture.kt +++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/MatrixRoomFixture.kt @@ -13,8 +13,8 @@ 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.api.room.RoomMember -import io.element.android.libraries.matrix.api.room.StateEventType import io.element.android.libraries.matrix.api.room.join.JoinRule +import io.element.android.libraries.matrix.api.room.powerlevels.RoomPermissions import io.element.android.libraries.matrix.test.AN_AVATAR_URL import io.element.android.libraries.matrix.test.A_ROOM_ALIAS import io.element.android.libraries.matrix.test.A_ROOM_ID @@ -25,6 +25,7 @@ import io.element.android.libraries.matrix.test.notificationsettings.FakeNotific 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.test.room.powerlevels.FakeRoomPermissions import io.element.android.tests.testutils.lambda.lambdaError fun aRoom( @@ -35,6 +36,7 @@ fun aRoom( topic: String? = A_ROOM_TOPIC, avatarUrl: String? = AN_AVATAR_URL, canonicalAlias: RoomAlias? = A_ROOM_ALIAS, + roomPermissions: RoomPermissions = FakeRoomPermissions(), isEncrypted: Boolean = true, isPublic: Boolean = true, isDirect: Boolean = false, @@ -42,29 +44,20 @@ fun aRoom( activeMemberCount: Long = 1, joinedMemberCount: Long = 1, invitedMemberCount: Long = 0, - canInviteResult: (UserId) -> Result = { lambdaError() }, - canBanResult: (UserId) -> Result = { lambdaError() }, - canKickResult: (UserId) -> Result = { lambdaError() }, - canSendStateResult: (UserId, StateEventType) -> Result = { _, _ -> lambdaError() }, userDisplayNameResult: (UserId) -> Result = { lambdaError() }, userAvatarUrlResult: () -> Result = { lambdaError() }, - canUserJoinCallResult: (UserId) -> Result = { lambdaError() }, getUpdatedMemberResult: (UserId) -> Result = { lambdaError() }, userRoleResult: () -> Result = { lambdaError() }, setIsFavoriteResult: (Boolean) -> Result = { lambdaError() }, ) = FakeBaseRoom( sessionId = sessionId, roomId = roomId, - canInviteResult = canInviteResult, - canBanResult = canBanResult, - canKickResult = canKickResult, - canSendStateResult = canSendStateResult, userDisplayNameResult = userDisplayNameResult, userAvatarUrlResult = userAvatarUrlResult, - canUserJoinCallResult = canUserJoinCallResult, getUpdatedMemberResult = getUpdatedMemberResult, userRoleResult = userRoleResult, setIsFavoriteResult = setIsFavoriteResult, + roomPermissions = roomPermissions, initialRoomInfo = aRoomInfo( name = displayName, rawName = rawName, @@ -89,6 +82,7 @@ fun aJoinedRoom( topic: String? = A_ROOM_TOPIC, avatarUrl: String? = AN_AVATAR_URL, canonicalAlias: RoomAlias? = A_ROOM_ALIAS, + roomPermissions: RoomPermissions = FakeRoomPermissions(), isEncrypted: Boolean = true, isPublic: Boolean = true, isDirect: Boolean = false, @@ -97,17 +91,12 @@ fun aJoinedRoom( joinedMemberCount: Long = 1, invitedMemberCount: Long = 0, notificationSettingsService: FakeNotificationSettingsService = FakeNotificationSettingsService(), - canInviteResult: (UserId) -> Result = { lambdaError() }, - canBanResult: (UserId) -> Result = { lambdaError() }, - canKickResult: (UserId) -> Result = { lambdaError() }, - canSendStateResult: (UserId, StateEventType) -> Result = { _, _ -> lambdaError() }, userDisplayNameResult: (UserId) -> Result = { lambdaError() }, userAvatarUrlResult: () -> Result = { lambdaError() }, setNameResult: (String) -> Result = { lambdaError() }, setTopicResult: (String) -> Result = { lambdaError() }, updateAvatarResult: (String, ByteArray) -> Result = { _, _ -> lambdaError() }, removeAvatarResult: () -> Result = { lambdaError() }, - canUserJoinCallResult: (UserId) -> Result = { lambdaError() }, getUpdatedMemberResult: (UserId) -> Result = { lambdaError() }, userRoleResult: () -> Result = { lambdaError() }, kickUserResult: (UserId, String?) -> Result = { _, _ -> lambdaError() }, @@ -132,13 +121,9 @@ fun aJoinedRoom( baseRoom = aRoom( sessionId = sessionId, roomId = roomId, - canInviteResult = canInviteResult, - canBanResult = canBanResult, - canKickResult = canKickResult, - canSendStateResult = canSendStateResult, + roomPermissions = roomPermissions, userDisplayNameResult = userDisplayNameResult, userAvatarUrlResult = userAvatarUrlResult, - canUserJoinCallResult = canUserJoinCallResult, getUpdatedMemberResult = getUpdatedMemberResult, userRoleResult = userRoleResult, setIsFavoriteResult = setIsFavoriteResult, 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 1e59c23172b..46f6feebd44 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 @@ -30,6 +30,7 @@ import io.element.android.libraries.matrix.api.room.RoomMembersState import io.element.android.libraries.matrix.api.room.RoomNotificationMode import io.element.android.libraries.matrix.api.room.StateEventType import io.element.android.libraries.matrix.api.room.join.JoinRule +import io.element.android.libraries.matrix.api.room.powerlevels.RoomPermissions 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.A_ROOM_NAME @@ -42,6 +43,7 @@ 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.notificationsettings.FakeNotificationSettingsService import io.element.android.libraries.matrix.test.room.aRoomInfo +import io.element.android.libraries.matrix.test.room.powerlevels.FakeRoomPermissions import io.element.android.libraries.preferences.api.store.AppPreferencesStore import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore import io.element.android.services.analytics.api.AnalyticsService @@ -123,9 +125,7 @@ class RoomDetailsPresenterTest { @Test fun `present - initial state is created from initial room info`() = runTest { val room = aJoinedRoom( - canInviteResult = { Result.success(true) }, - canUserJoinCallResult = { Result.success(true) }, - canSendStateResult = { _, _ -> Result.success(true) }, + roomPermissions = roomPermissions(), ) val presenter = createRoomDetailsPresenter(room) presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) { @@ -152,9 +152,7 @@ class RoomDetailsPresenterTest { pinnedEventIds = listOf(AN_EVENT_ID), ) val room = aJoinedRoom( - canInviteResult = { Result.success(true) }, - canUserJoinCallResult = { Result.success(true) }, - canSendStateResult = { _, _ -> Result.success(true) }, + roomPermissions = roomPermissions(), ).apply { givenRoomInfo(roomInfo) } @@ -174,9 +172,7 @@ class RoomDetailsPresenterTest { fun `present - initial state with no room name`() = runTest { val room = aJoinedRoom( displayName = "", - canInviteResult = { Result.success(true) }, - canUserJoinCallResult = { Result.success(true) }, - canSendStateResult = { _, _ -> Result.success(true) }, + roomPermissions = roomPermissions(), ) val presenter = createRoomDetailsPresenter(room) presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) { @@ -192,9 +188,7 @@ class RoomDetailsPresenterTest { val myRoomMember = aRoomMember(A_SESSION_ID) val otherRoomMember = aRoomMember(A_USER_ID_2) val room = aJoinedRoom( - canInviteResult = { Result.success(true) }, - canUserJoinCallResult = { Result.success(true) }, - canSendStateResult = { _, _ -> Result.success(true) }, + roomPermissions = roomPermissions(), getUpdatedMemberResult = { userId -> when (userId) { A_SESSION_ID -> Result.success(myRoomMember) @@ -229,9 +223,9 @@ class RoomDetailsPresenterTest { @Test fun `present - initial state when user can invite others to room`() = runTest { val room = aJoinedRoom( - canInviteResult = { Result.success(true) }, - canUserJoinCallResult = { Result.success(true) }, - canSendStateResult = { _, _ -> Result.success(true) }, + roomPermissions = roomPermissions( + canInvite = true, + ), ) val presenter = createRoomDetailsPresenter(room, dispatchers = testCoroutineDispatchers()) presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) { @@ -247,26 +241,9 @@ class RoomDetailsPresenterTest { @Test fun `present - initial state when user can not invite others to room`() = runTest { val room = aJoinedRoom( - canInviteResult = { Result.success(false) }, - canKickResult = { Result.success(false) }, - canBanResult = { Result.success(false) }, - canUserJoinCallResult = { Result.success(true) }, - canSendStateResult = { _, _ -> Result.success(true) }, - ) - val presenter = createRoomDetailsPresenter(room) - presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) { - assertThat(awaitItem().canInvite).isFalse() - - cancelAndIgnoreRemainingEvents() - } - } - - @Test - fun `present - initial state when canInvite errors`() = runTest { - val room = aJoinedRoom( - canInviteResult = { Result.failure(RuntimeException("Whoops")) }, - canUserJoinCallResult = { Result.success(true) }, - canSendStateResult = { _, _ -> Result.success(true) }, + roomPermissions = roomPermissions( + canInvite = false, + ), ) val presenter = createRoomDetailsPresenter(room) presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) { @@ -279,17 +256,11 @@ class RoomDetailsPresenterTest { @Test fun `present - initial state when user can edit one attribute`() = runTest { val room = aJoinedRoom( - canSendStateResult = { _, stateEventType -> - when (stateEventType) { - StateEventType.ROOM_TOPIC -> Result.success(true) - StateEventType.ROOM_NAME -> Result.success(false) - else -> Result.failure(RuntimeException("Whelp")) - } - }, - canBanResult = { Result.success(false) }, - canKickResult = { Result.success(false) }, - canInviteResult = { Result.success(false) }, - canUserJoinCallResult = { Result.success(true) }, + roomPermissions = roomPermissions( + canChangeName = true, + canChangeTopic = false, + canChangeAvatar = false, + ), ) val presenter = createRoomDetailsPresenter(room) presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) { @@ -307,18 +278,7 @@ class RoomDetailsPresenterTest { val myRoomMember = aRoomMember(A_SESSION_ID) val otherRoomMember = aRoomMember(A_USER_ID_2) val room = aJoinedRoom( - canSendStateResult = { _, stateEventType -> - when (stateEventType) { - StateEventType.ROOM_TOPIC, - StateEventType.ROOM_NAME, - StateEventType.ROOM_AVATAR -> Result.success(true) - else -> Result.failure(RuntimeException("Whelp")) - } - }, - canKickResult = { Result.success(false) }, - canBanResult = { Result.success(false) }, - canInviteResult = { Result.success(false) }, - canUserJoinCallResult = { Result.success(true) }, + roomPermissions = roomPermissions(), getUpdatedMemberResult = { userId -> when (userId) { A_SESSION_ID -> Result.success(myRoomMember) @@ -358,18 +318,9 @@ class RoomDetailsPresenterTest { val room = aJoinedRoom( isDirect = true, topic = null, - canSendStateResult = { _, stateEventType -> - when (stateEventType) { - StateEventType.ROOM_AVATAR, - StateEventType.ROOM_TOPIC, - StateEventType.ROOM_NAME -> Result.success(true) - else -> Result.failure(RuntimeException("Whelp")) - } - }, + roomPermissions = roomPermissions(), userDisplayNameResult = { Result.success(A_USER_NAME) }, userAvatarUrlResult = { Result.success(AN_AVATAR_URL) }, - canInviteResult = { Result.success(true) }, - canUserJoinCallResult = { Result.success(true) }, getUpdatedMemberResult = { userId -> when (userId) { A_SESSION_ID -> Result.success(myRoomMember) @@ -404,24 +355,11 @@ class RoomDetailsPresenterTest { @Test fun `present - initial state when user can edit all attributes`() = runTest { val room = aJoinedRoom( - canSendStateResult = { _, stateEventType -> - when (stateEventType) { - StateEventType.ROOM_TOPIC, - StateEventType.ROOM_NAME, - StateEventType.ROOM_AVATAR -> Result.success(true) - else -> Result.failure(RuntimeException("Whelp")) - } - }, - canKickResult = { - Result.success(false) - }, - canBanResult = { - Result.success(false) - }, - canInviteResult = { - Result.success(false) - }, - canUserJoinCallResult = { Result.success(true) }, + roomPermissions = roomPermissions( + canChangeAvatar = true, + canChangeName = true, + canChangeTopic = true, + ), ) val presenter = createRoomDetailsPresenter(room) presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) { @@ -437,24 +375,11 @@ class RoomDetailsPresenterTest { @Test fun `present - initial state when user can edit no attributes`() = runTest { val room = aJoinedRoom( - canSendStateResult = { _, stateEventType -> - when (stateEventType) { - StateEventType.ROOM_TOPIC, - StateEventType.ROOM_NAME, - StateEventType.ROOM_AVATAR -> Result.success(false) - else -> Result.failure(RuntimeException("Whelp")) - } - }, - canBanResult = { - Result.success(false) - }, - canKickResult = { - Result.success(false) - }, - canInviteResult = { - Result.success(false) - }, - canUserJoinCallResult = { Result.success(true) }, + roomPermissions = roomPermissions( + canChangeAvatar = false, + canChangeName = false, + canChangeTopic = false, + ), ) val presenter = createRoomDetailsPresenter(room) presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) { @@ -469,24 +394,9 @@ class RoomDetailsPresenterTest { fun `present - topic state is hidden when no topic and user has no permission`() = runTest { val room = aJoinedRoom( topic = null, - canSendStateResult = { _, stateEventType -> - when (stateEventType) { - StateEventType.ROOM_AVATAR, - StateEventType.ROOM_NAME -> Result.success(true) - StateEventType.ROOM_TOPIC -> Result.success(false) - else -> Result.failure(RuntimeException("Whelp")) - } - }, - canKickResult = { - Result.success(false) - }, - canBanResult = { - Result.success(false) - }, - canInviteResult = { - Result.success(false) - }, - canUserJoinCallResult = { Result.success(true) }, + roomPermissions = roomPermissions( + canChangeTopic = false + ), ) val presenter = createRoomDetailsPresenter(room) presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) { @@ -501,24 +411,7 @@ class RoomDetailsPresenterTest { fun `present - topic state is 'can add topic' when no topic and user has permission`() = runTest { val room = aJoinedRoom( topic = null, - canSendStateResult = { _, stateEventType -> - when (stateEventType) { - StateEventType.ROOM_AVATAR, - StateEventType.ROOM_TOPIC, - StateEventType.ROOM_NAME -> Result.success(true) - else -> Result.failure(RuntimeException("Whelp")) - } - }, - canKickResult = { - Result.success(false) - }, - canBanResult = { - Result.success(false) - }, - canInviteResult = { - Result.success(false) - }, - canUserJoinCallResult = { Result.success(true) }, + roomPermissions = roomPermissions(), ).apply { givenRoomInfo(aRoomInfo(topic = null)) } @@ -538,9 +431,7 @@ class RoomDetailsPresenterTest { fun `present - leave room event is passed on to leave room presenter`() = runTest { val leaveRoomEventRecorder = EventsRecorder() val room = aJoinedRoom( - canInviteResult = { Result.success(true) }, - canUserJoinCallResult = { Result.success(true) }, - canSendStateResult = { _, _ -> Result.success(true) }, + roomPermissions = roomPermissions(), ) val presenter = createRoomDetailsPresenter( room = room, @@ -559,9 +450,7 @@ class RoomDetailsPresenterTest { val notificationSettingsService = FakeNotificationSettingsService() val room = aJoinedRoom( notificationSettingsService = notificationSettingsService, - canInviteResult = { Result.success(true) }, - canUserJoinCallResult = { Result.success(true) }, - canSendStateResult = { _, _ -> Result.success(true) }, + roomPermissions = roomPermissions(), ) val presenter = createRoomDetailsPresenter( room = room, @@ -588,9 +477,7 @@ class RoomDetailsPresenterTest { FakeNotificationSettingsService(initialRoomMode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY) val room = aJoinedRoom( notificationSettingsService = notificationSettingsService, - canInviteResult = { Result.success(true) }, - canUserJoinCallResult = { Result.success(true) }, - canSendStateResult = { _, _ -> Result.success(true) }, + roomPermissions = roomPermissions(), ) val presenter = createRoomDetailsPresenter( room = room, @@ -616,9 +503,7 @@ class RoomDetailsPresenterTest { ) val room = aJoinedRoom( notificationSettingsService = notificationSettingsService, - canInviteResult = { Result.success(true) }, - canUserJoinCallResult = { Result.success(true) }, - canSendStateResult = { _, _ -> Result.success(true) }, + roomPermissions = roomPermissions(), ) val presenter = createRoomDetailsPresenter( room = room, @@ -641,9 +526,7 @@ class RoomDetailsPresenterTest { val setIsFavoriteResult = lambdaRecorder> { _ -> Result.success(Unit) } val room = aJoinedRoom( setIsFavoriteResult = setIsFavoriteResult, - canInviteResult = { Result.success(true) }, - canUserJoinCallResult = { Result.success(true) }, - canSendStateResult = { _, _ -> Result.success(true) }, + roomPermissions = roomPermissions(), ) val analyticsService = FakeAnalyticsService() val presenter = @@ -669,9 +552,7 @@ class RoomDetailsPresenterTest { @Test fun `present - changes in room info updates the is favorite flag`() = runTest { val room = aJoinedRoom( - canInviteResult = { Result.success(true) }, - canUserJoinCallResult = { Result.success(true) }, - canSendStateResult = { _, _ -> Result.success(true) }, + roomPermissions = roomPermissions(), ) val presenter = createRoomDetailsPresenter(room = room) presenter.testWithLifecycleOwner(lifecycleOwner = fakeLifecycleOwner) { @@ -690,9 +571,7 @@ class RoomDetailsPresenterTest { @Test fun `present - show knock requests`() = runTest { val room = aJoinedRoom( - canInviteResult = { Result.success(true) }, - canUserJoinCallResult = { Result.success(true) }, - canSendStateResult = { _, _ -> Result.success(true) }, + roomPermissions = roomPermissions(), joinRule = JoinRule.Knock, ) val featureFlagService = FakeFeatureFlagService( @@ -716,9 +595,7 @@ class RoomDetailsPresenterTest { @Test fun `present - show security and privacy`() = runTest { val room = aJoinedRoom( - canInviteResult = { Result.success(true) }, - canUserJoinCallResult = { Result.success(true) }, - canSendStateResult = { _, _ -> Result.success(true) }, + roomPermissions = roomPermissions(), ) val featureFlagService = FakeFeatureFlagService() val presenter = createRoomDetailsPresenter(room = room, featureFlagService = featureFlagService) @@ -733,9 +610,7 @@ class RoomDetailsPresenterTest { @Test fun `present - show debug info`() = runTest { val room = aJoinedRoom( - canInviteResult = { Result.success(true) }, - canUserJoinCallResult = { Result.success(true) }, - canSendStateResult = { _, _ -> Result.success(true) }, + roomPermissions = roomPermissions(), ) val inMemoryAppPreferencesStore = InMemoryAppPreferencesStore( isDeveloperModeEnabled = true, @@ -748,4 +623,41 @@ class RoomDetailsPresenterTest { } } } + + private fun roomPermissions( + canInvite: Boolean = true, + canKick: Boolean = true, + canBan: Boolean = true, + canRedactOther: Boolean = true, + canRedactOwn: Boolean = true, + canChangeRoomAccess: Boolean = true, + canChangeHistoryVisibility: Boolean = true, + canChangeEncryption: Boolean = true, + canChangeRoomVisibility: Boolean = true, + canChangeName: Boolean = true, + canChangeTopic: Boolean = true, + canChangeAvatar: Boolean = true, + canChangePowerLevels: Boolean = true, + ): RoomPermissions { + return FakeRoomPermissions( + canInvite = canInvite, + canKick = canKick, + canBan = canBan, + canRedactOther = canRedactOther, + canRedactOwn = canRedactOwn, + canSendState = { eventType -> + when (eventType) { + StateEventType.RoomJoinRules -> canChangeRoomAccess + StateEventType.RoomHistoryVisibility -> canChangeHistoryVisibility + StateEventType.RoomEncryption -> canChangeEncryption + StateEventType.RoomCanonicalAlias -> canChangeRoomVisibility + StateEventType.RoomAvatar -> canChangeAvatar + StateEventType.RoomName -> canChangeName + StateEventType.RoomTopic -> canChangeTopic + StateEventType.RoomPowerLevels -> canChangePowerLevels + else -> lambdaError() + } + } + ) + } } diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenterTest.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenterTest.kt index 6545d6e65ab..54fafc7bf51 100644 --- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenterTest.kt +++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListPresenterTest.kt @@ -13,7 +13,6 @@ import io.element.android.features.roommembermoderation.api.RoomMemberModeration import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.meta.BuildMeta -import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.room.RoomMembersState import io.element.android.libraries.matrix.api.room.RoomMembershipState @@ -22,6 +21,7 @@ import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService 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.test.room.powerlevels.FakeRoomPermissions import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.test import io.element.android.tests.testutils.testCoroutineDispatchers @@ -173,20 +173,7 @@ class RoomMemberListPresenterTest { fun `present - asynchronously sets canInvite when user does not have correct power level`() = runTest { val presenter = createPresenter( joinedRoom = createFakeJoinedRoom( - canInviteResult = { Result.success(false) }, - ) - ) - presenter.test { - val loadedState = awaitItem() - assertThat(loadedState.canInvite).isFalse() - } - } - - @Test - fun `present - asynchronously sets canInvite when power level check fails`() = runTest { - val presenter = createPresenter( - joinedRoom = createFakeJoinedRoom( - canInviteResult = { Result.failure(RuntimeException("Eek")) }, + canInvite = false, ) ) presenter.test { @@ -209,12 +196,14 @@ class RoomMemberListPresenterTest { private fun createFakeJoinedRoom( updateMembersResult: () -> Unit = { }, - canInviteResult: (UserId) -> Result = { Result.success(true) }, + canInvite: Boolean = true, ): FakeJoinedRoom { return FakeJoinedRoom( baseRoom = FakeBaseRoom( updateMembersResult = updateMembersResult, - canInviteResult = canInviteResult, + roomPermissions = FakeRoomPermissions( + canInvite = canInvite, + ), ).apply { // Needed to avoid discarding the loaded members as a partial and invalid result givenRoomInfo(aRoomInfo(joinedMembersCount = 2)) diff --git a/features/roomdetailsedit/api/build.gradle.kts b/features/roomdetailsedit/api/build.gradle.kts new file mode 100644 index 00000000000..0afaad72a61 --- /dev/null +++ b/features/roomdetailsedit/api/build.gradle.kts @@ -0,0 +1,21 @@ +/* + * 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. + */ + +plugins { + id("io.element.android-library") + id("kotlin-parcelize") +} + +android { + namespace = "io.element.android.features.roomdetailsedit.api" +} + +dependencies { + implementation(projects.libraries.architecture) + implementation(projects.libraries.matrix.api) +} diff --git a/features/roomdetailsedit/api/src/main/kotlin/io/element/android/features/roomdetailsedit/api/RoomDetailsEditEntryPoint.kt b/features/roomdetailsedit/api/src/main/kotlin/io/element/android/features/roomdetailsedit/api/RoomDetailsEditEntryPoint.kt new file mode 100644 index 00000000000..1dad3656466 --- /dev/null +++ b/features/roomdetailsedit/api/src/main/kotlin/io/element/android/features/roomdetailsedit/api/RoomDetailsEditEntryPoint.kt @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.roomdetailsedit.api + +import io.element.android.libraries.architecture.SimpleFeatureEntryPoint + +fun interface RoomDetailsEditEntryPoint : SimpleFeatureEntryPoint diff --git a/features/roomdetailsedit/api/src/main/kotlin/io/element/android/features/roomdetailsedit/api/RoomDetailsEditPermissions.kt b/features/roomdetailsedit/api/src/main/kotlin/io/element/android/features/roomdetailsedit/api/RoomDetailsEditPermissions.kt new file mode 100644 index 00000000000..8ac2ea50229 --- /dev/null +++ b/features/roomdetailsedit/api/src/main/kotlin/io/element/android/features/roomdetailsedit/api/RoomDetailsEditPermissions.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2025 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.roomdetailsedit.api + +import io.element.android.libraries.matrix.api.room.StateEventType +import io.element.android.libraries.matrix.api.room.powerlevels.RoomPermissions + +data class RoomDetailsEditPermissions( + val canEditName: Boolean, + val canEditTopic: Boolean, + val canEditAvatar: Boolean, +) { + val hasAny = canEditName || + canEditTopic || + canEditAvatar + + companion object { + val DEFAULT = RoomDetailsEditPermissions( + canEditName = false, + canEditTopic = false, + canEditAvatar = false, + ) + } +} + +fun RoomPermissions.roomDetailsEditPermissions(): RoomDetailsEditPermissions { + return RoomDetailsEditPermissions( + canEditName = canOwnUserSendState(StateEventType.RoomName), + canEditTopic = canOwnUserSendState(StateEventType.RoomTopic), + canEditAvatar = canOwnUserSendState(StateEventType.RoomAvatar), + ) +} diff --git a/features/roomdetailsedit/impl/build.gradle.kts b/features/roomdetailsedit/impl/build.gradle.kts new file mode 100644 index 00000000000..6b1d886abed --- /dev/null +++ b/features/roomdetailsedit/impl/build.gradle.kts @@ -0,0 +1,57 @@ +import extension.setupDependencyInjection +import extension.testCommonDependencies + +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023, 2024 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. + */ + +plugins { + id("io.element.android-compose-library") + id("kotlin-parcelize") +} + +android { + namespace = "io.element.android.features.roomdetailsedit.impl" + testOptions { + unitTests { + isIncludeAndroidResources = true + } + } +} + +setupDependencyInjection() + +dependencies { + 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) + 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/DefaultRoomDetailsEditEntryPoint.kt b/features/roomdetailsedit/impl/src/main/kotlin/io/element/android/features/roomdetailsedit/impl/DefaultRoomDetailsEditEntryPoint.kt new file mode 100644 index 00000000000..d928a0238c9 --- /dev/null +++ b/features/roomdetailsedit/impl/src/main/kotlin/io/element/android/features/roomdetailsedit/impl/DefaultRoomDetailsEditEntryPoint.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * Copyright 2023-2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.features.roomdetailsedit.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.roomdetailsedit.api.RoomDetailsEditEntryPoint +import io.element.android.libraries.architecture.createNode + +@ContributesBinding(AppScope::class) +class DefaultRoomDetailsEditEntryPoint : RoomDetailsEditEntryPoint { + override fun createNode(parentNode: Node, buildContext: BuildContext): Node { + return parentNode.createNode(buildContext) + } +} diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditEvents.kt b/features/roomdetailsedit/impl/src/main/kotlin/io/element/android/features/roomdetailsedit/impl/RoomDetailsEditEvent.kt similarity index 63% rename from features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditEvents.kt rename to features/roomdetailsedit/impl/src/main/kotlin/io/element/android/features/roomdetailsedit/impl/RoomDetailsEditEvent.kt index 2606d6be83e..40f123401cf 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditEvents.kt +++ b/features/roomdetailsedit/impl/src/main/kotlin/io/element/android/features/roomdetailsedit/impl/RoomDetailsEditEvent.kt @@ -6,15 +6,15 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.roomdetails.impl.edit +package io.element.android.features.roomdetailsedit.impl import io.element.android.libraries.matrix.ui.media.AvatarAction -sealed interface RoomDetailsEditEvents { - data class HandleAvatarAction(val action: AvatarAction) : RoomDetailsEditEvents - data class UpdateRoomName(val name: String) : RoomDetailsEditEvents - data class UpdateRoomTopic(val topic: String) : RoomDetailsEditEvents - data object OnBackPress : RoomDetailsEditEvents - data object Save : RoomDetailsEditEvents - data object CloseDialog : RoomDetailsEditEvents +sealed interface RoomDetailsEditEvent { + data class HandleAvatarAction(val action: AvatarAction) : RoomDetailsEditEvent + data class UpdateRoomName(val name: String) : RoomDetailsEditEvent + data class UpdateRoomTopic(val topic: String) : RoomDetailsEditEvent + data object OnBackPress : RoomDetailsEditEvent + data object Save : RoomDetailsEditEvent + data object CloseDialog : RoomDetailsEditEvent } diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditNode.kt b/features/roomdetailsedit/impl/src/main/kotlin/io/element/android/features/roomdetailsedit/impl/RoomDetailsEditNode.kt similarity index 96% rename from features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditNode.kt rename to features/roomdetailsedit/impl/src/main/kotlin/io/element/android/features/roomdetailsedit/impl/RoomDetailsEditNode.kt index dc2ebe8c27b..541a36c91ad 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditNode.kt +++ b/features/roomdetailsedit/impl/src/main/kotlin/io/element/android/features/roomdetailsedit/impl/RoomDetailsEditNode.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.roomdetails.impl.edit +package io.element.android.features.roomdetailsedit.impl import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditPresenter.kt b/features/roomdetailsedit/impl/src/main/kotlin/io/element/android/features/roomdetailsedit/impl/RoomDetailsEditPresenter.kt similarity index 82% rename from features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditPresenter.kt rename to features/roomdetailsedit/impl/src/main/kotlin/io/element/android/features/roomdetailsedit/impl/RoomDetailsEditPresenter.kt index 542b15a7763..24f8f49e816 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditPresenter.kt +++ b/features/roomdetailsedit/impl/src/main/kotlin/io/element/android/features/roomdetailsedit/impl/RoomDetailsEditPresenter.kt @@ -6,8 +6,9 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.roomdetails.impl.edit +package io.element.android.features.roomdetailsedit.impl +import android.Manifest import android.net.Uri import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -22,6 +23,8 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.core.net.toUri import dev.zacsweers.metro.Inject +import io.element.android.features.roomdetailsedit.api.RoomDetailsEditPermissions +import io.element.android.features.roomdetailsedit.api.roomDetailsEditPermissions import io.element.android.libraries.androidutils.file.TemporaryUriDeleter import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.Presenter @@ -29,16 +32,12 @@ import io.element.android.libraries.architecture.runCatchingUpdatingState import io.element.android.libraries.core.extensions.runCatchingExceptions import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.matrix.api.room.JoinedRoom -import io.element.android.libraries.matrix.api.room.StateEventType -import io.element.android.libraries.matrix.api.room.powerlevels.canSendState +import io.element.android.libraries.matrix.api.room.powerlevels.permissionsAsState import io.element.android.libraries.matrix.ui.media.AvatarAction -import io.element.android.libraries.matrix.ui.room.avatarUrl -import io.element.android.libraries.matrix.ui.room.rawName -import io.element.android.libraries.matrix.ui.room.topic import io.element.android.libraries.mediapickers.api.PickerProvider import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProvider import io.element.android.libraries.mediaupload.api.MediaPreProcessor -import io.element.android.libraries.permissions.api.PermissionsEvents +import io.element.android.libraries.permissions.api.PermissionsEvent import io.element.android.libraries.permissions.api.PermissionsPresenter import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CoroutineScope @@ -54,15 +53,14 @@ class RoomDetailsEditPresenter( permissionsPresenterFactory: PermissionsPresenter.Factory, private val mediaOptimizationConfigProvider: MediaOptimizationConfigProvider, ) : Presenter { - private val cameraPermissionPresenter = permissionsPresenterFactory.create(android.Manifest.permission.CAMERA) + private val cameraPermissionPresenter = permissionsPresenterFactory.create(Manifest.permission.CAMERA) private var pendingPermissionRequest = false @Composable override fun present(): RoomDetailsEditState { val cameraPermissionState = cameraPermissionPresenter.present() - val roomSyncUpdateFlow = room.syncUpdateFlow.collectAsState() - - val roomAvatarUri = room.avatarUrl() + val roomInfo by room.roomInfoFlow.collectAsState() + val roomAvatarUri = roomInfo.avatarUrl var roomAvatarUriEdited by rememberSaveable { mutableStateOf(null) } LaunchedEffect(roomAvatarUri) { // Every time the roomAvatar change (from sync), we can set the new avatar. @@ -70,13 +68,13 @@ class RoomDetailsEditPresenter( roomAvatarUriEdited = roomAvatarUri } - val roomRawNameTrimmed = room.rawName().orEmpty().trim() + val roomRawNameTrimmed = roomInfo.rawName.orEmpty().trim() var roomRawNameEdited by rememberSaveable { mutableStateOf("") } LaunchedEffect(roomRawNameTrimmed) { // Every time the rawName change (from sync), we can set the new name. roomRawNameEdited = roomRawNameTrimmed } - val roomTopicTrimmed = room.topic().orEmpty().trim() + val roomTopicTrimmed = roomInfo.topic.orEmpty().trim() var roomTopicEdited by rememberSaveable { mutableStateOf("") } LaunchedEffect(roomTopicTrimmed) { // Every time the topic change (from sync), we can set the new topic. @@ -95,14 +93,8 @@ class RoomDetailsEditPresenter( } } - var canChangeName by remember { mutableStateOf(false) } - var canChangeTopic by remember { mutableStateOf(false) } - var canChangeAvatar by remember { mutableStateOf(false) } - - LaunchedEffect(roomSyncUpdateFlow.value) { - canChangeName = room.canSendState(StateEventType.ROOM_NAME).getOrElse { false } - canChangeTopic = room.canSendState(StateEventType.ROOM_TOPIC).getOrElse { false } - canChangeAvatar = room.canSendState(StateEventType.ROOM_AVATAR).getOrElse { false } + val permissions by room.permissionsAsState(RoomDetailsEditPermissions.DEFAULT) { perms -> + perms.roomDetailsEditPermissions() } val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker( @@ -141,9 +133,9 @@ class RoomDetailsEditPresenter( val saveAction: MutableState> = remember { mutableStateOf(AsyncAction.Uninitialized) } val localCoroutineScope = rememberCoroutineScope() - fun handleEvent(event: RoomDetailsEditEvents) { + fun handleEvent(event: RoomDetailsEditEvent) { when (event) { - is RoomDetailsEditEvents.Save -> localCoroutineScope.saveChanges( + is RoomDetailsEditEvent.Save -> localCoroutineScope.saveChanges( currentNameTrimmed = roomRawNameTrimmed, newNameTrimmed = roomRawNameEdited.trim(), currentTopicTrimmed = roomTopicTrimmed, @@ -152,14 +144,14 @@ class RoomDetailsEditPresenter( newAvatarUri = roomAvatarUriEdited?.toUri(), action = saveAction, ) - is RoomDetailsEditEvents.HandleAvatarAction -> { + is RoomDetailsEditEvent.HandleAvatarAction -> { when (event.action) { AvatarAction.ChoosePhoto -> galleryImagePicker.launch() AvatarAction.TakePhoto -> if (cameraPermissionState.permissionGranted) { cameraPhotoPicker.launch() } else { pendingPermissionRequest = true - cameraPermissionState.eventSink(PermissionsEvents.RequestPermissions) + cameraPermissionState.eventSink(PermissionsEvent.RequestPermissions) } AvatarAction.Remove -> { temporaryUriDeleter.delete(roomAvatarUriEdited?.toUri()) @@ -168,10 +160,10 @@ class RoomDetailsEditPresenter( } } - is RoomDetailsEditEvents.UpdateRoomName -> roomRawNameEdited = event.name - is RoomDetailsEditEvents.UpdateRoomTopic -> roomTopicEdited = event.topic - RoomDetailsEditEvents.CloseDialog -> saveAction.value = AsyncAction.Uninitialized - RoomDetailsEditEvents.OnBackPress -> if (saveButtonEnabled.not() || saveAction.value == AsyncAction.ConfirmingCancellation) { + is RoomDetailsEditEvent.UpdateRoomName -> roomRawNameEdited = event.name + is RoomDetailsEditEvent.UpdateRoomTopic -> roomTopicEdited = event.topic + RoomDetailsEditEvent.CloseDialog -> saveAction.value = AsyncAction.Uninitialized + RoomDetailsEditEvent.OnBackPress -> if (saveButtonEnabled.not() || saveAction.value == AsyncAction.ConfirmingCancellation) { // No changes to save or already confirming exit without saving saveAction.value = AsyncAction.Success(Unit) } else { @@ -183,15 +175,16 @@ class RoomDetailsEditPresenter( return RoomDetailsEditState( roomId = room.roomId, roomRawName = roomRawNameEdited, - canChangeName = canChangeName, + canChangeName = permissions.canEditName, roomTopic = roomTopicEdited, - canChangeTopic = canChangeTopic, + canChangeTopic = permissions.canEditTopic, roomAvatarUrl = roomAvatarUriEdited, - canChangeAvatar = canChangeAvatar, + canChangeAvatar = permissions.canEditAvatar, avatarActions = avatarActions, saveButtonEnabled = saveButtonEnabled, saveAction = saveAction.value, cameraPermissionState = cameraPermissionState, + isSpace = roomInfo.isSpace, eventSink = ::handleEvent, ) } diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditState.kt b/features/roomdetailsedit/impl/src/main/kotlin/io/element/android/features/roomdetailsedit/impl/RoomDetailsEditState.kt similarity index 89% rename from features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditState.kt rename to features/roomdetailsedit/impl/src/main/kotlin/io/element/android/features/roomdetailsedit/impl/RoomDetailsEditState.kt index 3c5e87a2cda..35f088275fc 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditState.kt +++ b/features/roomdetailsedit/impl/src/main/kotlin/io/element/android/features/roomdetailsedit/impl/RoomDetailsEditState.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.roomdetails.impl.edit +package io.element.android.features.roomdetailsedit.impl import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.matrix.api.core.RoomId @@ -27,5 +27,6 @@ data class RoomDetailsEditState( val saveButtonEnabled: Boolean, val saveAction: AsyncAction, val cameraPermissionState: PermissionsState, - val eventSink: (RoomDetailsEditEvents) -> Unit + val isSpace: Boolean, + val eventSink: (RoomDetailsEditEvent) -> Unit ) diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditStateProvider.kt b/features/roomdetailsedit/impl/src/main/kotlin/io/element/android/features/roomdetailsedit/impl/RoomDetailsEditStateProvider.kt similarity index 90% rename from features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditStateProvider.kt rename to features/roomdetailsedit/impl/src/main/kotlin/io/element/android/features/roomdetailsedit/impl/RoomDetailsEditStateProvider.kt index 33ed4a9e1ed..a6e2e3798bb 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditStateProvider.kt +++ b/features/roomdetailsedit/impl/src/main/kotlin/io/element/android/features/roomdetailsedit/impl/RoomDetailsEditStateProvider.kt @@ -6,7 +6,7 @@ * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.roomdetails.impl.edit +package io.element.android.features.roomdetailsedit.impl import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.libraries.architecture.AsyncAction @@ -23,6 +23,7 @@ open class RoomDetailsEditStateProvider : PreviewParameterProvider = AsyncAction.Uninitialized, cameraPermissionState: PermissionsState = aPermissionsState(showDialog = false), - eventSink: (RoomDetailsEditEvents) -> Unit = {}, + isSpace: Boolean = false, + eventSink: (RoomDetailsEditEvent) -> Unit = {}, ) = RoomDetailsEditState( roomId = roomId, roomRawName = roomRawName, @@ -56,5 +58,6 @@ fun aRoomDetailsEditState( saveButtonEnabled = saveButtonEnabled, saveAction = saveAction, cameraPermissionState = cameraPermissionState, + isSpace = isSpace, eventSink = eventSink, ) diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditView.kt b/features/roomdetailsedit/impl/src/main/kotlin/io/element/android/features/roomdetailsedit/impl/RoomDetailsEditView.kt similarity index 76% rename from features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditView.kt rename to features/roomdetailsedit/impl/src/main/kotlin/io/element/android/features/roomdetailsedit/impl/RoomDetailsEditView.kt index b8f7e000696..d2de2ba260b 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditView.kt +++ b/features/roomdetailsedit/impl/src/main/kotlin/io/element/android/features/roomdetailsedit/impl/RoomDetailsEditView.kt @@ -8,15 +8,13 @@ @file:OptIn(ExperimentalMaterial3Api::class) -package io.element.android.features.roomdetails.impl.edit +package io.element.android.features.roomdetailsedit.impl import androidx.activity.compose.BackHandler 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.imePadding -import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardOptions @@ -25,16 +23,17 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.input.KeyboardCapitalization import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp -import io.element.android.features.roomdetails.impl.R import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.designsystem.components.async.AsyncActionView import io.element.android.libraries.designsystem.components.async.AsyncActionViewDefaults +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 import io.element.android.libraries.designsystem.components.button.BackButton @@ -47,7 +46,8 @@ import io.element.android.libraries.designsystem.theme.components.TextButton import io.element.android.libraries.designsystem.theme.components.TextField import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.matrix.ui.components.AvatarActionBottomSheet -import io.element.android.libraries.matrix.ui.components.EditableAvatarView +import io.element.android.libraries.matrix.ui.components.AvatarPickerState +import io.element.android.libraries.matrix.ui.components.AvatarPickerView import io.element.android.libraries.permissions.api.PermissionsView import io.element.android.libraries.ui.strings.CommonStrings @@ -66,7 +66,7 @@ fun RoomDetailsEditView( } BackHandler { - state.eventSink(RoomDetailsEditEvents.OnBackPress) + state.eventSink(RoomDetailsEditEvent.OnBackPress) } Scaffold( modifier = modifier.clearFocusOnTap(focusManager), @@ -76,7 +76,7 @@ fun RoomDetailsEditView( navigationIcon = { BackButton( onClick = { - state.eventSink(RoomDetailsEditEvents.OnBackPress) + state.eventSink(RoomDetailsEditEvent.OnBackPress) } ) }, @@ -86,7 +86,7 @@ fun RoomDetailsEditView( enabled = state.saveButtonEnabled, onClick = { focusManager.clearFocus() - state.eventSink(RoomDetailsEditEvents.Save) + state.eventSink(RoomDetailsEditEvent.Save) }, ) } @@ -97,41 +97,47 @@ fun RoomDetailsEditView( modifier = Modifier .padding(padding) .padding(horizontal = 16.dp) - .navigationBarsPadding() .imePadding() .verticalScroll(rememberScrollState()) ) { Spacer(modifier = Modifier.height(24.dp)) - EditableAvatarView( - matrixId = state.roomId.value, - // As per Element Web, we use the raw name for the avatar as well - displayName = state.roomRawName, - avatarUrl = state.roomAvatarUrl, - avatarSize = AvatarSize.EditRoomDetails, - avatarType = AvatarType.Room(), - onAvatarClick = ::onAvatarClick, - modifier = Modifier.fillMaxWidth(), + val avatarPickerState = remember(state.roomAvatarUrl) { + val size = AvatarSize.EditRoomDetails + val type = AvatarType.Room() + AvatarPickerState.Selected( + avatarData = AvatarData(id = state.roomId.value, name = state.roomRawName, size = size, url = state.roomAvatarUrl), + type = type + ) + } + AvatarPickerView( + state = avatarPickerState, + onClick = ::onAvatarClick, + modifier = Modifier.align(Alignment.CenterHorizontally), ) - Spacer(modifier = Modifier.height(60.dp)) + Spacer(modifier = Modifier.height(32.dp)) TextField( - label = stringResource(id = R.string.screen_room_details_room_name_label), + label = stringResource(id = CommonStrings.common_name), value = state.roomRawName, placeholder = stringResource(CommonStrings.common_room_name_placeholder), singleLine = true, readOnly = !state.canChangeName, - onValueChange = { state.eventSink(RoomDetailsEditEvents.UpdateRoomName(it)) }, + onValueChange = { state.eventSink(RoomDetailsEditEvent.UpdateRoomName(it)) }, ) - Spacer(modifier = Modifier.height(28.dp)) + Spacer(modifier = Modifier.height(32.dp)) TextField( label = stringResource(CommonStrings.common_topic), value = state.roomTopic, - placeholder = stringResource(CommonStrings.common_topic_placeholder), + placeholder = if (state.isSpace) { + stringResource(CommonStrings.common_space_topic_placeholder) + } else { + stringResource(CommonStrings.common_topic_placeholder) + }, maxLines = 10, readOnly = !state.canChangeTopic, - onValueChange = { state.eventSink(RoomDetailsEditEvents.UpdateRoomTopic(it)) }, + onValueChange = { state.eventSink(RoomDetailsEditEvent.UpdateRoomTopic(it)) }, keyboardOptions = KeyboardOptions( capitalization = KeyboardCapitalization.Sentences, ), @@ -142,7 +148,7 @@ fun RoomDetailsEditView( actions = state.avatarActions, isVisible = isAvatarActionsSheetVisible.value, onDismiss = { isAvatarActionsSheetVisible.value = false }, - onSelectAction = { state.eventSink(RoomDetailsEditEvents.HandleAvatarAction(it)) } + onSelectAction = { state.eventSink(RoomDetailsEditEvent.HandleAvatarAction(it)) } ) AsyncActionView( async = state.saveAction, @@ -154,14 +160,15 @@ fun RoomDetailsEditView( confirmationDialog = { if (state.saveAction == AsyncAction.ConfirmingCancellation) { SaveChangesDialog( - onSubmitClick = { state.eventSink(RoomDetailsEditEvents.OnBackPress) }, - onDismiss = { state.eventSink(RoomDetailsEditEvents.CloseDialog) } + onSaveClick = { state.eventSink(RoomDetailsEditEvent.Save) }, + onDiscardClick = { state.eventSink(RoomDetailsEditEvent.OnBackPress) }, + onDismiss = { state.eventSink(RoomDetailsEditEvent.CloseDialog) } ) } }, onSuccess = { onDone() }, errorMessage = { stringResource(R.string.screen_room_details_edition_error) }, - onErrorDismiss = { state.eventSink(RoomDetailsEditEvents.CloseDialog) } + onErrorDismiss = { state.eventSink(RoomDetailsEditEvent.CloseDialog) } ) PermissionsView( diff --git a/features/roomdetailsedit/impl/src/main/res/values-be/translations.xml b/features/roomdetailsedit/impl/src/main/res/values-be/translations.xml new file mode 100644 index 00000000000..26537576b44 --- /dev/null +++ b/features/roomdetailsedit/impl/src/main/res/values-be/translations.xml @@ -0,0 +1,7 @@ + + + "Рэдагаваць пакой" + "Адбылася невядомая памылка, і інфармацыю нельга было змяніць." + "Немагчыма абнавіць пакой" + "Ідзе абнаўленне пакоя…" + diff --git a/features/roomdetailsedit/impl/src/main/res/values-bg/translations.xml b/features/roomdetailsedit/impl/src/main/res/values-bg/translations.xml new file mode 100644 index 00000000000..787110dc793 --- /dev/null +++ b/features/roomdetailsedit/impl/src/main/res/values-bg/translations.xml @@ -0,0 +1,7 @@ + + + "Редактиране на стаята" + "Възникна неизвестна грешка и информацията не можа да бъде променена." + "Не може да се обнови стаята" + "Обновяване на стаята…" + diff --git a/features/roomdetailsedit/impl/src/main/res/values-cs/translations.xml b/features/roomdetailsedit/impl/src/main/res/values-cs/translations.xml new file mode 100644 index 00000000000..b2ea1073005 --- /dev/null +++ b/features/roomdetailsedit/impl/src/main/res/values-cs/translations.xml @@ -0,0 +1,7 @@ + + + "Upravit podrobnosti" + "Došlo k neznámé chybě a informace nebylo možné změnit." + "Nelze aktualizovat místnost" + "Aktualizace místnosti…" + diff --git a/features/roomdetailsedit/impl/src/main/res/values-cy/translations.xml b/features/roomdetailsedit/impl/src/main/res/values-cy/translations.xml new file mode 100644 index 00000000000..e13ddf6d919 --- /dev/null +++ b/features/roomdetailsedit/impl/src/main/res/values-cy/translations.xml @@ -0,0 +1,7 @@ + + + "Ystafell Golygu" + "Roedd gwall anhysbys ac nid oedd modd newid y manylion." + "Methu diweddaru\'r ystafell" + "Wrthi\'n diweddaru ystafell…" + diff --git a/features/roomdetailsedit/impl/src/main/res/values-da/translations.xml b/features/roomdetailsedit/impl/src/main/res/values-da/translations.xml new file mode 100644 index 00000000000..d751b1da969 --- /dev/null +++ b/features/roomdetailsedit/impl/src/main/res/values-da/translations.xml @@ -0,0 +1,7 @@ + + + "Redigér detaljer" + "Der opstod en ukendt fejl, og oplysningerne kunne ikke ændres." + "Rummet kunne ikke opdateres" + "Opdaterer rum…" + diff --git a/features/roomdetailsedit/impl/src/main/res/values-de/translations.xml b/features/roomdetailsedit/impl/src/main/res/values-de/translations.xml new file mode 100644 index 00000000000..1bc31f9f687 --- /dev/null +++ b/features/roomdetailsedit/impl/src/main/res/values-de/translations.xml @@ -0,0 +1,7 @@ + + + "Chat bearbeiten" + "Es ist ein unbekannter Fehler aufgetreten und die Informationen konnten nicht geändert werden." + "Chat kann nicht aktualisiert werden" + "Chat wird aktualisiert…" + diff --git a/features/roomdetailsedit/impl/src/main/res/values-el/translations.xml b/features/roomdetailsedit/impl/src/main/res/values-el/translations.xml new file mode 100644 index 00000000000..c783ab1d864 --- /dev/null +++ b/features/roomdetailsedit/impl/src/main/res/values-el/translations.xml @@ -0,0 +1,7 @@ + + + "Επεξεργασία Αίθουσας" + "Υπήρξε ένα άγνωστο σφάλμα και οι πληροφορίες δεν μπορούσαν να αλλάξουν." + "Αδυναμία ενημέρωσης αίθουσας" + "Ενημέρωση αίθουσας…" + diff --git a/features/roomdetailsedit/impl/src/main/res/values-es/translations.xml b/features/roomdetailsedit/impl/src/main/res/values-es/translations.xml new file mode 100644 index 00000000000..45e1d81df96 --- /dev/null +++ b/features/roomdetailsedit/impl/src/main/res/values-es/translations.xml @@ -0,0 +1,7 @@ + + + "Editar sala" + "Se ha producido un error desconocido y no se ha podido cambiar la información." + "No se puede actualizar la sala" + "Actualizando la sala…" + diff --git a/features/roomdetailsedit/impl/src/main/res/values-et/translations.xml b/features/roomdetailsedit/impl/src/main/res/values-et/translations.xml new file mode 100644 index 00000000000..4c158d348af --- /dev/null +++ b/features/roomdetailsedit/impl/src/main/res/values-et/translations.xml @@ -0,0 +1,7 @@ + + + "Muuda üksikasju" + "Tekkis tundmatu viga ja andmed jäid muutmata." + "Jututoa andmete muutmine ei õnnestu" + "Uuendame jututuba…" + diff --git a/features/roomdetailsedit/impl/src/main/res/values-eu/translations.xml b/features/roomdetailsedit/impl/src/main/res/values-eu/translations.xml new file mode 100644 index 00000000000..000b5856bb2 --- /dev/null +++ b/features/roomdetailsedit/impl/src/main/res/values-eu/translations.xml @@ -0,0 +1,7 @@ + + + "Editatu gela" + "Errore ezezaguna gertatu da eta ezin izan da informazioa aldatu." + "Ezin da gela eguneratu" + "Gela eguneratzen…" + diff --git a/features/roomdetailsedit/impl/src/main/res/values-fa/translations.xml b/features/roomdetailsedit/impl/src/main/res/values-fa/translations.xml new file mode 100644 index 00000000000..bbbd63d1716 --- /dev/null +++ b/features/roomdetailsedit/impl/src/main/res/values-fa/translations.xml @@ -0,0 +1,7 @@ + + + "ویرایش اتاق" + "خطایی ناشناخته رخ داد و اطّلاعات قابل تغییر نبودند." + "ناتوان در به‌روز رسانی اتاق" + "به‌روز کردن اتاق…" + diff --git a/features/roomdetailsedit/impl/src/main/res/values-fi/translations.xml b/features/roomdetailsedit/impl/src/main/res/values-fi/translations.xml new file mode 100644 index 00000000000..eabdcf2ab11 --- /dev/null +++ b/features/roomdetailsedit/impl/src/main/res/values-fi/translations.xml @@ -0,0 +1,7 @@ + + + "Muokkaa tietoja" + "Tuntematon virhe tapahtui, eikä tietoja voitu muuttaa." + "Huoneen muokkaaminen ei onnistunut" + "Muokataan huonetta…" + diff --git a/features/roomdetailsedit/impl/src/main/res/values-fr/translations.xml b/features/roomdetailsedit/impl/src/main/res/values-fr/translations.xml new file mode 100644 index 00000000000..bd0c4b6f026 --- /dev/null +++ b/features/roomdetailsedit/impl/src/main/res/values-fr/translations.xml @@ -0,0 +1,7 @@ + + + "Modifier les détails" + "Une erreur inconnue s’est produite et les informations n’ont pas pu être modifiées." + "Impossible de mettre à jour le salon" + "Mise à jour du salon…" + diff --git a/features/roomdetailsedit/impl/src/main/res/values-hr/translations.xml b/features/roomdetailsedit/impl/src/main/res/values-hr/translations.xml new file mode 100644 index 00000000000..7cd06d6bd8d --- /dev/null +++ b/features/roomdetailsedit/impl/src/main/res/values-hr/translations.xml @@ -0,0 +1,7 @@ + + + "Uredi pojedinosti" + "Došlo je do nepoznate pogreške i podatci se nisu mogli promijeniti." + "Nije moguće ažurirati sobu" + "Ažuriranje pojedinosti…" + diff --git a/features/roomdetailsedit/impl/src/main/res/values-hu/translations.xml b/features/roomdetailsedit/impl/src/main/res/values-hu/translations.xml new file mode 100644 index 00000000000..9685a9afbbc --- /dev/null +++ b/features/roomdetailsedit/impl/src/main/res/values-hu/translations.xml @@ -0,0 +1,7 @@ + + + "Részletek szerkesztése" + "Ismeretlen hiba történt, és az információkat nem lehetett megváltoztatni." + "Nem sikerült frissíteni a szobát" + "Szoba frissítése…" + diff --git a/features/roomdetailsedit/impl/src/main/res/values-in/translations.xml b/features/roomdetailsedit/impl/src/main/res/values-in/translations.xml new file mode 100644 index 00000000000..14b3d68c748 --- /dev/null +++ b/features/roomdetailsedit/impl/src/main/res/values-in/translations.xml @@ -0,0 +1,7 @@ + + + "Sunting Ruangan" + "Terjadi kesalahan yang tidak diketahui dan informasinya tidak dapat diubah." + "Tidak dapat memperbarui ruangan" + "Memperbarui ruangan…" + diff --git a/features/roomdetailsedit/impl/src/main/res/values-it/translations.xml b/features/roomdetailsedit/impl/src/main/res/values-it/translations.xml new file mode 100644 index 00000000000..7d28c1a3e20 --- /dev/null +++ b/features/roomdetailsedit/impl/src/main/res/values-it/translations.xml @@ -0,0 +1,7 @@ + + + "Modifica dettagli" + "Si è verificato un errore sconosciuto e non è stato possibile modificare le informazioni." + "Impossibile aggiornare la stanza" + "Aggiornamento della stanza…" + diff --git a/features/roomdetailsedit/impl/src/main/res/values-ka/translations.xml b/features/roomdetailsedit/impl/src/main/res/values-ka/translations.xml new file mode 100644 index 00000000000..53ea28028b6 --- /dev/null +++ b/features/roomdetailsedit/impl/src/main/res/values-ka/translations.xml @@ -0,0 +1,7 @@ + + + "ოთახის რედაქტირება" + "უცნობი შეცდომა მოხდა. ინფორმაციის შეცვლა ვერ მოხერხდა." + "ოთახის განახლება შეუძლებელია" + "ოთახის განახლება…" + diff --git a/features/roomdetailsedit/impl/src/main/res/values-ko/translations.xml b/features/roomdetailsedit/impl/src/main/res/values-ko/translations.xml new file mode 100644 index 00000000000..ae513847372 --- /dev/null +++ b/features/roomdetailsedit/impl/src/main/res/values-ko/translations.xml @@ -0,0 +1,7 @@ + + + "방 편집" + "알 수 없는 오류가 발생하여 정보를 변경할 수 없습니다." + "방을 업데이트할 수 없습니다." + "방 업데이트 중…" + diff --git a/features/roomdetailsedit/impl/src/main/res/values-lt/translations.xml b/features/roomdetailsedit/impl/src/main/res/values-lt/translations.xml new file mode 100644 index 00000000000..7e28eeb16c7 --- /dev/null +++ b/features/roomdetailsedit/impl/src/main/res/values-lt/translations.xml @@ -0,0 +1,7 @@ + + + "Redaguoti kambarį" + "Įvyko nežinoma klaida ir informacijos pakeisti nepavyko." + "Nepavyko atnaujinti kambario" + "Atnaujinamas kambarys…" + diff --git a/features/roomdetailsedit/impl/src/main/res/values-nb/translations.xml b/features/roomdetailsedit/impl/src/main/res/values-nb/translations.xml new file mode 100644 index 00000000000..e660c40e7b9 --- /dev/null +++ b/features/roomdetailsedit/impl/src/main/res/values-nb/translations.xml @@ -0,0 +1,7 @@ + + + "Rediger detaljer" + "Det oppstod en ukjent feil, og informasjonen kunne ikke endres." + "Kan ikke oppdatere rommet" + "Oppdaterer rommet …" + diff --git a/features/roomdetailsedit/impl/src/main/res/values-nl/translations.xml b/features/roomdetailsedit/impl/src/main/res/values-nl/translations.xml new file mode 100644 index 00000000000..af6fae3a90a --- /dev/null +++ b/features/roomdetailsedit/impl/src/main/res/values-nl/translations.xml @@ -0,0 +1,7 @@ + + + "Kamer bewerken" + "Er is een onbekende fout opgetreden en de informatie kon niet worden gewijzigd." + "Kan kamer niet bijwerken" + "Kamer bijwerken…" + diff --git a/features/roomdetailsedit/impl/src/main/res/values-pl/translations.xml b/features/roomdetailsedit/impl/src/main/res/values-pl/translations.xml new file mode 100644 index 00000000000..c676ff46ed8 --- /dev/null +++ b/features/roomdetailsedit/impl/src/main/res/values-pl/translations.xml @@ -0,0 +1,7 @@ + + + "Edytuj pokój" + "Wystąpił nieznany błąd i nie można było zmienić informacji." + "Nie można zaktualizować pokoju" + "Aktualizuję pokój…" + diff --git a/features/roomdetailsedit/impl/src/main/res/values-pt-rBR/translations.xml b/features/roomdetailsedit/impl/src/main/res/values-pt-rBR/translations.xml new file mode 100644 index 00000000000..e8e5dfc28d7 --- /dev/null +++ b/features/roomdetailsedit/impl/src/main/res/values-pt-rBR/translations.xml @@ -0,0 +1,7 @@ + + + "Editar detalhes" + "Ocorreu um erro desconhecido e as informações não puderam ser alteradas." + "Não foi possível atualizar a sala" + "Atualizando a sala…" + diff --git a/features/roomdetailsedit/impl/src/main/res/values-pt/translations.xml b/features/roomdetailsedit/impl/src/main/res/values-pt/translations.xml new file mode 100644 index 00000000000..25069318a52 --- /dev/null +++ b/features/roomdetailsedit/impl/src/main/res/values-pt/translations.xml @@ -0,0 +1,7 @@ + + + "Editar sala" + "Ocorreu um erro desconhecido e não foi possível alterar a informação." + "Não foi possível atualizar a sala" + "A atualizar sala…" + diff --git a/features/roomdetailsedit/impl/src/main/res/values-ro/translations.xml b/features/roomdetailsedit/impl/src/main/res/values-ro/translations.xml new file mode 100644 index 00000000000..401eeee5751 --- /dev/null +++ b/features/roomdetailsedit/impl/src/main/res/values-ro/translations.xml @@ -0,0 +1,7 @@ + + + "Editați detaliile" + "A apărut o eroare la actualizarea detaliilor camerei" + "Nu s-a putut actualiza camera" + "Se actualizează camera…" + diff --git a/features/roomdetailsedit/impl/src/main/res/values-ru/translations.xml b/features/roomdetailsedit/impl/src/main/res/values-ru/translations.xml new file mode 100644 index 00000000000..91ce37e6c0a --- /dev/null +++ b/features/roomdetailsedit/impl/src/main/res/values-ru/translations.xml @@ -0,0 +1,7 @@ + + + "Редактировать комнату" + "Произошла неизвестная ошибка и информацию не удалось изменить." + "Не удалось обновить комнату" + "Обновление комнаты…" + diff --git a/features/roomdetailsedit/impl/src/main/res/values-sk/translations.xml b/features/roomdetailsedit/impl/src/main/res/values-sk/translations.xml new file mode 100644 index 00000000000..52e484f1a3d --- /dev/null +++ b/features/roomdetailsedit/impl/src/main/res/values-sk/translations.xml @@ -0,0 +1,7 @@ + + + "Upraviť podrobnosti" + "Vyskytla sa neznáma chyba a informácie nebolo možné zmeniť." + "Nepodarilo sa aktualizovať miestnosť" + "Aktualizácia miestnosti…" + diff --git a/features/roomdetailsedit/impl/src/main/res/values-sv/translations.xml b/features/roomdetailsedit/impl/src/main/res/values-sv/translations.xml new file mode 100644 index 00000000000..176aed6b006 --- /dev/null +++ b/features/roomdetailsedit/impl/src/main/res/values-sv/translations.xml @@ -0,0 +1,7 @@ + + + "Redigera rummet" + "Ett okänt fel uppstod och informationen kunde inte ändras." + "Kunde inte uppdatera rummet" + "Uppdaterar rummet …" + diff --git a/features/roomdetailsedit/impl/src/main/res/values-tr/translations.xml b/features/roomdetailsedit/impl/src/main/res/values-tr/translations.xml new file mode 100644 index 00000000000..f55c55cfafd --- /dev/null +++ b/features/roomdetailsedit/impl/src/main/res/values-tr/translations.xml @@ -0,0 +1,7 @@ + + + "Odayı Düzenle" + "Bilinmeyen bir hata oluştu ve bilgiler değiştirilemedi." + "Oda güncellenemiyor" + "Oda güncelleniyor…" + diff --git a/features/roomdetailsedit/impl/src/main/res/values-uk/translations.xml b/features/roomdetailsedit/impl/src/main/res/values-uk/translations.xml new file mode 100644 index 00000000000..21a47cbd596 --- /dev/null +++ b/features/roomdetailsedit/impl/src/main/res/values-uk/translations.xml @@ -0,0 +1,7 @@ + + + "Редагувати кімнату" + "Сталася невідома помилка, й інформацію не вдалося змінити." + "Не вдалося оновити кімнату" + "Оновлення кімнати…" + diff --git a/features/roomdetailsedit/impl/src/main/res/values-ur/translations.xml b/features/roomdetailsedit/impl/src/main/res/values-ur/translations.xml new file mode 100644 index 00000000000..e0fd0e01b53 --- /dev/null +++ b/features/roomdetailsedit/impl/src/main/res/values-ur/translations.xml @@ -0,0 +1,7 @@ + + + "کمرے میں ترمیم کریں" + "ایک نامعلوم خلل تھا اور معلومات تبدیل نہیں ہوسکی۔" + "کمرے کی تجدید کرنے سے قاصر" + "کمرے کی تجدید کر رہا ہے…" + diff --git a/features/roomdetailsedit/impl/src/main/res/values-uz/translations.xml b/features/roomdetailsedit/impl/src/main/res/values-uz/translations.xml new file mode 100644 index 00000000000..cd118131163 --- /dev/null +++ b/features/roomdetailsedit/impl/src/main/res/values-uz/translations.xml @@ -0,0 +1,7 @@ + + + "Tafsilotlarni tahrirlash" + "Nomaʼlum xatolik yuz berdi va maʼlumotni oʻzgartirib boʻlmadi." + "Xonani yangilab bo‘lmadi" + "Xona yangilanmoqda…" + diff --git a/features/roomdetailsedit/impl/src/main/res/values-zh-rTW/translations.xml b/features/roomdetailsedit/impl/src/main/res/values-zh-rTW/translations.xml new file mode 100644 index 00000000000..44955e96dd4 --- /dev/null +++ b/features/roomdetailsedit/impl/src/main/res/values-zh-rTW/translations.xml @@ -0,0 +1,7 @@ + + + "編輯詳細資訊" + "發生未知錯誤,無法變更資訊。" + "無法更新聊天室" + "正在更新聊天室…" + diff --git a/features/roomdetailsedit/impl/src/main/res/values-zh/translations.xml b/features/roomdetailsedit/impl/src/main/res/values-zh/translations.xml new file mode 100644 index 00000000000..6b174fcb2cb --- /dev/null +++ b/features/roomdetailsedit/impl/src/main/res/values-zh/translations.xml @@ -0,0 +1,7 @@ + + + "编辑聊天室" + "出现未知错误,无法更改信息。" + "无法更新聊天室" + "正在更新聊天室……" + diff --git a/features/roomdetailsedit/impl/src/main/res/values/localazy.xml b/features/roomdetailsedit/impl/src/main/res/values/localazy.xml new file mode 100644 index 00000000000..c20e6f2b47d --- /dev/null +++ b/features/roomdetailsedit/impl/src/main/res/values/localazy.xml @@ -0,0 +1,7 @@ + + + "Edit details" + "There was an unknown error and the information couldn\'t be changed." + "Unable to update room" + "Updating details…" + diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditPresenterTest.kt b/features/roomdetailsedit/impl/src/test/kotlin/io/element/android/features/roomdetailsedit/impl/RoomDetailsEditPresenterTest.kt similarity index 76% rename from features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditPresenterTest.kt rename to features/roomdetailsedit/impl/src/test/kotlin/io/element/android/features/roomdetailsedit/impl/RoomDetailsEditPresenterTest.kt index f66091dd870..656cc922658 100644 --- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditPresenterTest.kt +++ b/features/roomdetailsedit/impl/src/test/kotlin/io/element/android/features/roomdetailsedit/impl/RoomDetailsEditPresenterTest.kt @@ -1,17 +1,15 @@ /* * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-2025 New Vector Ltd. * * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. * Please see LICENSE files in the repository root for full details. */ -package io.element.android.features.roomdetails.impl.edit +package io.element.android.features.roomdetailsedit.impl import android.net.Uri import app.cash.turbine.ReceiveTurbine import com.google.common.truth.Truth.assertThat -import io.element.android.features.roomdetails.impl.aJoinedRoom import io.element.android.libraries.androidutils.file.TemporaryUriDeleter import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.core.mimetype.MimeTypes @@ -20,6 +18,11 @@ import io.element.android.libraries.matrix.api.room.StateEventType import io.element.android.libraries.matrix.test.AN_AVATAR_URL import io.element.android.libraries.matrix.test.A_ROOM_NAME import io.element.android.libraries.matrix.test.A_ROOM_RAW_NAME +import io.element.android.libraries.matrix.test.A_ROOM_TOPIC +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.test.room.powerlevels.FakeRoomPermissions import io.element.android.libraries.matrix.ui.media.AvatarAction import io.element.android.libraries.mediapickers.test.FakePickerProvider import io.element.android.libraries.mediaupload.api.MediaUploadInfo @@ -32,6 +35,7 @@ import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.fake.FakeTemporaryUriDeleter import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.matching import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.test import io.mockk.every @@ -99,7 +103,6 @@ class RoomDetailsEditPresenterTest { avatarUrl = AN_AVATAR_URL, displayName = A_ROOM_NAME, rawName = A_ROOM_RAW_NAME, - canSendStateResult = { _, _ -> Result.success(true) } ) val deleteCallback = lambdaRecorder {} val presenter = createRoomDetailsEditPresenter( @@ -125,15 +128,14 @@ class RoomDetailsEditPresenterTest { @Test fun `present - sets canChangeName if user has permission`() = runTest { val room = aJoinedRoom( - avatarUrl = AN_AVATAR_URL, - canSendStateResult = { _, stateEventType -> + canSendState = { stateEventType -> when (stateEventType) { - StateEventType.ROOM_NAME -> Result.success(true) - StateEventType.ROOM_AVATAR -> Result.success(false) - StateEventType.ROOM_TOPIC -> Result.failure(RuntimeException("Oops")) + StateEventType.RoomName -> true + StateEventType.RoomAvatar -> false + StateEventType.RoomTopic -> false else -> lambdaError() } - }, + } ) val deleteCallback = lambdaRecorder {} val presenter = createRoomDetailsEditPresenter( @@ -159,11 +161,11 @@ class RoomDetailsEditPresenterTest { fun `present - sets canChangeAvatar if user has permission`() = runTest { val room = aJoinedRoom( avatarUrl = AN_AVATAR_URL, - canSendStateResult = { _, stateEventType -> + canSendState = { stateEventType -> when (stateEventType) { - StateEventType.ROOM_NAME -> Result.success(false) - StateEventType.ROOM_AVATAR -> Result.success(true) - StateEventType.ROOM_TOPIC -> Result.failure(RuntimeException("Oops")) + StateEventType.RoomName -> false + StateEventType.RoomAvatar -> true + StateEventType.RoomTopic -> false else -> lambdaError() } } @@ -191,11 +193,11 @@ class RoomDetailsEditPresenterTest { fun `present - sets canChangeTopic if user has permission`() = runTest { val room = aJoinedRoom( avatarUrl = AN_AVATAR_URL, - canSendStateResult = { _, stateEventType -> + canSendState = { stateEventType -> when (stateEventType) { - StateEventType.ROOM_NAME -> Result.success(false) - StateEventType.ROOM_AVATAR -> Result.failure(RuntimeException("Oops")) - StateEventType.ROOM_TOPIC -> Result.success(true) + StateEventType.RoomName -> false + StateEventType.RoomAvatar -> false + StateEventType.RoomTopic -> true else -> lambdaError() } } @@ -225,7 +227,6 @@ class RoomDetailsEditPresenterTest { topic = "My topic", displayName = "Name", avatarUrl = AN_AVATAR_URL, - canSendStateResult = { _, _ -> Result.success(true) } ) val deleteCallback = lambdaRecorder {} val presenter = createRoomDetailsEditPresenter( @@ -237,25 +238,25 @@ class RoomDetailsEditPresenterTest { assertThat(initialState.roomTopic).isEqualTo("My topic") assertThat(initialState.roomRawName).isEqualTo("Name") assertThat(initialState.roomAvatarUrl).isEqualTo(AN_AVATAR_URL) - initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName("Name II")) + initialState.eventSink(RoomDetailsEditEvent.UpdateRoomName("Name II")) awaitItem().apply { assertThat(roomTopic).isEqualTo("My topic") assertThat(roomRawName).isEqualTo("Name II") assertThat(roomAvatarUrl).isEqualTo(AN_AVATAR_URL) } - initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName("Name III")) + initialState.eventSink(RoomDetailsEditEvent.UpdateRoomName("Name III")) awaitItem().apply { assertThat(roomTopic).isEqualTo("My topic") assertThat(roomRawName).isEqualTo("Name III") assertThat(roomAvatarUrl).isEqualTo(AN_AVATAR_URL) } - initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic("Another topic")) + initialState.eventSink(RoomDetailsEditEvent.UpdateRoomTopic("Another topic")) awaitItem().apply { assertThat(roomTopic).isEqualTo("Another topic") assertThat(roomRawName).isEqualTo("Name III") assertThat(roomAvatarUrl).isEqualTo(AN_AVATAR_URL) } - initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.Remove)) + initialState.eventSink(RoomDetailsEditEvent.HandleAvatarAction(AvatarAction.Remove)) awaitItem().apply { assertThat(roomTopic).isEqualTo("Another topic") assertThat(roomRawName).isEqualTo("Name III") @@ -270,7 +271,6 @@ class RoomDetailsEditPresenterTest { topic = "My topic", displayName = "Name", avatarUrl = AN_AVATAR_URL, - canSendStateResult = { _, _ -> Result.success(true) } ) fakePickerProvider.givenResult(anotherAvatarUri) val deleteCallback = lambdaRecorder {} @@ -281,7 +281,7 @@ class RoomDetailsEditPresenterTest { presenter.test { val initialState = awaitFirstItem() assertThat(initialState.roomAvatarUrl).isEqualTo(AN_AVATAR_URL) - initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.ChoosePhoto)) + initialState.eventSink(RoomDetailsEditEvent.HandleAvatarAction(AvatarAction.ChoosePhoto)) awaitItem().apply { assertThat(roomAvatarUrl).isEqualTo(anotherAvatarUri.toString()) } @@ -294,7 +294,6 @@ class RoomDetailsEditPresenterTest { topic = "My topic", displayName = "Name", avatarUrl = AN_AVATAR_URL, - canSendStateResult = { _, _ -> Result.success(true) } ) fakePickerProvider.givenResult(anotherAvatarUri) val fakePermissionsPresenter = FakePermissionsPresenter() @@ -308,7 +307,7 @@ class RoomDetailsEditPresenterTest { val initialState = awaitFirstItem() assertThat(initialState.roomAvatarUrl).isEqualTo(AN_AVATAR_URL) assertThat(initialState.cameraPermissionState.permissionGranted).isFalse() - initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.TakePhoto)) + initialState.eventSink(RoomDetailsEditEvent.HandleAvatarAction(AvatarAction.TakePhoto)) val stateWithAskingPermission = awaitItem() assertThat(stateWithAskingPermission.cameraPermissionState.showDialog).isTrue() fakePermissionsPresenter.setPermissionGranted() @@ -318,7 +317,7 @@ class RoomDetailsEditPresenterTest { assertThat(stateWithNewAvatar.roomAvatarUrl).isEqualTo(anotherAvatarUri.toString()) // Do it again, no permission is requested fakePickerProvider.givenResult(roomAvatarUri) - stateWithNewAvatar.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.TakePhoto)) + stateWithNewAvatar.eventSink(RoomDetailsEditEvent.HandleAvatarAction(AvatarAction.TakePhoto)) val stateWithNewAvatar2 = awaitItem() assertThat(stateWithNewAvatar2.roomAvatarUrl).isEqualTo(AN_AVATAR_URL) deleteCallback.assertions().isCalledExactly(3).withSequence( @@ -335,7 +334,6 @@ class RoomDetailsEditPresenterTest { topic = "My topic", displayName = "Name", avatarUrl = AN_AVATAR_URL, - canSendStateResult = { _, _ -> Result.success(true) } ) fakePickerProvider.givenResult(roomAvatarUri) val deleteCallback = lambdaRecorder {} @@ -347,32 +345,32 @@ class RoomDetailsEditPresenterTest { val initialState = awaitFirstItem() assertThat(initialState.saveButtonEnabled).isFalse() // Once a change is made, the save button is enabled - initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName("Name II")) + initialState.eventSink(RoomDetailsEditEvent.UpdateRoomName("Name II")) awaitItem().apply { assertThat(saveButtonEnabled).isTrue() } // If it's reverted then the save disables again - initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName("Name")) + initialState.eventSink(RoomDetailsEditEvent.UpdateRoomName("Name")) awaitItem().apply { assertThat(saveButtonEnabled).isFalse() } // Make a change... - initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic("Another topic")) + initialState.eventSink(RoomDetailsEditEvent.UpdateRoomTopic("Another topic")) awaitItem().apply { assertThat(saveButtonEnabled).isTrue() } // Revert it... - initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic("My topic")) + initialState.eventSink(RoomDetailsEditEvent.UpdateRoomTopic("My topic")) awaitItem().apply { assertThat(saveButtonEnabled).isFalse() } // Make a change... - initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.Remove)) + initialState.eventSink(RoomDetailsEditEvent.HandleAvatarAction(AvatarAction.Remove)) awaitItem().apply { assertThat(saveButtonEnabled).isTrue() } // Revert it... - initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.ChoosePhoto)) + initialState.eventSink(RoomDetailsEditEvent.HandleAvatarAction(AvatarAction.ChoosePhoto)) awaitItem().apply { assertThat(saveButtonEnabled).isFalse() } @@ -385,7 +383,6 @@ class RoomDetailsEditPresenterTest { topic = null, displayName = "fallback", avatarUrl = null, - canSendStateResult = { _, _ -> Result.success(true) } ) fakePickerProvider.givenResult(roomAvatarUri) val deleteCallback = lambdaRecorder {} @@ -397,32 +394,32 @@ class RoomDetailsEditPresenterTest { val initialState = awaitFirstItem() assertThat(initialState.saveButtonEnabled).isFalse() // Once a change is made, the save button is enabled - initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName("Name II")) + initialState.eventSink(RoomDetailsEditEvent.UpdateRoomName("Name II")) awaitItem().apply { assertThat(saveButtonEnabled).isTrue() } // If it's reverted then the save disables again - initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName("fallback")) + initialState.eventSink(RoomDetailsEditEvent.UpdateRoomName("fallback")) awaitItem().apply { assertThat(saveButtonEnabled).isFalse() } // Make a change... - initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic("Another topic")) + initialState.eventSink(RoomDetailsEditEvent.UpdateRoomTopic("Another topic")) awaitItem().apply { assertThat(saveButtonEnabled).isTrue() } // Revert it... - initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic("")) + initialState.eventSink(RoomDetailsEditEvent.UpdateRoomTopic("")) awaitItem().apply { assertThat(saveButtonEnabled).isFalse() } // Make a change... - initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.ChoosePhoto)) + initialState.eventSink(RoomDetailsEditEvent.HandleAvatarAction(AvatarAction.ChoosePhoto)) awaitItem().apply { assertThat(saveButtonEnabled).isTrue() } // Revert it... - initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.Remove)) + initialState.eventSink(RoomDetailsEditEvent.HandleAvatarAction(AvatarAction.Remove)) awaitItem().apply { assertThat(saveButtonEnabled).isFalse() } @@ -441,7 +438,6 @@ class RoomDetailsEditPresenterTest { setNameResult = setNameResult, setTopicResult = setTopicResult, removeAvatarResult = removeAvatarResult, - canSendStateResult = { _, _ -> Result.success(true) } ) val deleteCallback = lambdaRecorder {} val presenter = createRoomDetailsEditPresenter( @@ -450,10 +446,10 @@ class RoomDetailsEditPresenterTest { ) presenter.test { val initialState = awaitFirstItem() - initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName("New name")) - initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic("New topic")) - initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.Remove)) - initialState.eventSink(RoomDetailsEditEvents.Save) + initialState.eventSink(RoomDetailsEditEvent.UpdateRoomName("New name")) + initialState.eventSink(RoomDetailsEditEvent.UpdateRoomTopic("New topic")) + initialState.eventSink(RoomDetailsEditEvent.HandleAvatarAction(AvatarAction.Remove)) + initialState.eventSink(RoomDetailsEditEvent.Save) skipItems(5) setNameResult.assertions().isCalledOnce().with(value("New name")) setTopicResult.assertions().isCalledOnce().with(value("New topic")) @@ -467,7 +463,6 @@ class RoomDetailsEditPresenterTest { topic = "My topic", displayName = "Name", avatarUrl = AN_AVATAR_URL, - canSendStateResult = { _, _ -> Result.success(true) } ) val deleteCallback = lambdaRecorder {} val presenter = createRoomDetailsEditPresenter( @@ -476,9 +471,9 @@ class RoomDetailsEditPresenterTest { ) presenter.test { val initialState = awaitItem() - initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName(" Name ")) - initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic(" My topic ")) - initialState.eventSink(RoomDetailsEditEvents.Save) + initialState.eventSink(RoomDetailsEditEvent.UpdateRoomName(" Name ")) + initialState.eventSink(RoomDetailsEditEvent.UpdateRoomTopic(" My topic ")) + initialState.eventSink(RoomDetailsEditEvent.Save) cancelAndIgnoreRemainingEvents() } } @@ -489,7 +484,6 @@ class RoomDetailsEditPresenterTest { topic = null, displayName = "Name", avatarUrl = AN_AVATAR_URL, - canSendStateResult = { _, _ -> Result.success(true) } ) val deleteCallback = lambdaRecorder {} val presenter = createRoomDetailsEditPresenter( @@ -498,8 +492,8 @@ class RoomDetailsEditPresenterTest { ) presenter.test { val initialState = awaitItem() - initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic("")) - initialState.eventSink(RoomDetailsEditEvents.Save) + initialState.eventSink(RoomDetailsEditEvent.UpdateRoomTopic("")) + initialState.eventSink(RoomDetailsEditEvent.Save) cancelAndIgnoreRemainingEvents() deleteCallback.assertions().isCalledOnce().with(value(null)) } @@ -511,7 +505,6 @@ class RoomDetailsEditPresenterTest { topic = "My topic", displayName = "Name", avatarUrl = AN_AVATAR_URL, - canSendStateResult = { _, _ -> Result.success(true) } ) val deleteCallback = lambdaRecorder {} val presenter = createRoomDetailsEditPresenter( @@ -520,8 +513,8 @@ class RoomDetailsEditPresenterTest { ) presenter.test { val initialState = awaitItem() - initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName("")) - initialState.eventSink(RoomDetailsEditEvents.Save) + initialState.eventSink(RoomDetailsEditEvent.UpdateRoomName("")) + initialState.eventSink(RoomDetailsEditEvent.Save) cancelAndIgnoreRemainingEvents() deleteCallback.assertions().isCalledOnce().with(value(null)) } @@ -535,24 +528,30 @@ class RoomDetailsEditPresenterTest { displayName = "Name", avatarUrl = AN_AVATAR_URL, updateAvatarResult = updateAvatarResult, - canSendStateResult = { _, _ -> Result.success(true) } ) - givenPickerReturnsFile() + val tmpFile = givenPickerReturnsFile() val deleteCallback = lambdaRecorder {} val presenter = createRoomDetailsEditPresenter( room = room, temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback), ) - presenter.test { - val initialState = awaitItem() - initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.ChoosePhoto)) - initialState.eventSink(RoomDetailsEditEvents.Save) - skipItems(4) - updateAvatarResult.assertions().isCalledOnce().with(value(MimeTypes.Jpeg), value(fakeFileContents)) - deleteCallback.assertions().isCalledExactly(2).withSequence( - listOf(value(null)), - listOf(value(roomAvatarUri)), - ) + try { + presenter.test { + val initialState = awaitItem() + initialState.eventSink(RoomDetailsEditEvent.HandleAvatarAction(AvatarAction.ChoosePhoto)) + initialState.eventSink(RoomDetailsEditEvent.Save) + skipItems(4) + updateAvatarResult.assertions().isCalledOnce().with( + value(MimeTypes.Jpeg), + matching { it.contentEquals(fakeFileContents) } + ) + deleteCallback.assertions().isCalledExactly(2).withSequence( + listOf(value(null)), + listOf(value(roomAvatarUri)), + ) + } + } finally { + tmpFile.delete() } } @@ -562,7 +561,6 @@ class RoomDetailsEditPresenterTest { topic = "My topic", displayName = "Name", avatarUrl = AN_AVATAR_URL, - canSendStateResult = { _, _ -> Result.success(true) } ) fakePickerProvider.givenResult(anotherAvatarUri) fakeMediaPreProcessor.givenResult(Result.failure(RuntimeException("Oh no"))) @@ -573,8 +571,8 @@ class RoomDetailsEditPresenterTest { ) presenter.test { val initialState = awaitItem() - initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.ChoosePhoto)) - initialState.eventSink(RoomDetailsEditEvents.Save) + initialState.eventSink(RoomDetailsEditEvent.HandleAvatarAction(AvatarAction.ChoosePhoto)) + initialState.eventSink(RoomDetailsEditEvent.Save) skipItems(3) assertThat(awaitItem().saveAction).isInstanceOf(AsyncAction.Failure::class.java) } @@ -587,9 +585,8 @@ class RoomDetailsEditPresenterTest { displayName = "Name", avatarUrl = AN_AVATAR_URL, setNameResult = { Result.failure(RuntimeException("!")) }, - canSendStateResult = { _, _ -> Result.success(true) } ) - saveAndAssertFailure(room, RoomDetailsEditEvents.UpdateRoomName("New name"), deleteCallbackNumberOfInvocation = 1) + saveAndAssertFailure(room, RoomDetailsEditEvent.UpdateRoomName("New name"), deleteCallbackNumberOfInvocation = 1) } @Test @@ -599,9 +596,8 @@ class RoomDetailsEditPresenterTest { displayName = "Name", avatarUrl = AN_AVATAR_URL, setTopicResult = { Result.failure(RuntimeException("!")) }, - canSendStateResult = { _, _ -> Result.success(true) } ) - saveAndAssertFailure(room, RoomDetailsEditEvents.UpdateRoomTopic("New topic"), deleteCallbackNumberOfInvocation = 1) + saveAndAssertFailure(room, RoomDetailsEditEvent.UpdateRoomTopic("New topic"), deleteCallbackNumberOfInvocation = 1) } @Test @@ -611,47 +607,52 @@ class RoomDetailsEditPresenterTest { displayName = "Name", avatarUrl = AN_AVATAR_URL, removeAvatarResult = { Result.failure(RuntimeException("!")) }, - canSendStateResult = { _, _ -> Result.success(true) } ) - saveAndAssertFailure(room, RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.Remove), deleteCallbackNumberOfInvocation = 2) + saveAndAssertFailure(room, RoomDetailsEditEvent.HandleAvatarAction(AvatarAction.Remove), deleteCallbackNumberOfInvocation = 2) } @Test fun `present - sets save action to failure if setting avatar fails`() = runTest { - givenPickerReturnsFile() + val tmpFile = givenPickerReturnsFile() val room = aJoinedRoom( topic = "My topic", displayName = "Name", avatarUrl = AN_AVATAR_URL, updateAvatarResult = { _, _ -> Result.failure(RuntimeException("!")) }, - canSendStateResult = { _, _ -> Result.success(true) } ) - saveAndAssertFailure(room, RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.ChoosePhoto), deleteCallbackNumberOfInvocation = 2) + try { + saveAndAssertFailure(room, RoomDetailsEditEvent.HandleAvatarAction(AvatarAction.ChoosePhoto), deleteCallbackNumberOfInvocation = 2) + } finally { + tmpFile.delete() + } } @Test fun `present - CancelSaveChanges resets save action state`() = runTest { - givenPickerReturnsFile() + val tmpFile = givenPickerReturnsFile() val room = aJoinedRoom( topic = "My topic", displayName = "Name", avatarUrl = AN_AVATAR_URL, setTopicResult = { Result.failure(RuntimeException("!")) }, - canSendStateResult = { _, _ -> Result.success(true) } ) val deleteCallback = lambdaRecorder {} val presenter = createRoomDetailsEditPresenter( room = room, temporaryUriDeleter = FakeTemporaryUriDeleter(deleteCallback), ) - presenter.test { - val initialState = awaitItem() - initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic("foo")) - initialState.eventSink(RoomDetailsEditEvents.Save) - skipItems(3) - assertThat(awaitItem().saveAction).isInstanceOf(AsyncAction.Failure::class.java) - initialState.eventSink(RoomDetailsEditEvents.CloseDialog) - assertThat(awaitItem().saveAction).isInstanceOf(AsyncAction.Uninitialized::class.java) + try { + presenter.test { + val initialState = awaitItem() + initialState.eventSink(RoomDetailsEditEvent.UpdateRoomTopic("foo")) + initialState.eventSink(RoomDetailsEditEvent.Save) + skipItems(3) + assertThat(awaitItem().saveAction).isInstanceOf(AsyncAction.Failure::class.java) + initialState.eventSink(RoomDetailsEditEvent.CloseDialog) + assertThat(awaitItem().saveAction).isInstanceOf(AsyncAction.Uninitialized::class.java) + } + } finally { + tmpFile.delete() } } @@ -659,7 +660,6 @@ class RoomDetailsEditPresenterTest { fun `present - leave without saving - cancel`() = runTest { val room = aJoinedRoom( displayName = "Name", - canSendStateResult = { _, _ -> Result.success(true) } ) val deleteCallback = lambdaRecorder {} val presenter = createRoomDetailsEditPresenter( @@ -670,14 +670,14 @@ class RoomDetailsEditPresenterTest { val initialState = awaitFirstItem() assertThat(initialState.saveButtonEnabled).isFalse() // Once a change is made, the save button is enabled - initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName("Name edited")) + initialState.eventSink(RoomDetailsEditEvent.UpdateRoomName("Name edited")) awaitItem().apply { assertThat(saveButtonEnabled).isTrue() - eventSink(RoomDetailsEditEvents.OnBackPress) + eventSink(RoomDetailsEditEvent.OnBackPress) } awaitItem().apply { assertThat(saveAction).isEqualTo(AsyncAction.ConfirmingCancellation) - eventSink(RoomDetailsEditEvents.CloseDialog) + eventSink(RoomDetailsEditEvent.CloseDialog) } awaitItem().apply { assertThat(saveAction).isEqualTo(AsyncAction.Uninitialized) @@ -689,7 +689,6 @@ class RoomDetailsEditPresenterTest { fun `present - leave no changes, no confirmation`() = runTest { val room = aJoinedRoom( displayName = "Name", - canSendStateResult = { _, _ -> Result.success(true) } ) val presenter = createRoomDetailsEditPresenter( room = room, @@ -698,7 +697,7 @@ class RoomDetailsEditPresenterTest { presenter.test { val initialState = awaitFirstItem() assertThat(initialState.saveButtonEnabled).isFalse() - initialState.eventSink(RoomDetailsEditEvents.OnBackPress) + initialState.eventSink(RoomDetailsEditEvent.OnBackPress) assertThat(awaitItem().saveAction).isEqualTo(AsyncAction.Success(Unit)) } } @@ -707,7 +706,7 @@ class RoomDetailsEditPresenterTest { fun `present - leave without saving - confirm`() = runTest { val room = aJoinedRoom( displayName = "Name", - canSendStateResult = { _, _ -> Result.success(true) } + canSendState = { _ -> true } ) val presenter = createRoomDetailsEditPresenter( room = room, @@ -717,14 +716,14 @@ class RoomDetailsEditPresenterTest { val initialState = awaitFirstItem() assertThat(initialState.saveButtonEnabled).isFalse() // Once a change is made, the save button is enabled - initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName("Name edited")) + initialState.eventSink(RoomDetailsEditEvent.UpdateRoomName("Name edited")) awaitItem().apply { assertThat(saveButtonEnabled).isTrue() - eventSink(RoomDetailsEditEvents.OnBackPress) + eventSink(RoomDetailsEditEvent.OnBackPress) } awaitItem().apply { assertThat(saveAction).isEqualTo(AsyncAction.ConfirmingCancellation) - eventSink(RoomDetailsEditEvents.OnBackPress) + eventSink(RoomDetailsEditEvent.OnBackPress) } awaitItem().apply { assertThat(saveAction).isEqualTo(AsyncAction.Success(Unit)) @@ -734,7 +733,7 @@ class RoomDetailsEditPresenterTest { private suspend fun saveAndAssertFailure( room: JoinedRoom, - event: RoomDetailsEditEvents, + event: RoomDetailsEditEvent, deleteCallbackNumberOfInvocation: Int = 2, ) { val deleteCallback = lambdaRecorder {} @@ -745,7 +744,7 @@ class RoomDetailsEditPresenterTest { presenter.test { val initialState = awaitFirstItem() initialState.eventSink(event) - initialState.eventSink(RoomDetailsEditEvents.Save) + initialState.eventSink(RoomDetailsEditEvent.Save) skipItems(1) assertThat(awaitItem().saveAction).isInstanceOf(AsyncAction.Loading::class.java) assertThat(awaitItem().saveAction).isInstanceOf(AsyncAction.Failure::class.java) @@ -753,20 +752,49 @@ class RoomDetailsEditPresenterTest { } } - private fun givenPickerReturnsFile() { - mockkStatic(File::readBytes) - val processedFile: File = mockk { - every { readBytes() } returns fakeFileContents - } + private fun givenPickerReturnsFile(): File { + val tmpFile = File.createTempFile("test", "jpg") + tmpFile.writeBytes(fakeFileContents) fakePickerProvider.givenResult(anotherAvatarUri) fakeMediaPreProcessor.givenResult( Result.success( MediaUploadInfo.AnyFile( - file = processedFile, + file = tmpFile, fileInfo = mockk(), ) ) ) + return tmpFile + } + + private fun aJoinedRoom( + avatarUrl: String? = AN_AVATAR_URL, + displayName: String = A_ROOM_NAME, + rawName: String = displayName, + topic: String? = A_ROOM_TOPIC, + setNameResult: (String) -> Result = { Result.success(Unit) }, + setTopicResult: (String) -> Result = { Result.success(Unit) }, + updateAvatarResult: (String, ByteArray) -> Result = { _, _ -> Result.success(Unit) }, + removeAvatarResult: () -> Result = { Result.success(Unit) }, + canSendState: (StateEventType) -> Boolean = { true }, + ): JoinedRoom { + return FakeJoinedRoom( + baseRoom = FakeBaseRoom( + roomPermissions = FakeRoomPermissions( + canSendState = canSendState, + ), + initialRoomInfo = aRoomInfo( + name = displayName, + topic = topic, + avatarUrl = avatarUrl, + rawName = rawName + ) + ), + setNameResult = setNameResult, + setTopicResult = setTopicResult, + updateAvatarResult = updateAvatarResult, + removeAvatarResult = removeAvatarResult, + ) } companion object { diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditViewTest.kt b/features/roomdetailsedit/impl/src/test/kotlin/io/element/android/features/roomdetailsedit/impl/RoomDetailsEditViewTest.kt similarity index 85% rename from features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditViewTest.kt rename to features/roomdetailsedit/impl/src/test/kotlin/io/element/android/features/roomdetailsedit/impl/RoomDetailsEditViewTest.kt index c8475cbb893..71fb143074f 100644 --- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditViewTest.kt +++ b/features/roomdetailsedit/impl/src/test/kotlin/io/element/android/features/roomdetailsedit/impl/RoomDetailsEditViewTest.kt @@ -1,12 +1,11 @@ /* * 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.roomdetails.impl.edit +package io.element.android.features.roomdetailsedit.impl import androidx.activity.ComponentActivity import androidx.annotation.StringRes @@ -40,45 +39,45 @@ class RoomDetailsEditViewTest { @Test fun `clicking on back emits the expected Event`() { - val eventsRecorder = EventsRecorder() + val eventsRecorder = EventsRecorder() rule.setRoomDetailsEditView( aRoomDetailsEditState( eventSink = eventsRecorder ), ) rule.pressBack() - eventsRecorder.assertSingle(RoomDetailsEditEvents.OnBackPress) + eventsRecorder.assertSingle(RoomDetailsEditEvent.OnBackPress) } @Test - fun `clicking on OK when confirming exit emits the expected Event`() { - val eventsRecorder = EventsRecorder() + fun `clicking on discard when confirming exit emits the expected Event`() { + val eventsRecorder = EventsRecorder() rule.setRoomDetailsEditView( aRoomDetailsEditState( saveAction = AsyncAction.ConfirmingCancellation, eventSink = eventsRecorder, ), ) - rule.clickOn(CommonStrings.action_ok) - eventsRecorder.assertSingle(RoomDetailsEditEvents.OnBackPress) + rule.clickOn(CommonStrings.action_discard) + eventsRecorder.assertSingle(RoomDetailsEditEvent.OnBackPress) } @Test - fun `clicking on cancel when confirming exit emits the expected Event`() { - val eventsRecorder = EventsRecorder() + fun `clicking on save when confirming exit emits the expected Event`() { + val eventsRecorder = EventsRecorder() rule.setRoomDetailsEditView( aRoomDetailsEditState( saveAction = AsyncAction.ConfirmingCancellation, eventSink = eventsRecorder, ), ) - rule.clickOn(CommonStrings.action_cancel) - eventsRecorder.assertSingle(RoomDetailsEditEvents.CloseDialog) + rule.clickOn(CommonStrings.action_save, inDialog = true) + eventsRecorder.assertSingle(RoomDetailsEditEvent.Save) } @Test fun `when edition is successful, the expected callback is invoked`() { - val eventsRecorder = EventsRecorder(expectEvents = false) + val eventsRecorder = EventsRecorder(expectEvents = false) ensureCalledOnce { callback -> rule.setRoomDetailsEditView( aRoomDetailsEditState( @@ -92,7 +91,7 @@ class RoomDetailsEditViewTest { @Test fun `when name is changed, the expected Event is emitted`() { - val eventsRecorder = EventsRecorder() + val eventsRecorder = EventsRecorder() rule.setRoomDetailsEditView( aRoomDetailsEditState( eventSink = eventsRecorder, @@ -100,12 +99,12 @@ class RoomDetailsEditViewTest { ), ) rule.onNodeWithText("Marketing").performTextInput("A") - eventsRecorder.assertSingle(RoomDetailsEditEvents.UpdateRoomName("AMarketing")) + eventsRecorder.assertSingle(RoomDetailsEditEvent.UpdateRoomName("AMarketing")) } @Test fun `when user cannot change name, nothing happen`() { - val eventsRecorder = EventsRecorder(expectEvents = false) + val eventsRecorder = EventsRecorder(expectEvents = false) rule.setRoomDetailsEditView( aRoomDetailsEditState( eventSink = eventsRecorder, @@ -118,7 +117,7 @@ class RoomDetailsEditViewTest { @Test fun `when topic is changed, the expected Event is emitted`() { - val eventsRecorder = EventsRecorder() + val eventsRecorder = EventsRecorder() rule.setRoomDetailsEditView( aRoomDetailsEditState( eventSink = eventsRecorder, @@ -126,12 +125,12 @@ class RoomDetailsEditViewTest { ), ) rule.onNodeWithText("My Topic").performTextInput("A") - eventsRecorder.assertSingle(RoomDetailsEditEvents.UpdateRoomTopic("AMy Topic")) + eventsRecorder.assertSingle(RoomDetailsEditEvent.UpdateRoomTopic("AMy Topic")) } @Test fun `when user cannot change topic, nothing happen`() { - val eventsRecorder = EventsRecorder(expectEvents = false) + val eventsRecorder = EventsRecorder(expectEvents = false) rule.setRoomDetailsEditView( aRoomDetailsEditState( eventSink = eventsRecorder, @@ -147,7 +146,7 @@ class RoomDetailsEditViewTest { fun `when avatar is changed with action to take photo, the expected Event is emitted`() { testAvatarChange( stringActionRes = CommonStrings.action_take_photo, - expectedEvent = RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.TakePhoto), + expectedEvent = RoomDetailsEditEvent.HandleAvatarAction(AvatarAction.TakePhoto), ) } @@ -156,7 +155,7 @@ class RoomDetailsEditViewTest { fun `when avatar is changed with action to choose photo, the expected Event is emitted`() { testAvatarChange( stringActionRes = CommonStrings.action_choose_photo, - expectedEvent = RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.ChoosePhoto), + expectedEvent = RoomDetailsEditEvent.HandleAvatarAction(AvatarAction.ChoosePhoto), ) } @@ -165,15 +164,15 @@ class RoomDetailsEditViewTest { fun `when avatar is changed with action to remove photo, the expected Event is emitted`() { testAvatarChange( stringActionRes = CommonStrings.action_remove, - expectedEvent = RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.Remove), + expectedEvent = RoomDetailsEditEvent.HandleAvatarAction(AvatarAction.Remove), ) } private fun testAvatarChange( @StringRes stringActionRes: Int, - expectedEvent: RoomDetailsEditEvents.HandleAvatarAction, + expectedEvent: RoomDetailsEditEvent.HandleAvatarAction, ) { - val eventsRecorder = EventsRecorder() + val eventsRecorder = EventsRecorder() rule.setRoomDetailsEditView( aRoomDetailsEditState( eventSink = eventsRecorder, @@ -188,7 +187,7 @@ class RoomDetailsEditViewTest { @Test fun `when user cannot change avatar, nothing happen`() { - val eventsRecorder = EventsRecorder(expectEvents = false) + val eventsRecorder = EventsRecorder(expectEvents = false) rule.setRoomDetailsEditView( aRoomDetailsEditState( eventSink = eventsRecorder, @@ -201,7 +200,7 @@ class RoomDetailsEditViewTest { @Test fun `when save is clicked, the expected Event is emitted`() { - val eventsRecorder = EventsRecorder() + val eventsRecorder = EventsRecorder() rule.setRoomDetailsEditView( aRoomDetailsEditState( eventSink = eventsRecorder, @@ -209,12 +208,12 @@ class RoomDetailsEditViewTest { ), ) rule.clickOn(CommonStrings.action_save) - eventsRecorder.assertSingle(RoomDetailsEditEvents.Save) + eventsRecorder.assertSingle(RoomDetailsEditEvent.Save) } @Test fun `when save is clicked, but nothing need to be saved, nothing happens`() { - val eventsRecorder = EventsRecorder(expectEvents = false) + val eventsRecorder = EventsRecorder(expectEvents = false) rule.setRoomDetailsEditView( aRoomDetailsEditState( eventSink = eventsRecorder, @@ -226,7 +225,7 @@ class RoomDetailsEditViewTest { @Test fun `when error is shown, closing the dialog emit the expected Event`() { - val eventsRecorder = EventsRecorder() + val eventsRecorder = EventsRecorder() rule.setRoomDetailsEditView( aRoomDetailsEditState( eventSink = eventsRecorder, @@ -234,7 +233,7 @@ class RoomDetailsEditViewTest { ), ) rule.clickOn(CommonStrings.action_ok) - eventsRecorder.assertSingle(RoomDetailsEditEvents.CloseDialog) + eventsRecorder.assertSingle(RoomDetailsEditEvent.CloseDialog) } } diff --git a/features/roomdetailsedit/test/build.gradle.kts b/features/roomdetailsedit/test/build.gradle.kts new file mode 100644 index 00000000000..ff9110eb496 --- /dev/null +++ b/features/roomdetailsedit/test/build.gradle.kts @@ -0,0 +1,22 @@ +/* + * 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. + */ + +plugins { + id("io.element.android-library") +} + +android { + namespace = "io.element.android.features.roomdetetailsedit.test" +} + +dependencies { + implementation(projects.features.roomdetailsedit.api) + implementation(projects.libraries.architecture) + implementation(projects.libraries.matrix.api) + implementation(projects.tests.testutils) +} diff --git a/features/roomdetailsedit/test/src/main/kotlin/io/element/android/features/roomdetailsedit/test/FakeRoomDetailsEditEntryPoint.kt b/features/roomdetailsedit/test/src/main/kotlin/io/element/android/features/roomdetailsedit/test/FakeRoomDetailsEditEntryPoint.kt new file mode 100644 index 00000000000..df890ab2c67 --- /dev/null +++ b/features/roomdetailsedit/test/src/main/kotlin/io/element/android/features/roomdetailsedit/test/FakeRoomDetailsEditEntryPoint.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 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.roomdetailsedit.test + +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import io.element.android.features.roomdetailsedit.api.RoomDetailsEditEntryPoint +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeRoomDetailsEditEntryPoint : RoomDetailsEditEntryPoint { + override fun createNode(parentNode: Node, buildContext: BuildContext): Node { + lambdaError() + } +} diff --git a/features/roomdirectory/impl/src/main/res/values-hr/translations.xml b/features/roomdirectory/impl/src/main/res/values-hr/translations.xml new file mode 100644 index 00000000000..2057f54c185 --- /dev/null +++ b/features/roomdirectory/impl/src/main/res/values-hr/translations.xml @@ -0,0 +1,5 @@ + + + "Učitavanje nije uspjelo" + "Direktorij soba" + diff --git a/features/roommembermoderation/api/src/main/kotlin/io/element/android/features/roommembermoderation/api/RoomMemberModerationPermissions.kt b/features/roommembermoderation/api/src/main/kotlin/io/element/android/features/roommembermoderation/api/RoomMemberModerationPermissions.kt new file mode 100644 index 00000000000..87414b836ee --- /dev/null +++ b/features/roommembermoderation/api/src/main/kotlin/io/element/android/features/roommembermoderation/api/RoomMemberModerationPermissions.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2025 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.roommembermoderation.api + +import io.element.android.libraries.matrix.api.room.powerlevels.RoomPermissions + +data class RoomMemberModerationPermissions( + val canKick: Boolean, + val canBan: Boolean, +) { + // Unban requires both kick and ban permission instead of a dedicated unban permission + val canUnban = canBan && canKick + + companion object { + val DEFAULT = RoomMemberModerationPermissions( + canKick = false, + canBan = false, + ) + } +} + +fun RoomPermissions.roomMemberModerationPermissions(): RoomMemberModerationPermissions { + return RoomMemberModerationPermissions( + canKick = canOwnUserKick(), + canBan = canOwnUserBan(), + ) +} diff --git a/features/roommembermoderation/api/src/main/kotlin/io/element/android/features/roommembermoderation/api/RoomMemberModerationState.kt b/features/roommembermoderation/api/src/main/kotlin/io/element/android/features/roommembermoderation/api/RoomMemberModerationState.kt index 85f3e8ec19e..c9ee958f6f3 100644 --- a/features/roommembermoderation/api/src/main/kotlin/io/element/android/features/roommembermoderation/api/RoomMemberModerationState.kt +++ b/features/roommembermoderation/api/src/main/kotlin/io/element/android/features/roommembermoderation/api/RoomMemberModerationState.kt @@ -12,8 +12,7 @@ import androidx.compose.runtime.Immutable @Immutable interface RoomMemberModerationState { - val canKick: Boolean - val canBan: Boolean + val permissions: RoomMemberModerationPermissions val eventSink: (RoomMemberModerationEvents) -> Unit } diff --git a/features/roommembermoderation/impl/src/main/kotlin/io/element/android/features/roommembermoderation/impl/InternalRoomMemberModerationState.kt b/features/roommembermoderation/impl/src/main/kotlin/io/element/android/features/roommembermoderation/impl/InternalRoomMemberModerationState.kt index 0796b110549..1a6dbeffd4f 100644 --- a/features/roommembermoderation/impl/src/main/kotlin/io/element/android/features/roommembermoderation/impl/InternalRoomMemberModerationState.kt +++ b/features/roommembermoderation/impl/src/main/kotlin/io/element/android/features/roommembermoderation/impl/InternalRoomMemberModerationState.kt @@ -10,6 +10,7 @@ package io.element.android.features.roommembermoderation.impl import io.element.android.features.roommembermoderation.api.ModerationActionState import io.element.android.features.roommembermoderation.api.RoomMemberModerationEvents +import io.element.android.features.roommembermoderation.api.RoomMemberModerationPermissions import io.element.android.features.roommembermoderation.api.RoomMemberModerationState import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.matrix.api.user.MatrixUser @@ -17,8 +18,7 @@ import kotlinx.collections.immutable.ImmutableList data class InternalRoomMemberModerationState( val showMatrixId: Boolean, - override val canKick: Boolean, - override val canBan: Boolean, + override val permissions: RoomMemberModerationPermissions, val selectedUser: MatrixUser?, val actions: ImmutableList, val kickUserAsyncAction: AsyncAction, diff --git a/features/roommembermoderation/impl/src/main/kotlin/io/element/android/features/roommembermoderation/impl/InternalRoomMemberModerationStateProvider.kt b/features/roommembermoderation/impl/src/main/kotlin/io/element/android/features/roommembermoderation/impl/InternalRoomMemberModerationStateProvider.kt index 269791d3e86..b33fd2177d3 100644 --- a/features/roommembermoderation/impl/src/main/kotlin/io/element/android/features/roommembermoderation/impl/InternalRoomMemberModerationStateProvider.kt +++ b/features/roommembermoderation/impl/src/main/kotlin/io/element/android/features/roommembermoderation/impl/InternalRoomMemberModerationStateProvider.kt @@ -12,6 +12,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.features.roommembermoderation.api.ModerationAction import io.element.android.features.roommembermoderation.api.ModerationActionState import io.element.android.features.roommembermoderation.api.RoomMemberModerationEvents +import io.element.android.features.roommembermoderation.api.RoomMemberModerationPermissions import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.user.MatrixUser @@ -84,8 +85,7 @@ fun anAlice() = MatrixUser( fun aRoomMembersModerationState( showMatrixId: Boolean = false, - canKick: Boolean = false, - canBan: Boolean = false, + permissions: RoomMemberModerationPermissions = RoomMemberModerationPermissions.DEFAULT, selectedUser: MatrixUser? = null, actions: List = emptyList(), kickUserAsyncAction: AsyncAction = AsyncAction.Uninitialized, @@ -94,8 +94,7 @@ fun aRoomMembersModerationState( eventSink: (RoomMemberModerationEvents) -> Unit = {}, ) = InternalRoomMemberModerationState( showMatrixId = showMatrixId, - canKick = canKick, - canBan = canBan, + permissions = permissions, selectedUser = selectedUser, actions = actions.toImmutableList(), kickUserAsyncAction = kickUserAsyncAction, diff --git a/features/roommembermoderation/impl/src/main/kotlin/io/element/android/features/roommembermoderation/impl/RoomMemberModerationPresenter.kt b/features/roommembermoderation/impl/src/main/kotlin/io/element/android/features/roommembermoderation/impl/RoomMemberModerationPresenter.kt index a56f8010b30..02a496878c7 100644 --- a/features/roommembermoderation/impl/src/main/kotlin/io/element/android/features/roommembermoderation/impl/RoomMemberModerationPresenter.kt +++ b/features/roommembermoderation/impl/src/main/kotlin/io/element/android/features/roommembermoderation/impl/RoomMemberModerationPresenter.kt @@ -21,22 +21,24 @@ import im.vector.app.features.analytics.plan.RoomModeration import io.element.android.features.roommembermoderation.api.ModerationAction import io.element.android.features.roommembermoderation.api.ModerationActionState import io.element.android.features.roommembermoderation.api.RoomMemberModerationEvents +import io.element.android.features.roommembermoderation.api.RoomMemberModerationPermissions import io.element.android.features.roommembermoderation.api.RoomMemberModerationState +import io.element.android.features.roommembermoderation.api.roomMemberModerationPermissions import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.runUpdatingState import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import io.element.android.libraries.core.coroutine.mapState import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMembershipState +import io.element.android.libraries.matrix.api.room.powerlevels.permissionsAsState import io.element.android.libraries.matrix.api.room.roomMembers import io.element.android.libraries.matrix.api.user.MatrixUser -import io.element.android.libraries.matrix.ui.room.canBanAsState -import io.element.android.libraries.matrix.ui.room.canKickAsState -import io.element.android.libraries.matrix.ui.room.userPowerLevelAsState +import io.element.android.libraries.matrix.ui.model.powerLevelOf import io.element.android.services.analytics.api.AnalyticsService import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -58,10 +60,14 @@ class RoomMemberModerationPresenter( @Composable override fun present(): RoomMemberModerationState { val coroutineScope = rememberCoroutineScope() - val syncUpdateFlow = room.syncUpdateFlow.collectAsState() - val canBan = room.canBanAsState(syncUpdateFlow.value) - val canKick = room.canKickAsState(syncUpdateFlow.value) - val currentUserMemberPowerLevel = room.userPowerLevelAsState(syncUpdateFlow.value) + val permissions by room.permissionsAsState(RoomMemberModerationPermissions.DEFAULT) { perms -> + perms.roomMemberModerationPermissions() + } + val currentUserPowerLevel by remember { + room.roomInfoFlow.mapState { info -> + info.powerLevelOf(room.sessionId) + } + }.collectAsState() val kickUserAsyncAction = remember { mutableStateOf(AsyncAction.Uninitialized as AsyncAction) } @@ -87,9 +93,8 @@ class RoomMemberModerationPresenter( } moderationActions.value = computeModerationActions( member = member, - canKick = canKick.value, - canBan = canBan.value, - currentUserMemberPowerLevel = currentUserMemberPowerLevel.value, + permissions = permissions, + currentUserPowerLevel = currentUserPowerLevel, ) } is RoomMemberModerationEvents.ProcessAction -> { @@ -142,8 +147,7 @@ class RoomMemberModerationPresenter( return InternalRoomMemberModerationState( showMatrixId = showMatrixId, - canKick = canKick.value, - canBan = canBan.value, + permissions = permissions, selectedUser = selectedUser, actions = moderationActions.value, kickUserAsyncAction = kickUserAsyncAction.value, @@ -155,26 +159,36 @@ class RoomMemberModerationPresenter( private fun computeModerationActions( member: RoomMember?, - canKick: Boolean, - canBan: Boolean, - currentUserMemberPowerLevel: Long, + permissions: RoomMemberModerationPermissions, + currentUserPowerLevel: Long, ): ImmutableList { return buildList { add(ModerationActionState(action = ModerationAction.DisplayProfile, isEnabled = true)) // Assume the member is a regular user when it's unknown val targetMemberPowerLevel = member?.powerLevel ?: 0 - val canModerateThisUser = currentUserMemberPowerLevel > targetMemberPowerLevel + val canModerateThisUser = currentUserPowerLevel > targetMemberPowerLevel // Assume the member is joined when it's unknown val membership = member?.membership ?: RoomMembershipState.JOIN - if (canKick) { - val isKickEnabled = canModerateThisUser && membership.isActive() - add(ModerationActionState(action = ModerationAction.KickUser, isEnabled = isKickEnabled)) - } - if (canBan) { - if (membership == RoomMembershipState.BAN) { - add(ModerationActionState(action = ModerationAction.UnbanUser, isEnabled = canModerateThisUser)) - } else { - add(ModerationActionState(action = ModerationAction.BanUser, isEnabled = canModerateThisUser)) + when (membership) { + RoomMembershipState.BAN -> { + if (permissions.canUnban) { + add(ModerationActionState(action = ModerationAction.UnbanUser, isEnabled = canModerateThisUser)) + } + } + RoomMembershipState.INVITE, + RoomMembershipState.JOIN, + RoomMembershipState.KNOCK -> { + if (permissions.canKick) { + add(ModerationActionState(action = ModerationAction.KickUser, isEnabled = canModerateThisUser)) + } + if (permissions.canBan) { + add(ModerationActionState(action = ModerationAction.BanUser, isEnabled = canModerateThisUser)) + } + } + RoomMembershipState.LEAVE -> { + if (permissions.canBan) { + add(ModerationActionState(action = ModerationAction.BanUser, isEnabled = canModerateThisUser)) + } } } }.toImmutableList() diff --git a/features/roommembermoderation/impl/src/main/res/values-de/translations.xml b/features/roommembermoderation/impl/src/main/res/values-de/translations.xml index 6c6f9dc8271..89af01f0b7f 100644 --- a/features/roommembermoderation/impl/src/main/res/values-de/translations.xml +++ b/features/roommembermoderation/impl/src/main/res/values-de/translations.xml @@ -4,10 +4,12 @@ "Sperren" "Sie können diesem Chat auch auf Einladung nicht erneut beitreten." "Möchtest du diesen Nutzer wirklich sperren?" + "Mitglieder können diesem Space auch mit Einladung nicht mehr beitreten, aber sie bleiben weiterhin Mitglied in allen Chats und untergeordneten Spaces." "%1$s wird gesperrt." "Entfernen" "Die Nutzer können dem Chat wieder beitreten, wenn sie eingeladen werden." "Möchtest du dieses Mitglied wirklich entfernen?" + "Sie können diesem Space wieder beitreten, wenn sie eingeladen werden. Außerdem behalten sie ihre Mitgliedschaft in allen Chats und untergeordneten Spaces." "Nutzerprofil anzeigen" "Mitglied entfernen" "Mitglied entfernen und für die Zukunft sperren?" diff --git a/features/roommembermoderation/impl/src/main/res/values-hr/translations.xml b/features/roommembermoderation/impl/src/main/res/values-hr/translations.xml new file mode 100644 index 00000000000..48185160db1 --- /dev/null +++ b/features/roommembermoderation/impl/src/main/res/values-hr/translations.xml @@ -0,0 +1,22 @@ + + + "Zabrani korisnika" + "Zabrani" + "Neće se moći ponovno pridružiti ako budu pozvani." + "Jeste li sigurni da želite zabraniti pristup ovom članu?" + "Neće se moći ponovno pridružiti ovom prostoru ako budu pozvani, ali će i dalje zadržati članstvo u svim sobama ili podprostorima." + "Zabranjuje se pristup korisniku %1$s" + "Ukloni" + "Moći će se ponovno pridružiti ovoj sobi ako budu pozvani." + "Jeste li sigurni da želite ukloniti ovog člana?" + "Moći će se ponovno pridružiti ovom prostoru ako budu pozvani, a i dalje će zadržati članstvo u svim sobama ili podprostorima." + "Prikaži profil" + "Ukloni korisnika" + "Želite li ukloniti člana i zabraniti mu da se ubuduće pridruži?" + "Uklanjanje člana %1$s…" + "Poništi zabranu pristupa korisniku" + "Poništi zabranu" + "Mogli bi se ponovno pridružiti ako budu pozvani" + "Jeste li sigurni da želite poništiti zabranu pristupa ovom članu?" + "Uklanja se zabrana korisniku %1$s" + diff --git a/features/roommembermoderation/impl/src/main/res/values-nb/translations.xml b/features/roommembermoderation/impl/src/main/res/values-nb/translations.xml index 4507fce49c7..392b271a1ab 100644 --- a/features/roommembermoderation/impl/src/main/res/values-nb/translations.xml +++ b/features/roommembermoderation/impl/src/main/res/values-nb/translations.xml @@ -9,7 +9,7 @@ "De vil kunne bli med i dette rommet igjen hvis de blir invitert." "Er du sikker på at du vil fjerne dette medlemmet?" "Vis profil" - "Fjern fra rommet" + "Fjern bruker" "Fjerne medlem og utestenge fra å bli med i fremtiden?" "Fjerner %1$s…" "Fjern utestengelsen fra rommet" 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 4811b5263fb..9f21c8d3519 100644 --- a/features/roommembermoderation/impl/src/main/res/values-ro/translations.xml +++ b/features/roommembermoderation/impl/src/main/res/values-ro/translations.xml @@ -4,10 +4,12 @@ "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" "Î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." "Vizualizare profil" "Înlăturați membrul" "Înlăturați membrul și interziceți-i să se alăture în viitor?" diff --git a/features/roommembermoderation/impl/src/main/res/values-uz/translations.xml b/features/roommembermoderation/impl/src/main/res/values-uz/translations.xml index 90dfae8ce38..9b8c57ede31 100644 --- a/features/roommembermoderation/impl/src/main/res/values-uz/translations.xml +++ b/features/roommembermoderation/impl/src/main/res/values-uz/translations.xml @@ -9,7 +9,7 @@ "Agar taklif qilinsa, ular bu xonaga qayta qo‘shilishlari mumkin." "Haqiqatan ham bu a’zoni olib tashlaysizmi?" "Profilni koʻrish" - "Xonadan olib tashlash" + "Foydalanuvchini olib tashlash" "Aʻzo oʻchirilsinmi va kelgusida qoʻshilish taqiqlansinmi?" "Oʻchirish %1$s …" "Xonadan taqiqni olib tashlash" diff --git a/features/roommembermoderation/impl/src/test/kotlin/io/element/android/features/roommembermoderation/impl/RoomMemberModerationPresenterTest.kt b/features/roommembermoderation/impl/src/test/kotlin/io/element/android/features/roommembermoderation/impl/RoomMemberModerationPresenterTest.kt index 2b3f71e7706..3397f3a40ad 100644 --- a/features/roommembermoderation/impl/src/test/kotlin/io/element/android/features/roommembermoderation/impl/RoomMemberModerationPresenterTest.kt +++ b/features/roommembermoderation/impl/src/test/kotlin/io/element/android/features/roommembermoderation/impl/RoomMemberModerationPresenterTest.kt @@ -13,6 +13,7 @@ import com.google.common.truth.Truth.assertThat import io.element.android.features.roommembermoderation.api.ModerationAction import io.element.android.features.roommembermoderation.api.ModerationActionState import io.element.android.features.roommembermoderation.api.RoomMemberModerationEvents +import io.element.android.features.roommembermoderation.api.RoomMemberModerationPermissions import io.element.android.features.roommembermoderation.api.RoomMemberModerationState import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.core.coroutine.CoroutineDispatchers @@ -20,17 +21,22 @@ import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMembersState import io.element.android.libraries.matrix.api.room.RoomMembershipState +import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevels import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.test.A_USER_ID 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.test.room.aRoomMember +import io.element.android.libraries.matrix.test.room.defaultRoomPowerLevelValues +import io.element.android.libraries.matrix.test.room.powerlevels.FakeRoomPermissions 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.test import io.element.android.tests.testutils.testCoroutineDispatchers import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentMapOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest @@ -48,8 +54,7 @@ class RoomMemberModerationPresenterTest { val room = aJoinedRoom() createRoomMemberModerationPresenter(room = room).test { val initialState = awaitState() - assertThat(initialState.canKick).isFalse() - assertThat(initialState.canBan).isFalse() + assertThat(initialState.permissions).isEqualTo(RoomMemberModerationPermissions.DEFAULT) assertThat(initialState.selectedUser).isNull() assertThat(initialState.banUserAsyncAction).isEqualTo(AsyncAction.Uninitialized) assertThat(initialState.kickUserAsyncAction).isEqualTo(AsyncAction.Uninitialized) @@ -160,7 +165,6 @@ class RoomMemberModerationPresenterTest { assertThat(updatedState.selectedUser).isEqualTo(targetUser) assertThat(updatedState.actions).containsExactly( ModerationActionState(action = ModerationAction.DisplayProfile, isEnabled = true), - ModerationActionState(action = ModerationAction.KickUser, isEnabled = false), ModerationActionState(action = ModerationAction.UnbanUser, isEnabled = true), ) } @@ -222,9 +226,11 @@ class RoomMemberModerationPresenterTest { val room = aJoinedRoom() room.baseRoom.givenUpdateMembersResult { // Simulate the member list being updated - room.givenRoomMembersState(RoomMembersState.Ready( - persistentListOf(aRoomMember()) - )) + room.givenRoomMembersState( + RoomMembersState.Ready( + persistentListOf(aRoomMember()) + ) + ) } createRoomMemberModerationPresenter(room = room).test { val initialState = awaitState() @@ -250,9 +256,11 @@ class RoomMemberModerationPresenterTest { val room = aJoinedRoom() room.baseRoom.givenUpdateMembersResult { // Simulate the member list being updated - room.givenRoomMembersState(RoomMembersState.Ready( - persistentListOf(aRoomMember()) - )) + room.givenRoomMembersState( + RoomMembersState.Ready( + persistentListOf(aRoomMember()) + ) + ) } createRoomMemberModerationPresenter(room = room).test { val initialState = awaitState() @@ -278,9 +286,11 @@ class RoomMemberModerationPresenterTest { val room = aJoinedRoom() room.baseRoom.givenUpdateMembersResult { // Simulate the member list being updated - room.givenRoomMembersState(RoomMembersState.Ready( - persistentListOf(aRoomMember()) - )) + room.givenRoomMembersState( + RoomMembersState.Ready( + persistentListOf(aRoomMember()) + ) + ) } createRoomMemberModerationPresenter(room = room).test { val initialState = awaitState() @@ -355,10 +365,18 @@ class RoomMemberModerationPresenterTest { banUserResult = { _, _ -> banUserResult }, unBanUserResult = { _, _ -> unBanUserResult }, baseRoom = FakeBaseRoom( - canBanResult = { _ -> Result.success(canBan) }, - canKickResult = { _ -> Result.success(canKick) }, + roomPermissions = FakeRoomPermissions( + canBan = canBan, + canKick = canKick + ), userRoleResult = { Result.success(myUserRole) }, - updateMembersResult = { Result.success(Unit) } + updateMembersResult = { Result.success(Unit) }, + initialRoomInfo = aRoomInfo( + roomPowerLevels = RoomPowerLevels( + values = defaultRoomPowerLevelValues(), + users = persistentMapOf(A_USER_ID to myUserRole.powerLevel) + ) + ) ), ).apply { val roomMembers = listOfNotNull(targetRoomMember).toImmutableList() 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 8a87fc8983a..497c64f0727 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 @@ -116,11 +116,12 @@ private fun Content( ) { 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( - formattedRecoveryKey, - context.getString(R.string.screen_recovery_key_copied_to_clipboard) + text = formattedRecoveryKey, + toastMessage = toastMessage, ) state.eventSink.invoke(SecureBackupSetupEvents.RecoveryKeyHasBeenSaved) } diff --git a/features/securebackup/impl/src/main/res/values-hr/translations.xml b/features/securebackup/impl/src/main/res/values-hr/translations.xml new file mode 100644 index 00000000000..d225c2f837c --- /dev/null +++ b/features/securebackup/impl/src/main/res/values-hr/translations.xml @@ -0,0 +1,70 @@ + + + "Brisanje pohrane ključeva" + "Uključivanje sigurnosnog kopiranja" + "Sigurno pohranite svoj kriptografski identitet i ključeve poruka na poslužitelju. To će vam omogućiti pregled povijesti poruka na svim novim uređajima. %1$s." + "Pohrana ključeva" + "Za postavljanje oporavka mora biti uključena pohrana ključeva." + "Prenesi ključeve s ovog uređaja" + "Dopusti pohranu ključeva" + "Promjena ključa za oporavak" + "Ako ste izgubili sve postojeće uređaje, oporavite svoj kriptografski identitet i povijest poruka pomoću ključa za oporavak." + "Unesi ključ za oporavak" + "Vaša pohrana ključeva trenutačno nije sinkronizirana." + "Postavljanje oporavka" + "Pristupite svojim šifriranim porukama ako se odjavite iz aplikacije s%1$s sa svih uređaja ili ih izgubite." + "Otvorite %1$s na stolnom uređaju" + "Ponovno se prijavite na svoj račun" + "Kada se od vas zatraži da potvrdite svoj uređaj, odaberite %1$s" + "“Poništi sve”" + "Pridržavajte se uputa za izradu novog ključa za oporavak" + "Spremite svoj novi ključ za oporavak u upravitelj zaporki ili šifriranu bilješku" + "Resetirajte šifriranje za svoj račun pomoću drugog uređaja" + "Nastavi s poništavanjem" + "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" + "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 poništiti svoj identitet." + "Isključi" + "Izgubit ćete šifrirane poruke ako se odjavite sa svih uređaja." + "Jeste li sigurni da želite isključiti sigurnosno kopiranje?" + "Brisanjem pohrane ključeva uklonit ćete svoj kriptografski identitet i ključeve poruka s poslužitelja te isključiti sljedeće sigurnosne značajke:" + "Na novim uređajima nećete imati šifriranu povijest poruka" + "Ako se odjavite iz aplikacije %1$s na svim uređajima, izgubit ćete pristup svojim šifriranim porukama." + "Jeste li sigurni da želite isključiti pohranu ključeva i izbrisati je?" + "Izradite novi ključ za oporavak ako ste izgubili postojeći. Nakon promjene ključa za oporavak, prijašnji više neće funkcionirati." + "Generiraj novi ključ za oporavak" + "Ne dijelite ovo ni s kim!" + "Ključ za oporavak je promijenjen" + "Želite li promijeniti ključ za oporavak?" + "Izradi novi ključ za oporavak" + "Pazite da nitko ne vidi ovaj zaslon!" + "Pokušajte ponovno potvrditi pristup pohrani ključeva." + "Neispravan ključ za oporavak" + "Ako imate sigurnosni ključ ili sigurnosni izraz, i ovo će funkcionirati." + "Unos…" + "Izgubili ste ključ za oporavak?" + "Ključ za oporavak je potvrđen" + "Unesite svoj ključ za oporavak" + "Kopirani ključ za oporavak" + "Generiranje…" + "Spremi ključ za oporavak" + "Zapišite ovaj ključ za oporavak i čuvajte ga na sigurnom mjestu, poput upravitelja zaporki, šifrirane bilješke ili fizičkog sefa." + "Dodirnite za kopiranje ključa za oporavak" + "Spremite svoj ključ za oporavak na sigurno mjesto" + "Nakon ovog koraka nećete moći pristupiti svom novom ključu za oporavak." + "Jeste li spremili svoj ključ za oporavak?" + "Vaša pohrana ključeva zaštićena je ključem za oporavak. Ako vam je nakon postavljanja potreban novi ključ za oporavak, možete ga ponovno izraditi odabirom mogućnosti ‘Promijeni ključ za oporavak’." + "Generirajte svoj ključ za oporavak" + "Ne dijelite ovo ni s kim!" + "Postavljanje oporavka je uspjelo" + "Postavljanje oporavka" + "Da, poništi sada" + "Ovaj je proces nepovratan." + "Jeste li sigurni da želite poništiti svoj identitet?" + "Došlo je do nepoznate pogreške. Provjerite je li zaporka vašeg računa ispravna i pokušajte ponovno." + "Unos…" + "Potvrdite da želite poništiti svoj identitet." + "Unesite zaporku računa kako biste nastavili" + diff --git a/features/securityandprivacy/api/src/main/kotlin/io/element/android/features/securityandprivacy/api/SecurityAndPrivacyEntryPoint.kt b/features/securityandprivacy/api/src/main/kotlin/io/element/android/features/securityandprivacy/api/SecurityAndPrivacyEntryPoint.kt index 2c7c1cfd412..be2a4dc9e25 100644 --- a/features/securityandprivacy/api/src/main/kotlin/io/element/android/features/securityandprivacy/api/SecurityAndPrivacyEntryPoint.kt +++ b/features/securityandprivacy/api/src/main/kotlin/io/element/android/features/securityandprivacy/api/SecurityAndPrivacyEntryPoint.kt @@ -8,6 +8,19 @@ package io.element.android.features.securityandprivacy.api -import io.element.android.libraries.architecture.SimpleFeatureEntryPoint +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 -fun interface SecurityAndPrivacyEntryPoint : SimpleFeatureEntryPoint +fun interface SecurityAndPrivacyEntryPoint : FeatureEntryPoint { + interface Callback : Plugin { + fun onDone() + } + + fun createNode( + parentNode: Node, + buildContext: BuildContext, + callback: Callback, + ): Node +} diff --git a/features/securityandprivacy/api/src/main/kotlin/io/element/android/features/securityandprivacy/api/SecurityAndPrivacyPermissions.kt b/features/securityandprivacy/api/src/main/kotlin/io/element/android/features/securityandprivacy/api/SecurityAndPrivacyPermissions.kt index 82ab30581e9..f42d792b6f1 100644 --- a/features/securityandprivacy/api/src/main/kotlin/io/element/android/features/securityandprivacy/api/SecurityAndPrivacyPermissions.kt +++ b/features/securityandprivacy/api/src/main/kotlin/io/element/android/features/securityandprivacy/api/SecurityAndPrivacyPermissions.kt @@ -8,13 +8,9 @@ package io.element.android.features.securityandprivacy.api -import androidx.compose.runtime.Composable -import androidx.compose.runtime.State -import androidx.compose.runtime.produceState -import io.element.android.features.securityandprivacy.api.SecurityAndPrivacyPermissions.Companion.DEFAULT -import io.element.android.libraries.matrix.api.room.BaseRoom import io.element.android.libraries.matrix.api.room.StateEventType -import io.element.android.libraries.matrix.api.room.powerlevels.canSendState +import io.element.android.libraries.matrix.api.room.join.JoinRule +import io.element.android.libraries.matrix.api.room.powerlevels.RoomPermissions data class SecurityAndPrivacyPermissions( val canChangeRoomAccess: Boolean, @@ -22,10 +18,19 @@ data class SecurityAndPrivacyPermissions( val canChangeEncryption: Boolean, val canChangeRoomVisibility: Boolean, ) { - val hasAny = canChangeRoomAccess || - canChangeHistoryVisibility || - canChangeEncryption || - canChangeRoomVisibility + fun hasAny(isSpace: Boolean, joinRule: JoinRule?): Boolean { + val canChangeRoomVisibility = when (joinRule) { + is JoinRule.Public, + is JoinRule.Knock, + is JoinRule.KnockRestricted -> canChangeRoomVisibility + else -> false + } + return if (isSpace) { + canChangeRoomAccess || canChangeRoomVisibility + } else { + canChangeRoomAccess || canChangeRoomVisibility || canChangeHistoryVisibility || canChangeEncryption + } + } companion object { val DEFAULT = SecurityAndPrivacyPermissions( @@ -37,14 +42,11 @@ data class SecurityAndPrivacyPermissions( } } -@Composable -fun BaseRoom.securityAndPrivacyPermissionsAsState(updateKey: Long): State { - return produceState(DEFAULT, key1 = updateKey) { - value = SecurityAndPrivacyPermissions( - canChangeRoomAccess = canSendState(type = StateEventType.ROOM_JOIN_RULES).getOrElse { false }, - canChangeHistoryVisibility = canSendState(type = StateEventType.ROOM_HISTORY_VISIBILITY).getOrElse { false }, - canChangeEncryption = canSendState(type = StateEventType.ROOM_ENCRYPTION).getOrElse { false }, - canChangeRoomVisibility = canSendState(type = StateEventType.ROOM_CANONICAL_ALIAS).getOrElse { false }, - ) - } +fun RoomPermissions.securityAndPrivacyPermissions(): SecurityAndPrivacyPermissions { + return SecurityAndPrivacyPermissions( + canChangeRoomAccess = canOwnUserSendState(StateEventType.RoomJoinRules), + canChangeHistoryVisibility = canOwnUserSendState(StateEventType.RoomHistoryVisibility), + canChangeEncryption = canOwnUserSendState(StateEventType.RoomEncryption), + canChangeRoomVisibility = canOwnUserSendState(StateEventType.RoomCanonicalAlias), + ) } diff --git a/features/securityandprivacy/impl/build.gradle.kts b/features/securityandprivacy/impl/build.gradle.kts index 5a83cebd8cb..bb9764eb602 100644 --- a/features/securityandprivacy/impl/build.gradle.kts +++ b/features/securityandprivacy/impl/build.gradle.kts @@ -28,7 +28,9 @@ 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) implementation(projects.libraries.designsystem) diff --git a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/DefaultSecurityAndPrivacyEntryPoint.kt b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/DefaultSecurityAndPrivacyEntryPoint.kt index 2d01ed4a836..d2a40c68367 100644 --- a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/DefaultSecurityAndPrivacyEntryPoint.kt +++ b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/DefaultSecurityAndPrivacyEntryPoint.kt @@ -17,7 +17,11 @@ import io.element.android.libraries.di.RoomScope @ContributesBinding(RoomScope::class) class DefaultSecurityAndPrivacyEntryPoint : SecurityAndPrivacyEntryPoint { - override fun createNode(parentNode: Node, buildContext: BuildContext): Node { - return parentNode.createNode(buildContext) + override fun createNode( + parentNode: Node, + buildContext: BuildContext, + callback: SecurityAndPrivacyEntryPoint.Callback, + ): Node { + return parentNode.createNode(buildContext, listOf(callback)) } } diff --git a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/SecurityAndPrivacyFlowNode.kt b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/SecurityAndPrivacyFlowNode.kt index 5dbc8dbb457..c306264773b 100644 --- a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/SecurityAndPrivacyFlowNode.kt +++ b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/SecurityAndPrivacyFlowNode.kt @@ -9,8 +9,12 @@ package io.element.android.features.securityandprivacy.impl import android.os.Parcelable +import androidx.annotation.VisibleForTesting import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.plugin.Plugin @@ -18,12 +22,22 @@ import com.bumble.appyx.navmodel.backstack.BackStack import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedInject import io.element.android.annotations.ContributesNode +import io.element.android.features.securityandprivacy.api.SecurityAndPrivacyEntryPoint +import io.element.android.features.securityandprivacy.api.securityAndPrivacyPermissions import io.element.android.features.securityandprivacy.impl.editroomaddress.EditRoomAddressNode +import io.element.android.features.securityandprivacy.impl.manageauthorizedspaces.ManageAuthorizedSpacesNode import io.element.android.features.securityandprivacy.impl.root.SecurityAndPrivacyNode import io.element.android.libraries.architecture.BackstackView import io.element.android.libraries.architecture.BaseFlowNode +import io.element.android.libraries.architecture.callback import io.element.android.libraries.architecture.createNode import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.matrix.api.room.JoinedRoom +import io.element.android.libraries.matrix.api.room.powerlevels.use +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch import kotlinx.parcelize.Parcelize @ContributesNode(RoomScope::class) @@ -31,6 +45,7 @@ import kotlinx.parcelize.Parcelize class SecurityAndPrivacyFlowNode( @Assisted buildContext: BuildContext, @Assisted plugins: List, + private val room: JoinedRoom, ) : BaseFlowNode( backstack = BackStack( initialElement = NavTarget.SecurityAndPrivacy, @@ -45,9 +60,33 @@ class SecurityAndPrivacyFlowNode( @Parcelize data object EditRoomAddress : NavTarget + + @Parcelize + data object ManageAuthorizedSpaces : NavTarget } - private val navigator = BackstackSecurityAndPrivacyNavigator(backstack) + private val callback: SecurityAndPrivacyEntryPoint.Callback = callback() + + @VisibleForTesting + val navigator = BackstackSecurityAndPrivacyNavigator(callback, backstack) + + override fun onBuilt() { + super.onBuilt() + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.CREATED) { + room.roomInfoFlow + .map { roomInfo -> + room.roomPermissions().use(false) { perms -> + perms.securityAndPrivacyPermissions().hasAny(roomInfo.isSpace, roomInfo.joinRule) + } + } + .filter { canEdit -> !canEdit } + .first() + // If the user can no longer edit security and privacy, exit the flow + callback.onDone() + } + } + } override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { return when (navTarget) { @@ -57,6 +96,9 @@ class SecurityAndPrivacyFlowNode( NavTarget.EditRoomAddress -> { createNode(buildContext, plugins = listOf(navigator)) } + NavTarget.ManageAuthorizedSpaces -> { + createNode(buildContext, plugins = listOf(navigator)) + } } } diff --git a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/SecurityAndPrivacyNavigator.kt b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/SecurityAndPrivacyNavigator.kt index 3b71868bbfe..274bf0b823f 100644 --- a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/SecurityAndPrivacyNavigator.kt +++ b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/SecurityAndPrivacyNavigator.kt @@ -12,15 +12,24 @@ import com.bumble.appyx.core.plugin.Plugin import com.bumble.appyx.navmodel.backstack.BackStack import com.bumble.appyx.navmodel.backstack.operation.pop import com.bumble.appyx.navmodel.backstack.operation.push +import io.element.android.features.securityandprivacy.api.SecurityAndPrivacyEntryPoint interface SecurityAndPrivacyNavigator : Plugin { + fun onDone() fun openEditRoomAddress() fun closeEditRoomAddress() + fun openManageAuthorizedSpaces() + fun closeManageAuthorizedSpaces() } class BackstackSecurityAndPrivacyNavigator( + private val callback: SecurityAndPrivacyEntryPoint.Callback, private val backStack: BackStack ) : SecurityAndPrivacyNavigator { + override fun onDone() { + callback.onDone() + } + override fun openEditRoomAddress() { backStack.push(SecurityAndPrivacyFlowNode.NavTarget.EditRoomAddress) } @@ -28,4 +37,12 @@ class BackstackSecurityAndPrivacyNavigator( override fun closeEditRoomAddress() { backStack.pop() } + + override fun openManageAuthorizedSpaces() { + backStack.push(SecurityAndPrivacyFlowNode.NavTarget.ManageAuthorizedSpaces) + } + + override fun closeManageAuthorizedSpaces() { + backStack.pop() + } } diff --git a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/manageauthorizedspaces/ManageAuthorizedSpacesEvent.kt b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/manageauthorizedspaces/ManageAuthorizedSpacesEvent.kt new file mode 100644 index 00000000000..3b7460721c6 --- /dev/null +++ b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/manageauthorizedspaces/ManageAuthorizedSpacesEvent.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2025 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.securityandprivacy.impl.manageauthorizedspaces + +import io.element.android.libraries.matrix.api.core.RoomId + +sealed interface ManageAuthorizedSpacesEvent { + data object Cancel : ManageAuthorizedSpacesEvent + data object Done : ManageAuthorizedSpacesEvent + data class ToggleSpace(val roomId: RoomId) : ManageAuthorizedSpacesEvent +} diff --git a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/manageauthorizedspaces/ManageAuthorizedSpacesNode.kt b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/manageauthorizedspaces/ManageAuthorizedSpacesNode.kt new file mode 100644 index 00000000000..8414826d398 --- /dev/null +++ b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/manageauthorizedspaces/ManageAuthorizedSpacesNode.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2025 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.securityandprivacy.impl.manageauthorizedspaces + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +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 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.libraries.architecture.appyx.launchMolecule +import io.element.android.libraries.di.RoomScope + +@ContributesNode(RoomScope::class) +@AssistedInject +class ManageAuthorizedSpacesNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + presenter: ManageAuthorizedSpacesPresenter, +) : Node(buildContext, plugins = plugins) { + private val stateFlow = launchMolecule { presenter.present() } + + @Composable + override fun View(modifier: Modifier) { + val state by stateFlow.collectAsState() + ManageAuthorizedSpacesView( + state = state, + modifier = modifier + ) + } +} diff --git a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/manageauthorizedspaces/ManageAuthorizedSpacesPresenter.kt b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/manageauthorizedspaces/ManageAuthorizedSpacesPresenter.kt new file mode 100644 index 00000000000..cdb0d9801fe --- /dev/null +++ b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/manageauthorizedspaces/ManageAuthorizedSpacesPresenter.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2025 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.securityandprivacy.impl.manageauthorizedspaces + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import dev.zacsweers.metro.Inject +import io.element.android.libraries.architecture.Presenter +import kotlinx.collections.immutable.toImmutableList + +@Inject +class ManageAuthorizedSpacesPresenter( + private val spaceSelectionStateHolder: SpaceSelectionStateHolder, +) : Presenter { + @Composable + override fun present(): ManageAuthorizedSpacesState { + val spaceSelectionState by spaceSelectionStateHolder.state.collectAsState() + fun handleEvent(event: ManageAuthorizedSpacesEvent) { + when (event) { + is ManageAuthorizedSpacesEvent.ToggleSpace -> { + val currentSelectedIds = spaceSelectionState.selectedSpaceIds + val newSelectedIds = if (currentSelectedIds.contains(event.roomId)) { + currentSelectedIds.minus(event.roomId).toImmutableList() + } else { + currentSelectedIds.plus(event.roomId).toImmutableList() + } + spaceSelectionStateHolder.updateSelectedSpaceIds(newSelectedIds) + } + ManageAuthorizedSpacesEvent.Done -> { + spaceSelectionStateHolder.setCompletion(SpaceSelectionState.Completion.Completed) + } + ManageAuthorizedSpacesEvent.Cancel -> { + spaceSelectionStateHolder.setCompletion(SpaceSelectionState.Completion.Cancelled) + } + } + } + + return ManageAuthorizedSpacesState( + selectableSpaces = spaceSelectionState.selectableSpaces, + unknownSpaceIds = spaceSelectionState.unknownSpaceIds, + selectedIds = spaceSelectionState.selectedSpaceIds, + eventSink = ::handleEvent, + ) + } +} diff --git a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/manageauthorizedspaces/ManageAuthorizedSpacesState.kt b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/manageauthorizedspaces/ManageAuthorizedSpacesState.kt new file mode 100644 index 00000000000..bfea7d200c3 --- /dev/null +++ b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/manageauthorizedspaces/ManageAuthorizedSpacesState.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025 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.securityandprivacy.impl.manageauthorizedspaces + +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.spaces.SpaceRoom +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableSet + +data class ManageAuthorizedSpacesState( + val selectableSpaces: ImmutableSet, + val unknownSpaceIds: ImmutableList, + val selectedIds: ImmutableList, + val eventSink: (ManageAuthorizedSpacesEvent) -> Unit +) { + val isDoneButtonEnabled = selectedIds.isNotEmpty() +} diff --git a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/manageauthorizedspaces/ManageAuthorizedSpacesStateProvider.kt b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/manageauthorizedspaces/ManageAuthorizedSpacesStateProvider.kt new file mode 100644 index 00000000000..d2fec941ffe --- /dev/null +++ b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/manageauthorizedspaces/ManageAuthorizedSpacesStateProvider.kt @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2025 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.securityandprivacy.impl.manageauthorizedspaces + +import androidx.compose.ui.tooling.preview.PreviewParameterProvider +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.spaces.SpaceRoom +import io.element.android.libraries.previewutils.room.aSpaceRoom +import kotlinx.collections.immutable.toImmutableList +import kotlinx.collections.immutable.toImmutableSet + +open class ManageAuthorizedSpacesStateProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + aManageAuthorizedSpacesState(), + aManageAuthorizedSpacesState( + unknownSpaceIds = listOf(aRoomId(99)) + ), + aManageAuthorizedSpacesState( + selectedIds = listOf(aRoomId(1), aRoomId(3)), + ), + ) +} + +private fun aRoomId(index: Int) = RoomId("!roomId$index:matrix.org") + +private fun aSpaceRoomList(count: Int): List { + return (1..count).map { index -> + aSpaceRoom( + roomId = aRoomId(index), + displayName = "Space $index", + canonicalAlias = if (index % 2 == 0) { + RoomAlias("#space$index:matrix.org") + } else { + null + } + ) + } +} + +fun aManageAuthorizedSpacesState( + selectableSpaces: List = aSpaceRoomList(5), + unknownSpaceIds: List = emptyList(), + selectedIds: List = emptyList(), + eventSink: (ManageAuthorizedSpacesEvent) -> Unit = {}, +) = ManageAuthorizedSpacesState( + selectableSpaces = selectableSpaces.toImmutableSet(), + unknownSpaceIds = unknownSpaceIds.toImmutableList(), + selectedIds = selectedIds.toImmutableList(), + eventSink = eventSink, +) 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 new file mode 100644 index 00000000000..7208ee61157 --- /dev/null +++ b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/manageauthorizedspaces/ManageAuthorizedSpacesView.kt @@ -0,0 +1,197 @@ +/* + * Copyright (c) 2025 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.securityandprivacy.impl.manageauthorizedspaces + +import androidx.activity.compose.BackHandler +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 +import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.features.securityandprivacy.impl.R +import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule +import io.element.android.libraries.designsystem.components.BigIcon +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 +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.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.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.model.getAvatarData +import io.element.android.libraries.ui.strings.CommonStrings + +// Figma design: https://www.figma.com/design/kcnHxunG1LDWXsJhaNuiHz/ER-145--Spaces-on-Element-X?node-id=6361-86274&m=dev +@Composable +fun ManageAuthorizedSpacesView( + state: ManageAuthorizedSpacesState, + modifier: Modifier = Modifier, +) { + fun onCancel() { + state.eventSink(ManageAuthorizedSpacesEvent.Cancel) + } + + fun onDone() { + state.eventSink(ManageAuthorizedSpacesEvent.Done) + } + + BackHandler(onBack = ::onCancel) + + Scaffold( + modifier = modifier, + topBar = { + ManageAuthorizedSpacesTopBar( + onBackClick = ::onCancel, + onDoneClick = ::onDone, + isDoneButtonEnabled = state.isDoneButtonEnabled + ) + } + ) { padding -> + LazyColumn( + modifier = Modifier.padding(padding) + ) { + headerItem() + item { + ListSectionHeader( + title = stringResource(R.string.screen_manage_authorized_spaces_your_spaces_section_title), + hasDivider = false, + ) + } + items(items = state.selectableSpaces.toList()) { space -> + CheckableSpaceListItem( + headlineText = space.displayName, + supportingText = space.canonicalAlias?.value, + avatarData = space.getAvatarData(AvatarSize.SpaceMember), + checked = state.selectedIds.contains(space.roomId), + onCheckedChange = { _ -> + state.eventSink( + ManageAuthorizedSpacesEvent.ToggleSpace(space.roomId) + ) + } + ) + } + if (state.unknownSpaceIds.isNotEmpty()) { + item { + ListSectionHeader( + title = stringResource(R.string.screen_manage_authorized_spaces_unknown_spaces_section_title), + hasDivider = true, + ) + } + items(items = state.unknownSpaceIds) { + CheckableSpaceListItem( + headlineText = stringResource(R.string.screen_manage_authorized_spaces_unknown_space), + supportingText = it.value, + avatarData = null, + checked = state.selectedIds.contains(it), + onCheckedChange = { _ -> + state.eventSink( + ManageAuthorizedSpacesEvent.ToggleSpace(it) + ) + } + ) + } + } + } + } +} + +private fun LazyListScope.headerItem() { + item(key = "header", contentType = "header") { + IconTitleSubtitleMolecule( + modifier = Modifier.padding( + vertical = 16.dp, + horizontal = 24.dp + ), + title = stringResource(R.string.screen_manage_authorized_spaces_header), + subTitle = null, + iconStyle = BigIcon.Style.Default( + vectorIcon = CompoundIcons.SpaceSolid(), + ) + ) + } +} + +@Composable +private fun CheckableSpaceListItem( + headlineText: String, + supportingText: String?, + avatarData: AvatarData?, + checked: Boolean, + onCheckedChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, +) { + ListItem( + headlineContent = { + Text(text = headlineText) + }, + supportingContent = supportingText?.let { + @Composable { + Text(text = supportingText) + } + }, + leadingContent = avatarData?.let { + ListItemContent.Custom { + Avatar( + avatarData = avatarData, + avatarType = AvatarType.Space(), + ) + } + }, + trailingContent = ListItemContent.Checkbox( + checked = checked, + enabled = enabled, + ), + enabled = enabled, + onClick = { onCheckedChange(!checked) }, + modifier = modifier, + ) +} + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ManageAuthorizedSpacesTopBar( + isDoneButtonEnabled: Boolean, + onBackClick: () -> Unit, + onDoneClick: () -> Unit, + modifier: Modifier = Modifier, +) { + TopAppBar( + modifier = modifier, + titleStr = stringResource(R.string.screen_manage_authorized_spaces_title), + navigationIcon = { BackButton(onClick = onBackClick) }, + actions = { + TextButton( + enabled = isDoneButtonEnabled, + text = stringResource(CommonStrings.action_done), + onClick = onDoneClick, + ) + } + ) +} + +@PreviewsDayNight +@Composable +internal fun ManageAuthorizedSpacesViewPreview( + @PreviewParameter(ManageAuthorizedSpacesStateProvider::class) state: ManageAuthorizedSpacesState +) = ElementPreview { + ManageAuthorizedSpacesView(state = state) +} diff --git a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/manageauthorizedspaces/SpaceSelectionState.kt b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/manageauthorizedspaces/SpaceSelectionState.kt new file mode 100644 index 00000000000..3df9e9bf1cf --- /dev/null +++ b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/manageauthorizedspaces/SpaceSelectionState.kt @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2025 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.securityandprivacy.impl.manageauthorizedspaces + +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.spaces.SpaceRoom +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableSet +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentSetOf +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.update + +data class SpaceSelectionState( + val selectableSpaces: ImmutableSet, + val unknownSpaceIds: ImmutableList, + val selectedSpaceIds: ImmutableList, + val completion: Completion, +) { + enum class Completion { + Initial, + Completed, + Cancelled, + } + + companion object { + val INITIAL = SpaceSelectionState( + selectableSpaces = persistentSetOf(), + unknownSpaceIds = persistentListOf(), + selectedSpaceIds = persistentListOf(), + completion = Completion.Initial, + ) + } +} + +@Inject +@SingleIn(RoomScope::class) +class SpaceSelectionStateHolder { + private val _state = MutableStateFlow(SpaceSelectionState.INITIAL) + val state: StateFlow = _state.asStateFlow() + + fun update(transform: (SpaceSelectionState) -> SpaceSelectionState) { + _state.update(transform) + } + + fun updateSelectedSpaceIds(selectedSpaceIds: ImmutableList) { + update { it.copy(selectedSpaceIds = selectedSpaceIds) } + } + + fun setCompletion(completion: SpaceSelectionState.Completion) { + update { it.copy(completion = completion) } + } +} diff --git a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyEvent.kt b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyEvent.kt new file mode 100644 index 00000000000..d61f063a26d --- /dev/null +++ b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyEvent.kt @@ -0,0 +1,30 @@ +/* + * 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.securityandprivacy.impl.root + +sealed interface SecurityAndPrivacyEvent { + data object EditRoomAddress : SecurityAndPrivacyEvent + data object ManageAuthorizedSpaces : SecurityAndPrivacyEvent + data object Save : SecurityAndPrivacyEvent + data object Exit : SecurityAndPrivacyEvent + data object DismissExitConfirmation : SecurityAndPrivacyEvent + data class ChangeRoomAccess(val roomAccess: SecurityAndPrivacyRoomAccess) : SecurityAndPrivacyEvent + + // Special case for "Space Members" + data object SelectSpaceMemberAccess : SecurityAndPrivacyEvent + + // Special case for "Ask to join with Space Members" + data object SelectAskToJoinWithSpaceMembersAccess : SecurityAndPrivacyEvent + data object ToggleEncryptionState : SecurityAndPrivacyEvent + data object CancelEnableEncryption : SecurityAndPrivacyEvent + data object ConfirmEnableEncryption : SecurityAndPrivacyEvent + data class ChangeHistoryVisibility(val historyVisibility: SecurityAndPrivacyHistoryVisibility) : SecurityAndPrivacyEvent + data object ToggleRoomVisibility : SecurityAndPrivacyEvent + data object DismissSaveError : SecurityAndPrivacyEvent +} diff --git a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyEvents.kt b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyEvents.kt deleted file mode 100644 index b1d739c45ba..00000000000 --- a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyEvents.kt +++ /dev/null @@ -1,23 +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.securityandprivacy.impl.root - -sealed interface SecurityAndPrivacyEvents { - data object EditRoomAddress : SecurityAndPrivacyEvents - data object Save : SecurityAndPrivacyEvents - data object Exit : SecurityAndPrivacyEvents - data object DismissExitConfirmation : SecurityAndPrivacyEvents - data class ChangeRoomAccess(val roomAccess: SecurityAndPrivacyRoomAccess) : SecurityAndPrivacyEvents - data object ToggleEncryptionState : SecurityAndPrivacyEvents - data object CancelEnableEncryption : SecurityAndPrivacyEvents - data object ConfirmEnableEncryption : SecurityAndPrivacyEvents - data class ChangeHistoryVisibility(val historyVisibility: SecurityAndPrivacyHistoryVisibility) : SecurityAndPrivacyEvents - data object ToggleRoomVisibility : SecurityAndPrivacyEvents - data object DismissSaveError : SecurityAndPrivacyEvents -} diff --git a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyNode.kt b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyNode.kt index 5e329a06f6f..d5fb72e72e2 100644 --- a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyNode.kt +++ b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyNode.kt @@ -8,6 +8,8 @@ package io.element.android.features.securityandprivacy.impl.root +import android.app.Activity +import androidx.activity.compose.LocalActivity import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -19,7 +21,9 @@ 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.compound.theme.ElementTheme import io.element.android.features.securityandprivacy.impl.SecurityAndPrivacyNavigator +import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab import io.element.android.libraries.architecture.appyx.launchMolecule import io.element.android.libraries.di.RoomScope @@ -35,12 +39,20 @@ class SecurityAndPrivacyNode( private val stateFlow = launchMolecule { presenter.present() } + private fun onOpenExternalUrl(activity: Activity, darkTheme: Boolean, url: String) { + activity.openUrlInChromeCustomTab(null, darkTheme, url) + } + @Composable override fun View(modifier: Modifier) { + val activity = requireNotNull(LocalActivity.current) + val isDark = ElementTheme.isLightTheme.not() val state by stateFlow.collectAsState() SecurityAndPrivacyView( state = state, - onBackClick = this::navigateUp, + onLinkClick = { url -> + onOpenExternalUrl(activity, isDark, url) + }, modifier = modifier ) } 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 43d8383b76a..09873b6b86a 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 @@ -15,15 +15,19 @@ 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 import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedInject -import io.element.android.features.securityandprivacy.api.securityAndPrivacyPermissionsAsState +import io.element.android.features.securityandprivacy.api.SecurityAndPrivacyPermissions +import io.element.android.features.securityandprivacy.api.securityAndPrivacyPermissions import io.element.android.features.securityandprivacy.impl.SecurityAndPrivacyNavigator import io.element.android.features.securityandprivacy.impl.editroomaddress.matchesServer +import io.element.android.features.securityandprivacy.impl.manageauthorizedspaces.SpaceSelectionState +import io.element.android.features.securityandprivacy.impl.manageauthorizedspaces.SpaceSelectionStateHolder import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.Presenter @@ -36,24 +40,35 @@ import io.element.android.libraries.matrix.api.core.RoomAlias import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.room.RoomInfo import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibility +import io.element.android.libraries.matrix.api.room.join.AllowRule import io.element.android.libraries.matrix.api.room.join.JoinRule +import io.element.android.libraries.matrix.api.room.powerlevels.permissionsAsState import io.element.android.libraries.matrix.api.roomdirectory.RoomVisibility +import io.element.android.libraries.matrix.api.spaces.SpaceRoom +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.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch @AssistedInject class SecurityAndPrivacyPresenter( @Assisted private val navigator: SecurityAndPrivacyNavigator, + private val spaceSelectionStateHolder: SpaceSelectionStateHolder, private val matrixClient: MatrixClient, private val room: JoinedRoom, private val featureFlagService: FeatureFlagService, ) : Presenter { @AssistedFactory interface Factory { - fun create(navigator: SecurityAndPrivacyNavigator): SecurityAndPrivacyPresenter + fun create( + navigator: SecurityAndPrivacyNavigator, + ): SecurityAndPrivacyPresenter } @Composable @@ -63,10 +78,12 @@ class SecurityAndPrivacyPresenter( val isKnockEnabled by remember { featureFlagService.isFeatureEnabledFlow(FeatureFlags.Knock) }.collectAsState(false) + val isSpaceSettingsEnabled by remember { + featureFlagService.isFeatureEnabledFlow(FeatureFlags.SpaceSettings) + }.collectAsState(false) + val saveAction = remember { mutableStateOf>(AsyncAction.Uninitialized) } - var confirmExitAction by remember { mutableStateOf>(AsyncAction.Uninitialized) } val homeserverName = remember { matrixClient.userIdServerName() } - val syncUpdateFlow = room.syncUpdateFlow.collectAsState() val roomInfo by room.roomInfoFlow.collectAsState() val savedIsVisibleInRoomDirectory = remember { mutableStateOf>(AsyncData.Uninitialized) } @@ -86,7 +103,7 @@ class SecurityAndPrivacyPresenter( } } - var editedRoomAccess by remember(savedSettings.roomAccess) { + val editedRoomAccess = remember(savedSettings.roomAccess) { mutableStateOf(savedSettings.roomAccess) } var editedHistoryVisibility by remember(savedSettings.historyVisibility) { @@ -99,19 +116,52 @@ class SecurityAndPrivacyPresenter( mutableStateOf(savedIsVisibleInRoomDirectory.value) } val editedSettings = SecurityAndPrivacySettings( - roomAccess = editedRoomAccess, + roomAccess = editedRoomAccess.value, isEncrypted = editedIsEncrypted, isVisibleInRoomDirectory = editedVisibleInRoomDirectory, historyVisibility = editedHistoryVisibility, address = savedSettings.address, ) + val selectableJoinedSpaces by produceState(initialValue = persistentSetOf(), key1 = savedSettings.roomAccess.spaceIds()) { + val joinedParentSpaces = matrixClient + .spaceService + .joinedParents(room.roomId) + .getOrDefault(emptyList()) + + val nonParentJoinedSpaces = savedSettings.roomAccess + .spaceIds() + .mapNotNull { spaceId -> matrixClient.spaceService.getSpaceRoom(spaceId) } + + value = (joinedParentSpaces + nonParentJoinedSpaces).toImmutableSet() + } + + val spaceSelectionMode by remember { + derivedStateOf { + getSpaceSelectionMode(selectableJoinedSpaces, savedSettings.roomAccess) + } + } + + LaunchedEffect(selectableJoinedSpaces, savedSettings.roomAccess) { + val unknownSpaceIds = savedSettings.roomAccess.spaceIds().filter { spaceId -> + selectableJoinedSpaces.none { it.roomId == spaceId } + }.toImmutableList() + spaceSelectionStateHolder.update { state -> + state.copy( + selectableSpaces = selectableJoinedSpaces, + unknownSpaceIds = unknownSpaceIds, + ) + } + } + var showEnableEncryptionConfirmation by remember(savedSettings.isEncrypted) { mutableStateOf(false) } - val permissions by room.securityAndPrivacyPermissionsAsState(syncUpdateFlow.value) + val permissions by room.permissionsAsState(SecurityAndPrivacyPermissions.DEFAULT) { perms -> + perms.securityAndPrivacyPermissions() + } - fun handleEvent(event: SecurityAndPrivacyEvents) { + fun handleEvent(event: SecurityAndPrivacyEvent) { when (event) { - SecurityAndPrivacyEvents.Save -> { + SecurityAndPrivacyEvent.Save -> { coroutineScope.save( saveAction = saveAction, isVisibleInRoomDirectory = savedIsVisibleInRoomDirectory, @@ -119,49 +169,76 @@ class SecurityAndPrivacyPresenter( editedSettings = editedSettings ) } - is SecurityAndPrivacyEvents.ChangeRoomAccess -> { - editedRoomAccess = event.roomAccess + is SecurityAndPrivacyEvent.ChangeRoomAccess -> { + editedRoomAccess.value = event.roomAccess } - is SecurityAndPrivacyEvents.ToggleEncryptionState -> { + is SecurityAndPrivacyEvent.ToggleEncryptionState -> { if (editedIsEncrypted) { editedIsEncrypted = false } else { showEnableEncryptionConfirmation = true } } - is SecurityAndPrivacyEvents.ChangeHistoryVisibility -> { + is SecurityAndPrivacyEvent.ChangeHistoryVisibility -> { editedHistoryVisibility = event.historyVisibility } - SecurityAndPrivacyEvents.ToggleRoomVisibility -> { + SecurityAndPrivacyEvent.ToggleRoomVisibility -> { editedVisibleInRoomDirectory = when (val edited = editedVisibleInRoomDirectory) { is AsyncData.Success -> AsyncData.Success(!edited.data) else -> edited } } - SecurityAndPrivacyEvents.EditRoomAddress -> navigator.openEditRoomAddress() - SecurityAndPrivacyEvents.CancelEnableEncryption -> { + SecurityAndPrivacyEvent.EditRoomAddress -> navigator.openEditRoomAddress() + SecurityAndPrivacyEvent.CancelEnableEncryption -> { showEnableEncryptionConfirmation = false } - SecurityAndPrivacyEvents.ConfirmEnableEncryption -> { + SecurityAndPrivacyEvent.ConfirmEnableEncryption -> { showEnableEncryptionConfirmation = false editedIsEncrypted = true } - SecurityAndPrivacyEvents.DismissSaveError -> { + SecurityAndPrivacyEvent.DismissSaveError -> { saveAction.value = AsyncAction.Uninitialized } - SecurityAndPrivacyEvents.Exit -> { - confirmExitAction = if (savedSettings == editedSettings || confirmExitAction.isConfirming()) { + SecurityAndPrivacyEvent.Exit -> { + saveAction.value = if (savedSettings == editedSettings || saveAction.value == AsyncAction.ConfirmingCancellation) { AsyncAction.Success(Unit) } else { - AsyncAction.ConfirmingNoParams + AsyncAction.ConfirmingCancellation } } - SecurityAndPrivacyEvents.DismissExitConfirmation -> { - confirmExitAction = AsyncAction.Uninitialized + SecurityAndPrivacyEvent.DismissExitConfirmation -> { + saveAction.value = AsyncAction.Uninitialized + } + SecurityAndPrivacyEvent.ManageAuthorizedSpaces -> coroutineScope.launch { + handleMultipleSelection( + savedAccess = savedSettings.roomAccess, + editedRoomAccess = editedRoomAccess, + forKnockRestricted = editedRoomAccess.value is SecurityAndPrivacyRoomAccess.AskToJoinWithSpaceMember + ) + } + SecurityAndPrivacyEvent.SelectSpaceMemberAccess -> coroutineScope.launch { + handleSpaceMemberAccessSelection( + spaceSelectionMode = spaceSelectionMode, + savedAccess = savedSettings.roomAccess, + editedAccess = editedRoomAccess, + ) + } + SecurityAndPrivacyEvent.SelectAskToJoinWithSpaceMembersAccess -> coroutineScope.launch { + handleAskToJoinWithSpaceMembersAccessSelection( + spaceSelectionMode = spaceSelectionMode, + savedAccess = savedSettings.roomAccess, + editedAccess = editedRoomAccess, + ) } } } + LaunchedEffect(saveAction.value.isSuccess()) { + if (saveAction.value.isSuccess()) { + navigator.onDone() + } + } + val state = SecurityAndPrivacyState( savedSettings = savedSettings, editedSettings = editedSettings, @@ -171,19 +248,136 @@ class SecurityAndPrivacyPresenter( saveAction = saveAction.value, permissions = permissions, isSpace = roomInfo.isSpace, - confirmExitAction = confirmExitAction, + isSpaceSettingsEnabled = isSpaceSettingsEnabled, + selectableJoinedSpaces = selectableJoinedSpaces, + spaceSelectionMode = spaceSelectionMode, eventSink = ::handleEvent, ) - // If the history visibility is not available for the current access, use the fallback. - LaunchedEffect(state.availableHistoryVisibilities) { - if (editedSettings.historyVisibility !in state.availableHistoryVisibilities) { + // Revert changes that the user is not allowed to make anymore + LaunchedEffect(permissions, state.editedSettings.roomAccess) { + if (!state.showRoomAccessSection) { + editedRoomAccess.value = savedSettings.roomAccess + } + if (!state.showEncryptionSection) { + editedIsEncrypted = savedSettings.isEncrypted + } + if (!state.showRoomVisibilitySections) { + editedVisibleInRoomDirectory = savedSettings.isVisibleInRoomDirectory + } + if (!state.showHistoryVisibilitySection) { + editedHistoryVisibility = savedSettings.historyVisibility + } else if (editedSettings.historyVisibility !in state.availableHistoryVisibilities) { editedHistoryVisibility = editedSettings.historyVisibility.fallback() } } return state } + private suspend fun handleSpaceMemberAccessSelection( + spaceSelectionMode: SpaceSelectionMode, + savedAccess: SecurityAndPrivacyRoomAccess, + editedAccess: MutableState, + ) { + if (editedAccess.value is SecurityAndPrivacyRoomAccess.SpaceMember) { + return + } + when (spaceSelectionMode) { + is SpaceSelectionMode.None -> Unit + is SpaceSelectionMode.Multiple -> handleMultipleSelection( + savedAccess = savedAccess, + editedRoomAccess = editedAccess, + forKnockRestricted = false, + ) + is SpaceSelectionMode.Single -> { + val newRoomAccess = SecurityAndPrivacyRoomAccess.SpaceMember( + spaceIds = persistentListOf(spaceSelectionMode.spaceId) + ) + editedAccess.value = newRoomAccess + } + } + } + + private suspend fun handleAskToJoinWithSpaceMembersAccessSelection( + spaceSelectionMode: SpaceSelectionMode, + savedAccess: SecurityAndPrivacyRoomAccess, + editedAccess: MutableState, + ) { + if (editedAccess.value is SecurityAndPrivacyRoomAccess.AskToJoinWithSpaceMember) { + return + } + when (spaceSelectionMode) { + is SpaceSelectionMode.None -> Unit + is SpaceSelectionMode.Multiple -> handleMultipleSelection( + savedAccess = savedAccess, + editedRoomAccess = editedAccess, + forKnockRestricted = true, + ) + is SpaceSelectionMode.Single -> { + val newRoomAccess = SecurityAndPrivacyRoomAccess.AskToJoinWithSpaceMember( + spaceIds = persistentListOf(spaceSelectionMode.spaceId) + ) + editedAccess.value = newRoomAccess + } + } + } + + private suspend fun handleMultipleSelection( + savedAccess: SecurityAndPrivacyRoomAccess, + editedRoomAccess: MutableState, + forKnockRestricted: Boolean + ) { + val initialSelection = when (val currentRoomAccess = editedRoomAccess.value) { + is SecurityAndPrivacyRoomAccess.SpaceMember -> currentRoomAccess.spaceIds + is SecurityAndPrivacyRoomAccess.AskToJoinWithSpaceMember -> currentRoomAccess.spaceIds + else -> savedAccess.spaceIds() + } + spaceSelectionStateHolder.update { state -> + state.copy(selectedSpaceIds = initialSelection, completion = SpaceSelectionState.Completion.Initial) + } + navigator.openManageAuthorizedSpaces() + val newState = spaceSelectionStateHolder.state.first { it.completion != SpaceSelectionState.Completion.Initial } + when (newState.completion) { + SpaceSelectionState.Completion.Initial -> Unit + SpaceSelectionState.Completion.Cancelled -> { + navigator.closeManageAuthorizedSpaces() + } + SpaceSelectionState.Completion.Completed -> { + val selectedIds = newState.selectedSpaceIds + editedRoomAccess.value = if (forKnockRestricted) { + SecurityAndPrivacyRoomAccess.AskToJoinWithSpaceMember(spaceIds = selectedIds) + } else { + SecurityAndPrivacyRoomAccess.SpaceMember(spaceIds = selectedIds) + } + navigator.closeManageAuthorizedSpaces() + } + } + } + + private fun getSpaceSelectionMode( + selectableJoinedSpaces: Set, + savedAccess: SecurityAndPrivacyRoomAccess, + ): SpaceSelectionMode { + val selectableSpacesCount = (selectableJoinedSpaces.map { it.roomId } + savedAccess.spaceIds()).toSet().size + return when { + selectableSpacesCount == 0 -> SpaceSelectionMode.None + selectableSpacesCount > 1 -> SpaceSelectionMode.Multiple + else -> { + val joinedSpace = selectableJoinedSpaces.firstOrNull() + if (joinedSpace != null) { + SpaceSelectionMode.Single(joinedSpace.roomId, joinedSpace) + } else { + val spaceId = savedAccess.spaceIds().firstOrNull() + if (spaceId == null) { + SpaceSelectionMode.None + } else { + SpaceSelectionMode.Single(spaceId, null) + } + } + } + } + } + private fun CoroutineScope.isRoomVisibleInRoomDirectory(isRoomVisible: MutableState>) = launch { isRoomVisible.runUpdatingState { room.getRoomVisibility().map { it == RoomVisibility.Public } @@ -224,6 +418,7 @@ class SecurityAndPrivacyPresenter( // the room should be automatically made invisible (private) in the room directory. val editedIsVisibleInRoomDirectory = when (editedSettings.roomAccess) { SecurityAndPrivacyRoomAccess.AskToJoin, + is SecurityAndPrivacyRoomAccess.AskToJoinWithSpaceMember, SecurityAndPrivacyRoomAccess.Anyone -> editedSettings.isVisibleInRoomDirectory.dataOrNull() else -> false } @@ -261,8 +456,19 @@ class SecurityAndPrivacyPresenter( private fun JoinRule?.map(): SecurityAndPrivacyRoomAccess { return when (this) { JoinRule.Public -> SecurityAndPrivacyRoomAccess.Anyone - JoinRule.Knock, is JoinRule.KnockRestricted -> SecurityAndPrivacyRoomAccess.AskToJoin - is JoinRule.Restricted -> SecurityAndPrivacyRoomAccess.SpaceMember + JoinRule.Knock -> SecurityAndPrivacyRoomAccess.AskToJoin + is JoinRule.KnockRestricted -> SecurityAndPrivacyRoomAccess.AskToJoinWithSpaceMember( + spaceIds = this.rules + .filterIsInstance() + .map { it.roomId } + .toImmutableList() + ) + is JoinRule.Restricted -> SecurityAndPrivacyRoomAccess.SpaceMember( + spaceIds = this.rules + .filterIsInstance() + .map { it.roomId } + .toImmutableList() + ) JoinRule.Invite -> SecurityAndPrivacyRoomAccess.InviteOnly // All other cases are not supported so we default to InviteOnly is JoinRule.Custom, @@ -276,28 +482,32 @@ private fun SecurityAndPrivacyRoomAccess.map(): JoinRule? { SecurityAndPrivacyRoomAccess.Anyone -> JoinRule.Public SecurityAndPrivacyRoomAccess.AskToJoin -> JoinRule.Knock SecurityAndPrivacyRoomAccess.InviteOnly -> JoinRule.Private - // SpaceMember can't be selected in the ui - SecurityAndPrivacyRoomAccess.SpaceMember -> null + is SecurityAndPrivacyRoomAccess.SpaceMember -> JoinRule.Restricted( + rules = this.spaceIds.map { AllowRule.RoomMembership(it) }.toImmutableList() + ) + is SecurityAndPrivacyRoomAccess.AskToJoinWithSpaceMember -> JoinRule.KnockRestricted( + rules = this.spaceIds.map { AllowRule.RoomMembership(it) }.toImmutableList() + ) } } private fun RoomHistoryVisibility?.map(): SecurityAndPrivacyHistoryVisibility { return when (this) { - RoomHistoryVisibility.WorldReadable -> SecurityAndPrivacyHistoryVisibility.Anyone RoomHistoryVisibility.Joined, - RoomHistoryVisibility.Invited -> SecurityAndPrivacyHistoryVisibility.SinceInvite - RoomHistoryVisibility.Shared -> SecurityAndPrivacyHistoryVisibility.SinceSelection - // All other cases are not supported so we default to SinceSelection + RoomHistoryVisibility.Invited -> SecurityAndPrivacyHistoryVisibility.Invited + RoomHistoryVisibility.Shared -> SecurityAndPrivacyHistoryVisibility.Shared + RoomHistoryVisibility.WorldReadable -> SecurityAndPrivacyHistoryVisibility.WorldReadable + // All other cases are not supported so we default to Shared is RoomHistoryVisibility.Custom, - null -> SecurityAndPrivacyHistoryVisibility.SinceSelection + null -> SecurityAndPrivacyHistoryVisibility.Shared } } private fun SecurityAndPrivacyHistoryVisibility.map(): RoomHistoryVisibility { return when (this) { - SecurityAndPrivacyHistoryVisibility.SinceSelection -> RoomHistoryVisibility.Shared - SecurityAndPrivacyHistoryVisibility.SinceInvite -> RoomHistoryVisibility.Invited - SecurityAndPrivacyHistoryVisibility.Anyone -> RoomHistoryVisibility.WorldReadable + SecurityAndPrivacyHistoryVisibility.Invited -> RoomHistoryVisibility.Invited + SecurityAndPrivacyHistoryVisibility.Shared -> RoomHistoryVisibility.Shared + SecurityAndPrivacyHistoryVisibility.WorldReadable -> RoomHistoryVisibility.WorldReadable } } 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 0671bbf1635..6ec47ba183d 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 @@ -8,10 +8,18 @@ package io.element.android.features.securityandprivacy.impl.root +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource import io.element.android.features.securityandprivacy.api.SecurityAndPrivacyPermissions +import io.element.android.features.securityandprivacy.impl.R import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncData -import kotlinx.collections.immutable.toImmutableSet +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.spaces.SpaceRoom +import kotlinx.collections.immutable.ImmutableList +import kotlinx.collections.immutable.ImmutableSet +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList data class SecurityAndPrivacyState( // the settings that are currently applied on the room. @@ -20,23 +28,56 @@ data class SecurityAndPrivacyState( val editedSettings: SecurityAndPrivacySettings, val homeserverName: String, val showEnableEncryptionConfirmation: Boolean, - val isKnockEnabled: Boolean, + private val isKnockEnabled: Boolean, + private val isSpaceSettingsEnabled: Boolean, val saveAction: AsyncAction, - val confirmExitAction: AsyncAction, val isSpace: Boolean, private val permissions: SecurityAndPrivacyPermissions, - val eventSink: (SecurityAndPrivacyEvents) -> Unit + private val selectableJoinedSpaces: ImmutableSet, + private val spaceSelectionMode: SpaceSelectionMode, + val eventSink: (SecurityAndPrivacyEvent) -> Unit ) { + val isSpaceMemberSelectable = isSpaceSettingsEnabled && spaceSelectionMode != SpaceSelectionMode.None + + // 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) + val showSpaceMemberOption = savedSettings.roomAccess is SecurityAndPrivacyRoomAccess.SpaceMember || isSpaceMemberSelectable + + val showManageSpaceFooter = spaceSelectionMode is SpaceSelectionMode.Multiple && + (editedSettings.roomAccess is SecurityAndPrivacyRoomAccess.SpaceMember || + editedSettings.roomAccess is SecurityAndPrivacyRoomAccess.AskToJoinWithSpaceMember) + + val isAskToJoinSelectable = isKnockEnabled + + val isAskToJoinWithSpaceMembersSelectable = isAskToJoinSelectable && isSpaceMemberSelectable + + // Show Ask to join option only when: + // - AskToJoin is the current saved value (legacy), OR + // - Knock FF enabled BUT (SpaceSettings FF disabled OR no spaces available) + val showAskToJoinOption = savedSettings.roomAccess == SecurityAndPrivacyRoomAccess.AskToJoin || + isAskToJoinSelectable && !isAskToJoinWithSpaceMembersSelectable + + // Show AskToJoinWithSpaceMember option when: + // - It's the current saved value, OR + // - Both FFs enabled AND spaces available + val showAskToJoinWithSpaceMemberOption = savedSettings.roomAccess is SecurityAndPrivacyRoomAccess.AskToJoinWithSpaceMember || + isAskToJoinWithSpaceMembersSelectable + val canBeSaved = savedSettings != editedSettings - val availableHistoryVisibilities = buildSet { - add(SecurityAndPrivacyHistoryVisibility.SinceSelection) + // Logic is in https://github.com/element-hq/element-meta/issues/3029 + val availableHistoryVisibilities = buildList { + // Shared is always available + add(SecurityAndPrivacyHistoryVisibility.Shared) if (editedSettings.roomAccess == SecurityAndPrivacyRoomAccess.Anyone && !editedSettings.isEncrypted) { - add(SecurityAndPrivacyHistoryVisibility.Anyone) + add(SecurityAndPrivacyHistoryVisibility.WorldReadable) } else { - add(SecurityAndPrivacyHistoryVisibility.SinceInvite) + add(SecurityAndPrivacyHistoryVisibility.Invited) } - }.toImmutableSet() + } + .sorted() + .toImmutableList() val showRoomAccessSection = permissions.canChangeRoomAccess @@ -45,6 +86,40 @@ data class SecurityAndPrivacyState( val showHistoryVisibilitySection = permissions.canChangeHistoryVisibility && !isSpace val showEncryptionSection = permissions.canChangeEncryption && !isSpace + + @Composable + fun spaceMemberDescription(): String { + return if (isSpaceMemberSelectable) { + when (spaceSelectionMode) { + is SpaceSelectionMode.Single -> { + val spaceName = spaceSelectionMode.spaceRoom?.displayName ?: spaceSelectionMode.spaceId.value + stringResource(R.string.screen_security_and_privacy_room_access_space_members_option_single_parent_description, spaceName) + } + is SpaceSelectionMode.None, + is SpaceSelectionMode.Multiple -> stringResource( + R.string.screen_security_and_privacy_room_access_space_members_option_multiple_parents_description + ) + } + } else { + stringResource(R.string.screen_security_and_privacy_room_access_space_members_option_unavailable_description) + } + } + + @Composable + fun askToJoinWithSpaceMembersDescription(): String { + return if (isAskToJoinWithSpaceMembersSelectable) { + when (spaceSelectionMode) { + is SpaceSelectionMode.Single -> { + val spaceName = spaceSelectionMode.spaceRoom?.displayName ?: spaceSelectionMode.spaceId.value + stringResource(R.string.screen_security_and_privacy_ask_to_join_single_space_members_option_description, spaceName) + } + is SpaceSelectionMode.None, + is SpaceSelectionMode.Multiple -> stringResource(R.string.screen_security_and_privacy_ask_to_join_multiple_spaces_members_option_description) + } + } else { + stringResource(R.string.screen_security_and_privacy_ask_to_join_option_description) + } + } } data class SecurityAndPrivacySettings( @@ -56,32 +131,48 @@ data class SecurityAndPrivacySettings( ) enum class SecurityAndPrivacyHistoryVisibility { - SinceSelection, - SinceInvite, - Anyone; + // Order matters, and is from the most to the least restrictive + Invited, + Shared, + WorldReadable; /** * Returns the fallback visibility when the current visibility is not available. */ fun fallback(): SecurityAndPrivacyHistoryVisibility { return when (this) { - SinceSelection, - SinceInvite -> SinceSelection - Anyone -> SinceInvite + Invited, + Shared -> Shared + WorldReadable -> Invited } } } -enum class SecurityAndPrivacyRoomAccess { - InviteOnly, - AskToJoin, - Anyone, - SpaceMember; +sealed interface SpaceSelectionMode { + data object None : SpaceSelectionMode + data class Single(val spaceId: RoomId, val spaceRoom: SpaceRoom?) : SpaceSelectionMode + data object Multiple : SpaceSelectionMode +} + +sealed interface SecurityAndPrivacyRoomAccess { + data object InviteOnly : SecurityAndPrivacyRoomAccess + data object AskToJoin : SecurityAndPrivacyRoomAccess + data object Anyone : SecurityAndPrivacyRoomAccess + data class SpaceMember(val spaceIds: ImmutableList) : SecurityAndPrivacyRoomAccess + data class AskToJoinWithSpaceMember(val spaceIds: ImmutableList) : SecurityAndPrivacyRoomAccess fun canConfigureRoomVisibility(): Boolean { return when (this) { - InviteOnly, SpaceMember -> false - AskToJoin, Anyone -> true + InviteOnly, is SpaceMember -> false + AskToJoin, Anyone, is AskToJoinWithSpaceMember -> true + } + } + + fun spaceIds(): ImmutableList { + return when (this) { + is SpaceMember -> spaceIds + is AskToJoinWithSpaceMember -> spaceIds + else -> persistentListOf() } } } diff --git a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyStateProvider.kt b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyStateProvider.kt index 11e5665c1c1..95cb45d641e 100644 --- a/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyStateProvider.kt +++ b/features/securityandprivacy/impl/src/main/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyStateProvider.kt @@ -12,6 +12,9 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.features.securityandprivacy.api.SecurityAndPrivacyPermissions import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.matrix.api.spaces.SpaceRoom +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableSet open class SecurityAndPrivacyStateProvider : PreviewParameterProvider { override val values: Sequence @@ -27,7 +30,7 @@ open class SecurityAndPrivacyStateProvider : PreviewParameterProvider = AsyncData.Uninitialized, ) = SecurityAndPrivacySettings( roomAccess = roomAccess, @@ -109,7 +128,6 @@ fun aSecurityAndPrivacyState( homeserverName: String = "myserver.xyz", showEncryptionConfirmation: Boolean = false, saveAction: AsyncAction = AsyncAction.Uninitialized, - confirmExitAction: AsyncAction = AsyncAction.Uninitialized, permissions: SecurityAndPrivacyPermissions = SecurityAndPrivacyPermissions( canChangeRoomAccess = true, canChangeHistoryVisibility = true, @@ -118,16 +136,21 @@ fun aSecurityAndPrivacyState( ), isKnockEnabled: Boolean = true, isSpace: Boolean = false, - eventSink: (SecurityAndPrivacyEvents) -> Unit = {} + selectableJoinedSpaces: Set = emptySet(), + spaceSelectionMode: SpaceSelectionMode = SpaceSelectionMode.None, + isSpaceSettingsEnabled: Boolean = true, + eventSink: (SecurityAndPrivacyEvent) -> Unit = {} ) = SecurityAndPrivacyState( editedSettings = editedSettings, savedSettings = savedSettings, homeserverName = homeserverName, showEnableEncryptionConfirmation = showEncryptionConfirmation, saveAction = saveAction, - confirmExitAction = confirmExitAction, isKnockEnabled = isKnockEnabled, permissions = permissions, isSpace = isSpace, + selectableJoinedSpaces = selectableJoinedSpaces.toImmutableSet(), + spaceSelectionMode = spaceSelectionMode, + isSpaceSettingsEnabled = isSpaceSettingsEnabled, 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 d4bf0ab630a..f4207ae3af9 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 @@ -27,11 +27,14 @@ import androidx.compose.material3.ListItemDefaults import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp +import io.element.android.appconfig.LearnMoreConfig import io.element.android.compound.theme.ElementTheme import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.features.securityandprivacy.impl.R +import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage import io.element.android.libraries.designsystem.components.async.AsyncActionView @@ -43,6 +46,7 @@ import io.element.android.libraries.designsystem.components.list.ListItemContent import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.PreviewWithLargeHeight +import io.element.android.libraries.designsystem.text.stringWithLink import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator import io.element.android.libraries.designsystem.theme.components.IconSource import io.element.android.libraries.designsystem.theme.components.ListItem @@ -51,16 +55,16 @@ 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.ui.strings.CommonStrings -import kotlinx.collections.immutable.ImmutableSet +import kotlinx.collections.immutable.ImmutableList @Composable fun SecurityAndPrivacyView( state: SecurityAndPrivacyState, - onBackClick: () -> Unit, + onLinkClick: (String) -> Unit, modifier: Modifier = Modifier, ) { BackHandler { - state.eventSink(SecurityAndPrivacyEvents.Exit) + state.eventSink(SecurityAndPrivacyEvent.Exit) } Scaffold( modifier = modifier, @@ -68,10 +72,10 @@ fun SecurityAndPrivacyView( SecurityAndPrivacyToolbar( isSaveActionEnabled = state.canBeSaved, onBackClick = { - state.eventSink(SecurityAndPrivacyEvents.Exit) + state.eventSink(SecurityAndPrivacyEvent.Exit) }, onSaveClick = { - state.eventSink(SecurityAndPrivacyEvents.Save) + state.eventSink(SecurityAndPrivacyEvent.Save) }, ) } @@ -86,11 +90,8 @@ fun SecurityAndPrivacyView( ) { if (state.showRoomAccessSection) { RoomAccessSection( + state = state, modifier = Modifier.padding(top = 24.dp), - edited = state.editedSettings.roomAccess, - saved = state.savedSettings.roomAccess, - isKnockEnabled = state.isKnockEnabled, - onSelectOption = { state.eventSink(SecurityAndPrivacyEvents.ChangeRoomAccess(it)) }, ) } if (state.showRoomVisibilitySections) { @@ -98,10 +99,10 @@ fun SecurityAndPrivacyView( RoomAddressSection( roomAddress = state.editedSettings.address, homeserverName = state.homeserverName, - onRoomAddressClick = { state.eventSink(SecurityAndPrivacyEvents.EditRoomAddress) }, + onRoomAddressClick = { state.eventSink(SecurityAndPrivacyEvent.EditRoomAddress) }, isVisibleInRoomDirectory = state.editedSettings.isVisibleInRoomDirectory, onVisibilityChange = { - state.eventSink(SecurityAndPrivacyEvents.ToggleRoomVisibility) + state.eventSink(SecurityAndPrivacyEvent.ToggleRoomVisibility) }, ) } @@ -110,10 +111,10 @@ fun SecurityAndPrivacyView( isRoomEncrypted = state.editedSettings.isEncrypted, // encryption can't be disabled once enabled canToggleEncryption = !state.savedSettings.isEncrypted, - onToggleEncryption = { state.eventSink(SecurityAndPrivacyEvents.ToggleEncryptionState) }, + onToggleEncryption = { state.eventSink(SecurityAndPrivacyEvent.ToggleEncryptionState) }, showConfirmation = state.showEnableEncryptionConfirmation, - onDismissConfirmation = { state.eventSink(SecurityAndPrivacyEvents.CancelEnableEncryption) }, - onConfirmEncryption = { state.eventSink(SecurityAndPrivacyEvents.ConfirmEnableEncryption) }, + onDismissConfirmation = { state.eventSink(SecurityAndPrivacyEvent.CancelEnableEncryption) }, + onConfirmEncryption = { state.eventSink(SecurityAndPrivacyEvent.ConfirmEnableEncryption) }, ) } if (state.showHistoryVisibilitySection) { @@ -121,7 +122,8 @@ fun SecurityAndPrivacyView( editedOption = state.editedSettings.historyVisibility, savedOptions = state.savedSettings.historyVisibility, availableOptions = state.availableHistoryVisibilities, - onSelectOption = { state.eventSink(SecurityAndPrivacyEvents.ChangeHistoryVisibility(it)) }, + onSelectOption = { state.eventSink(SecurityAndPrivacyEvent.ChangeHistoryVisibility(it)) }, + onLinkClick = onLinkClick, ) } } @@ -129,25 +131,24 @@ fun SecurityAndPrivacyView( AsyncActionView( async = state.saveAction, onSuccess = { }, - onErrorDismiss = { state.eventSink(SecurityAndPrivacyEvents.DismissSaveError) }, + onErrorDismiss = { state.eventSink(SecurityAndPrivacyEvent.DismissSaveError) }, + confirmationDialog = { confirming -> + when (confirming) { + is AsyncAction.ConfirmingCancellation -> + SaveChangesDialog( + onSaveClick = { state.eventSink(SecurityAndPrivacyEvent.Save) }, + onDiscardClick = { state.eventSink(SecurityAndPrivacyEvent.Exit) }, + onDismiss = { state.eventSink(SecurityAndPrivacyEvent.DismissExitConfirmation) } + ) + } + }, errorMessage = { stringResource(CommonStrings.error_unknown) }, progressDialog = { AsyncActionViewDefaults.ProgressDialog( progressText = stringResource(CommonStrings.common_saving), ) }, - onRetry = { state.eventSink(SecurityAndPrivacyEvents.Save) }, - ) - AsyncActionView( - async = state.confirmExitAction, - onSuccess = { onBackClick() }, - onErrorDismiss = { }, - confirmationDialog = { - SaveChangesDialog( - onSubmitClick = { state.eventSink(SecurityAndPrivacyEvents.Exit) }, - onDismiss = { state.eventSink(SecurityAndPrivacyEvents.DismissExitConfirmation) } - ) - }, + onRetry = { state.eventSink(SecurityAndPrivacyEvent.Save) }, ) } @@ -177,6 +178,7 @@ private fun SecurityAndPrivacyToolbar( private fun SecurityAndPrivacySection( title: String, modifier: Modifier = Modifier, + subtitle: AnnotatedString? = null, content: @Composable ColumnScope.() -> Unit, ) { Column( @@ -188,18 +190,42 @@ private fun SecurityAndPrivacySection( color = ElementTheme.colors.textPrimary, modifier = Modifier.padding(horizontal = 16.dp), ) + if (subtitle != null) { + Spacer(Modifier.height(8.dp)) + Text( + text = subtitle, + style = ElementTheme.typography.fontBodyMdRegular, + color = ElementTheme.colors.textSecondary, + modifier = Modifier.padding(horizontal = 16.dp), + ) + } content() } } @Composable private fun RoomAccessSection( - edited: SecurityAndPrivacyRoomAccess, - saved: SecurityAndPrivacyRoomAccess, - isKnockEnabled: Boolean, - onSelectOption: (SecurityAndPrivacyRoomAccess) -> Unit, + state: SecurityAndPrivacyState, modifier: Modifier = Modifier, ) { + val edited = state.editedSettings.roomAccess + + fun onSelectOption(option: SecurityAndPrivacyRoomAccess) { + state.eventSink(SecurityAndPrivacyEvent.ChangeRoomAccess(option)) + } + + fun onSpaceMemberAccessClick() { + state.eventSink(SecurityAndPrivacyEvent.SelectSpaceMemberAccess) + } + + fun onAskToJoinWithSpaceMembersClick() { + state.eventSink(SecurityAndPrivacyEvent.SelectAskToJoinWithSpaceMembersAccess) + } + + fun onManageSpacesClick() { + state.eventSink(SecurityAndPrivacyEvent.ManageAuthorizedSpaces) + } + SecurityAndPrivacySection( title = stringResource(R.string.screen_security_and_privacy_room_access_section_header), modifier = modifier, @@ -211,29 +237,36 @@ private fun RoomAccessSection( leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Public())), onClick = { onSelectOption(SecurityAndPrivacyRoomAccess.Anyone) }, ) - // Show space member option, but disabled as we don't support this option for now. - if (saved == SecurityAndPrivacyRoomAccess.SpaceMember) { + if (state.showSpaceMemberOption) { ListItem( headlineContent = { Text(text = stringResource(R.string.screen_security_and_privacy_room_access_space_members_option_title)) }, supportingContent = { - Text(text = stringResource(R.string.screen_security_and_privacy_room_access_space_members_option_unavailable_description)) + Text(text = state.spaceMemberDescription()) }, - trailingContent = ListItemContent.RadioButton(selected = edited == SecurityAndPrivacyRoomAccess.SpaceMember, enabled = false), + trailingContent = ListItemContent.RadioButton(selected = state.editedSettings.roomAccess is SecurityAndPrivacyRoomAccess.SpaceMember), leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Space())), - enabled = false, + onClick = ::onSpaceMemberAccessClick, + enabled = state.isSpaceMemberSelectable, ) } - // Show Ask to join option in two cases: - // - the Knock FF is enabled - // - AskToJoin is the current saved value - if (saved == SecurityAndPrivacyRoomAccess.AskToJoin || isKnockEnabled) { + if (state.showAskToJoinOption) { ListItem( headlineContent = { Text(text = stringResource(R.string.screen_security_and_privacy_ask_to_join_option_title)) }, supportingContent = { Text(text = stringResource(R.string.screen_security_and_privacy_ask_to_join_option_description)) }, trailingContent = ListItemContent.RadioButton(selected = edited == SecurityAndPrivacyRoomAccess.AskToJoin), onClick = { onSelectOption(SecurityAndPrivacyRoomAccess.AskToJoin) }, leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.UserAdd())), - enabled = isKnockEnabled, + enabled = state.isAskToJoinSelectable, + ) + } + if (state.showAskToJoinWithSpaceMemberOption) { + ListItem( + headlineContent = { Text(text = stringResource(R.string.screen_security_and_privacy_ask_to_join_option_title)) }, + supportingContent = { Text(text = state.askToJoinWithSpaceMembersDescription()) }, + trailingContent = ListItemContent.RadioButton(selected = edited is SecurityAndPrivacyRoomAccess.AskToJoinWithSpaceMember), + onClick = ::onAskToJoinWithSpaceMembersClick, + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.UserAdd())), + enabled = state.isAskToJoinWithSpaceMembersSelectable, ) } ListItem( @@ -243,6 +276,20 @@ private fun RoomAccessSection( leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Lock())), onClick = { onSelectOption(SecurityAndPrivacyRoomAccess.InviteOnly) }, ) + if (state.showManageSpaceFooter) { + val footerText = stringWithLink( + textRes = R.string.screen_security_and_privacy_room_access_footer, + url = "", + linkTextRes = R.string.screen_security_and_privacy_room_access_footer_manage_spaces_action, + onLinkClick = { onManageSpacesClick() }, + ) + Text( + text = footerText, + style = ElementTheme.typography.fontBodySmRegular, + color = ElementTheme.colors.textSecondary, + modifier = Modifier.padding(bottom = 12.dp, start = 56.dp, end = 24.dp) + ) + } } } @@ -360,12 +407,18 @@ private fun EncryptionSection( private fun HistoryVisibilitySection( editedOption: SecurityAndPrivacyHistoryVisibility?, savedOptions: SecurityAndPrivacyHistoryVisibility?, - availableOptions: ImmutableSet, + availableOptions: ImmutableList, onSelectOption: (SecurityAndPrivacyHistoryVisibility) -> Unit, + onLinkClick: (String) -> Unit, modifier: Modifier = Modifier, ) { SecurityAndPrivacySection( title = stringResource(R.string.screen_security_and_privacy_room_history_section_header), + subtitle = stringWithLink( + textRes = R.string.screen_security_and_privacy_room_history_section_footer, + url = LearnMoreConfig.HISTORY_VISIBLE_URL, + onLinkClick = onLinkClick, + ), modifier = modifier, ) { for (availableOption in availableOptions) { @@ -397,9 +450,9 @@ private fun HistoryVisibilityItem( isEnabled: Boolean = true, ) { val headlineText = when (option) { - SecurityAndPrivacyHistoryVisibility.SinceSelection -> stringResource(R.string.screen_security_and_privacy_room_history_since_selecting_option_title) - SecurityAndPrivacyHistoryVisibility.SinceInvite -> stringResource(R.string.screen_security_and_privacy_room_history_since_invite_option_title) - SecurityAndPrivacyHistoryVisibility.Anyone -> stringResource(R.string.screen_security_and_privacy_room_history_anyone_option_title) + SecurityAndPrivacyHistoryVisibility.Invited -> stringResource(R.string.screen_security_and_privacy_room_history_since_invite_option_title) + SecurityAndPrivacyHistoryVisibility.Shared -> stringResource(R.string.screen_security_and_privacy_room_history_since_selecting_option_title) + SecurityAndPrivacyHistoryVisibility.WorldReadable -> stringResource(R.string.screen_security_and_privacy_room_history_anyone_option_title) } ListItem( headlineContent = { Text(text = headlineText) }, @@ -425,6 +478,6 @@ internal fun SecurityAndPrivacyViewDarkPreview(@PreviewParameter(SecurityAndPriv private fun ContentToPreview(state: SecurityAndPrivacyState) { SecurityAndPrivacyView( state = state, - onBackClick = {}, + onLinkClick = {}, ) } diff --git a/features/securityandprivacy/impl/src/main/res/values-be/translations.xml b/features/securityandprivacy/impl/src/main/res/values-be/translations.xml deleted file mode 100644 index a1e17451009..00000000000 --- a/features/securityandprivacy/impl/src/main/res/values-be/translations.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - "Папрасіце далучыцца" - "Хто заўгодна" - "Хто заўгодна" - diff --git a/features/securityandprivacy/impl/src/main/res/values-bg/translations.xml b/features/securityandprivacy/impl/src/main/res/values-bg/translations.xml index 9917a7146f1..72586962606 100644 --- a/features/securityandprivacy/impl/src/main/res/values-bg/translations.xml +++ b/features/securityandprivacy/impl/src/main/res/values-bg/translations.xml @@ -7,14 +7,12 @@ "Шифроване" "Включване на шифроване от край до край" "Всеки може да намери и да се присъедини" - "Всеки" "Хората могат да се присъединят само ако са поканени" "Само с покана" "Достъп до стаята" "Членове на пространството" "Пространствата в момента не се поддържат" "Видима в директорията на обществените стаи" - "Всеки" "Кой може да чете историята" "Само за членове откакто са поканени" "Само за членове от избирането на тази опция" 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 a70ccdbf721..71480958c65 100644 --- a/features/securityandprivacy/impl/src/main/res/values-cs/translations.xml +++ b/features/securityandprivacy/impl/src/main/res/values-cs/translations.xml @@ -2,9 +2,16 @@ "Budete potřebovat adresu místnosti, aby byla viditelná v adresáři místností." "Upravit adresu" + "Prostory, kam se členové mohou připojit bez pozvánky." + "Spravovat prostory" + "(Neznámý prostor)" + "Další prostory, jejichž nejste členem" + "Vaše prostory" "Přidat adresu" + "Připojit se může kdokoli v autorizovaných prostorách, ale všichni ostatní musí o přístup požádat." "Všichni musí požádat o přístup." - "Požádat o připojení" + "Požádat o vstup" + "Kdokoli v %1$s se může připojit, ale všichni ostatní musí o přístup požádat." "Ano, povolit šifrování" "Po aktivaci nelze šifrování místnosti deaktivovat. Historie zpráv bude viditelná pouze pro členy místnosti od doby, kdy byli pozváni nebo od té doby, co do místnosti vstoupili. Nikdo kromě členů místnosti nebude moci číst zprávy. To může bránit správnému fungování robotů a propojení. @@ -15,7 +22,8 @@ Nedoporučujeme povolovat šifrování pro místnosti, které může kdokoli naj "Povolit koncové šifrování" "Vstoupit může kdokoli." "Kdokoliv" - "Vyberte, kteří členové prostorů se k této místnosti mohou připojit bez pozvánky. %1$s" + "Vyberte, kteří členové prostorů mohou vstoupit do této místnosti bez pozvánky. %1$s" + "Spravovat prostory" "Vstoupit mohou pouze pozvaní lidé." "Pouze pro zvané" "Přístup" @@ -28,10 +36,11 @@ Nedoporučujeme povolovat šifrování pro místnosti, které může kdokoli naj "Umožněte nalezení této místnosti prohledáním adresáře veřejných místností na %1$s" "Umožnit nalezení vyhledáváním ve veřejném adresáři." "Viditelné ve veřejném adresáři" - "Kdokoliv" + "Kdokoli (historie je veřejná)" + "Změny neovlivní starší zprávy, pouze nové. %1$s" "Kdo může číst historii" - "Pouze členové od té doby, co byli pozváni" - "Pouze členové od výběru této možnosti" + "Členové od pozvání" + "Členové (úplná historie)" "Adresy místností představují způsoby, jak najít místnosti a získat k nim přístup. Díky tomu můžete svoji místnost snadno sdílet s ostatními. Můžete se rozhodnout publikovat svou místnost ve veřejném adresáři místnosti vašeho domovského serveru." "Publikování místnosti" diff --git a/features/securityandprivacy/impl/src/main/res/values-da/translations.xml b/features/securityandprivacy/impl/src/main/res/values-da/translations.xml index 46a20980bda..2758e944b65 100644 --- a/features/securityandprivacy/impl/src/main/res/values-da/translations.xml +++ b/features/securityandprivacy/impl/src/main/res/values-da/translations.xml @@ -1,10 +1,9 @@ - "Du skal bruge en rum-adresse for at gøre den synlig i kataloget." - "Rummets adresse" - "Tilføj adresse på rum" - "Alle kan bede om at deltage i lokalet, men en administrator eller moderator skal acceptere anmodningen." - "Spørg om at deltage" + "Du skal bruge en adresse for at gøre det synligt i det offentlige register." + "Redigér adresse" + "Tilføj adresse" + "Alle skal anmode om adgang." "Ja, aktivér kryptering" "Når det først er aktiveret, kan kryptering for et rum ikke deaktiveres igen. Beskedhistorik vil kun være synlig for rummedlemmer, siden de blev inviteret, eller siden de blev medlem af rummet. Ingen udover medlemmer af rummet vil være i stand til at læse beskeder. Dette kan forhindre bots og broer i at fungere korrekt. @@ -13,22 +12,23 @@ Vi anbefaler ikke at aktivere kryptering for rum, som alle kan finde og deltage "Når kryptering først er aktiveret, kan den ikke deaktiveres igen." "Kryptering" "Aktivér end-to-end-kryptering" - "Alle kan finde og deltage" - "Enhver" - "Andre kan kun deltage, hvis de bliver inviteret" - "Kun med invitation" - "Adgang til rummet" + "Alle kan være med." + "Kun inviterede personer kan deltage i dette rum." + "Kun inviterede" + "Adgang" "Medlemmer af gruppen" "Grupper understøttes ikke i øjeblikket" - "Du skal bruge en rum-adresse for at gøre den synlig i kataloget." + "Du skal bruge en adresse for at gøre det synligt i det offentlige register." + "Adresse" "Tillad, at dette rum kan findes ved at søge i %1$s fortegnelse over offentlige rum" - "Synlig i det offentlige register over rum" - "Enhver" + "Gør det muligt at blive fundet ved søgninger i det offentlige register." + "Synlig i det offentlige register" "Hvem kan læse historikken?" "Kun medlemmer, efter de blev inviteret" "Kun medlemmer siden valg af denne mulighed" "Rum-adresser er en måde at finde og få adgang til værelser på. Dette sikrer også, at du nemt kan dele dit rum med andre. Du kan vælge at offentliggøre dit rum i din hjemmeservers offentlige katalog over rum." "Udgivelse af rum" + "Synlighed" "Sikkerhed og privatliv" diff --git a/features/securityandprivacy/impl/src/main/res/values-de/translations.xml b/features/securityandprivacy/impl/src/main/res/values-de/translations.xml index c37481560d2..82322e12fc4 100644 --- a/features/securityandprivacy/impl/src/main/res/values-de/translations.xml +++ b/features/securityandprivacy/impl/src/main/res/values-de/translations.xml @@ -1,10 +1,17 @@ - "Du benötigst eine Chat-Adresse, um den Chat im Verzeichnis sichtbar zu machen." - "Chat-Adresse" + "Du benötigst eine Chat-Adresse, um den Chat im öffentlichen Verzeichnis sichtbar zu machen." + "Chat-Adresse bearbeiten" + "Spaces, deren Mitglieder der Gruppe ohne Einladung beitreten können." + "Spaces verwalten" + "(Unbekannter Space)" + "Andere Spaces, in denen du kein Mitglied bist" + "Deine Spaces" "Chat-Adresse hinzufügen" - "Jeder kann den Beitritt zum Chat anfragen, aber ein Admin oder Moderator müssen die Anfrage akzeptieren." - "Beitritt beantragen" + "Jedes Mitglied eines autorisierten Space kann beitreten, aber alle anderen müssen einen Beitritt anfragen." + "Zugang nur auf Anfrage." + "Bitte um Beitritt" + "Jeder in %1$s kann beitreten, aber alle anderen müssen den Beitritt anfragen." "Ja, Verschlüsselung aktivieren" "Einmal angeschaltet kann die Verschlüsselung für einen Chat nicht mehr deaktiviert werden. Der Nachrichtenverlauf ist für Mitglieder nur sichtbar, seit sie eingeladen wurden oder dem Chat beigetreten sind. Niemand außer den Chat Mitgliedern kann Nachrichten lesen. Dies kann verhindern, dass Bots und Bridges richtig funktionieren. @@ -13,24 +20,31 @@ Wir empfehlen keine Verschlüsselung für Chats zu aktivieren, die jeder finden "Einmal angeschaltet kann die Verschlüsselung nicht mehr deaktiviert werden." "Verschlüsselung" "Ende-zu-Ende-Verschlüsselung aktivieren" - "Jeder kann diesen Chat finden und ihm beitreten" + "Jeder kann beitreten." "Jeder" - "Personen können nur beitreten, wenn sie eingeladen werden." + "Wähle aus, welche Spaces ihren Mitgliedern ermöglichen sollen, dieser Gruppe ohne Einladung beitreten zu können. %1$s" + "Spaces verwalten" + "Nur eingeladene Personen können beitreten" "Nur auf Einladung" - "Chat Zugang" + "Zugang" + "Jeder in autorisierten Spaces kann beitreten." + "Jeder in %1$s kann beitreten." "Spacemitglieder" "Spaces werden zur Zeit nicht unterstützt." - "Du benötigst eine Chat-Adresse, um den Chat im Verzeichnis sichtbar zu machen." - "Chatroomadresse" + "Du benötigst eine Chat-Adresse, um den Chat im öffentlichen Verzeichnis sichtbar zu machen." + "Adresse" "Erlaube das Auffinden dieses Chats durch Suche im öffentlichen Verzeichnis von %1$s" + "Lass dich über die Suche im öffentlichen Verzeichnis finden." "Sichtbar im öffentlichen Verzeichnis" - "Jeder" + "Jeder (Nachrichtenverlauf ist öffentlich)" + "Änderungen wirken sich nicht auf alte Nachrichten aus, sondern nur auf neue. %1$s" "Wer hat Zugriff auf den Nachrichtenverlauf" "Nur Mitglieder, aber erst seit deren Einladung" - "Nur Mitglieder seit Auswahl dieser Option" + "Mitglieder (voller Nachrichtenverlauf)" "Chat-Adressen machen es möglich, Chats zu finden und ihnen beizutreten. Dies erleichtert es, Chats mit anderen zu teilen. Auf Wunsch kannst du deinen Chat im öffentlichen Verzeichnis deines Homeservers veröffentlichen." "Veröffentlichung von Chats" - "Chatroomsichtbarkeit." + "Adressen ermöglichen es, Gruppen und Spaces zu finden und zu betreten. Dadurch wird auch sichergestellt, dass diese problemlos mit anderen geteilt werden können." + "Sichtbarkeit" "Sicherheit & Datenschutz" 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 1946bae8b25..c9a65b058dc 100644 --- a/features/securityandprivacy/impl/src/main/res/values-el/translations.xml +++ b/features/securityandprivacy/impl/src/main/res/values-el/translations.xml @@ -23,7 +23,6 @@ "Θα χρειαστείτε μια διεύθυνση αίθουσας για να την κάνετε ορατή στον κατάλογο." "Επιστρέψτε την εύρεση αυτής της αίθουσας με αναζήτηση στον κατάλογο %1$s δημοσίων αιθουσών" "Ορατή στον κατάλογο δημόσιων αιθουσών" - "Οποιοσδήποτε" "Ποιος μπορεί να διαβάσει το ιστορικό" "Μόνο μέλη από τη στιγμή που προσκλήθηκαν" "Μόνο για μέλη μετά από αυτήν την επιλογή" diff --git a/features/securityandprivacy/impl/src/main/res/values-en-rUS/translations.xml b/features/securityandprivacy/impl/src/main/res/values-en-rUS/translations.xml new file mode 100644 index 00000000000..9a4b56e2873 --- /dev/null +++ b/features/securityandprivacy/impl/src/main/res/values-en-rUS/translations.xml @@ -0,0 +1,5 @@ + + + "Anyone in authorized spaces can join, but everyone else must request access." + "Anyone in authorized spaces can join." + diff --git a/features/securityandprivacy/impl/src/main/res/values-es/translations.xml b/features/securityandprivacy/impl/src/main/res/values-es/translations.xml index 2799d9d196f..97b138c75f7 100644 --- a/features/securityandprivacy/impl/src/main/res/values-es/translations.xml +++ b/features/securityandprivacy/impl/src/main/res/values-es/translations.xml @@ -4,7 +4,6 @@ "Dirección de la sala" "Agregar dirección de sala" "Cualquiera puede solicitar unirse a la sala, pero un administrador o moderador tendrá que aceptar la solicitud." - "Solicitud para unirse" "Sí, activar cifrado" "Una vez activado, el cifrado de una sala no se puede desactivar. El historial de mensajes solo será visible para los miembros de la sala desde que fueron invitados o desde que se unieron a la sala. Nadie más que los miembros de la sala podrán leer los mensajes. Esto puede impedir que los bots y los puentes funcionen correctamente. @@ -14,7 +13,6 @@ No recomendamos habilitar el cifrado para las salas que cualquiera pueda encontr "Cifrado" "Activar el cifrado de extremo a extremo" "Cualquiera puede encontrarla y unirse" - "Cualquiera" "Las personas solo pueden unirse si están invitadas" "Solo por invitación" "Acceso a la sala" @@ -23,7 +21,6 @@ No recomendamos habilitar el cifrado para las salas que cualquiera pueda encontr "Necesitarás una dirección de sala para que sea visible en el directorio." "Permite encontrar esta sala buscando en el directorio de salas públicas de %1$s" "Visible en el directorio de salas públicas" - "Cualquiera" "Quién puede leer el historial" "Solo participantes desde que fueron invitados" "Solo participantes desde que se selecciona esta opción" diff --git a/features/securityandprivacy/impl/src/main/res/values-et/translations.xml b/features/securityandprivacy/impl/src/main/res/values-et/translations.xml index 7e6cdec9c6b..a907f0bb168 100644 --- a/features/securityandprivacy/impl/src/main/res/values-et/translations.xml +++ b/features/securityandprivacy/impl/src/main/res/values-et/translations.xml @@ -2,9 +2,16 @@ "Selleks, et jututuba oleks nähtav jututubade avalikus kataloogis, vajab ta aadressi." "Muuda aadressi" + "Kogukonnad, milles on võimalik jututoaga liituda ilma kutseta." + "Halda kogukondi" + "(Tundmatu kogukond)" + "Muud kogukonnad, mille liige sa ei ole" + "Sinu kogukonnad" "Lisa aadress" + "Liituda saavad kõik volitatud kogukondade liikmed, kuid kõik teised peavad küsima võimalust ligipääsuks." "Kõik võivad paluda jututoaga liitumist." - "Küsi võimalust liitumiseks" + "Palu võimalust liituda" + "Liituda saavad kõik „%1$s“ kogukonna liikmed, kuid kõik teised peavad küsima võimalust ligipääsuks." "Jah, lülita krüptimine sisse" "Kui jututoa krüptimine on kord sisse lülitatud, siis seda välja lülitada ei saa. Sõnumite ajalugu on nähtav vaid jututoa liikmetele alates kutse saamise või liitumise hetkest. Keegi teine peale jututoa liikmete ei saa sõnumeid lugeda. See võib takistada suhtlusrobotite ja/või võrgusildade toimimist. @@ -14,10 +21,14 @@ Me ei soovita krüptimise kasutamist selliste avalike jututubade puhul, millega "Krüptimine" "Võta läbiv krüptimine kasutusele" "Kõik võivad jututoaga liituda" - "Kõik" + "Avalik" + "Vali kogukonnad, mille liikmed saavad selle jututoaga liituda ilma kutseta. %1$s" + "Halda kogukondi" "Liituda saab vaid kutse olemasolul" "Vaid kutsega" "Ligipääs" + "Liituda saavad kõik volitatud kogukondade liikmed." + "Liituda võivad kõik „%1$s“ liikmed." "Kogukonna liikmed" "Kogukondade tugi veel puudub" "Selleks, et jututuba oleks nähtav jututubade avalikus kataloogis, vajab ta aadressi." @@ -25,13 +36,15 @@ Me ei soovita krüptimise kasutamist selliste avalike jututubade puhul, millega "Võimalda leida seda jututuba avalikust kataloogist otsides „%1$s“" "Luba leitavus avaliku kataloogi otsingust." "Nähtav avalikus kataloogis" - "Kõik" + "Kõik (ajalugu on avalik)" + "Muudatused ei mõjuta varasemaid sõnumeid, ainult uusi. %1$s" "Kes võivad lugeda jututoa ajalugu" "Liikmed peale kutse saamist" - "Liikmed peale selle valiku sisselülitamist" + "Liikmed (terviklik ajalugu)" "Jututoa aadressid annavad võimaluse neid leida ning saada neile ligi. Samuti võimaldab see jututuba teistele huvilistele jagada. Lisaks võid sa jututoa avaldada oma koduserveri avalikus jututubade kataloogis." "Jututoa avaldamine" + "Aadressid on mugav viis jututubade ja kogukondade leidmiseks ning tagab mugava võimaluse nende jagamiseks." "Nähtavus" "Turvalisus ja privaatsus" diff --git a/features/securityandprivacy/impl/src/main/res/values-eu/translations.xml b/features/securityandprivacy/impl/src/main/res/values-eu/translations.xml index c66b6769cee..f9f3514dbd1 100644 --- a/features/securityandprivacy/impl/src/main/res/values-eu/translations.xml +++ b/features/securityandprivacy/impl/src/main/res/values-eu/translations.xml @@ -5,7 +5,6 @@ "Bai, gaitu zifratzea" "Zifratzea" "Edonork aurkitu eta bat egin dezake" - "Edonork" "Gonbidatutako pertsonak bakarrik sartu ahal izango dira" "Gonbidapen bidez" "Gelarako sarbidea" @@ -13,7 +12,6 @@ "Gaur-gaurkoz ez da guneekin bateragarria" "Gelaren helbidea" "Gela publikoen direktorioan ikusgai" - "Edonork" "Nork irakur dezake historia" "Kideek bakarrik, gonbidatu zituztenetik" "Kideek bakarrik, aukera hau hautatu zenetik" diff --git a/features/securityandprivacy/impl/src/main/res/values-fi/translations.xml b/features/securityandprivacy/impl/src/main/res/values-fi/translations.xml index 0f42d87675b..3a7d5503ac8 100644 --- a/features/securityandprivacy/impl/src/main/res/values-fi/translations.xml +++ b/features/securityandprivacy/impl/src/main/res/values-fi/translations.xml @@ -28,7 +28,7 @@ Emme suosittele salauksen ottamista käyttöön huoneissa, jotka kuka tahansa vo "Kuka tahansa" "Kuka voi lukea viestihistoriaa" "Jäsenet vasta kutsusta lähtien" - "Jäsenet tämän vaihtoehdon valinnan jälkeen" + "Jäsenet (koko historia)" "Huoneosoitteet ovat tapoja löytää ja käyttää huoneita. Näin voit myös helposti jakaa huoneesi muiden kanssa. Voit halutessasi julkaista huoneesi kotipalvelimesi julkisessa huonehakemistossa." "Huoneen julkaiseminen" diff --git a/features/securityandprivacy/impl/src/main/res/values-fr/translations.xml b/features/securityandprivacy/impl/src/main/res/values-fr/translations.xml index 754ef4cd1ae..60aa211ef15 100644 --- a/features/securityandprivacy/impl/src/main/res/values-fr/translations.xml +++ b/features/securityandprivacy/impl/src/main/res/values-fr/translations.xml @@ -2,9 +2,16 @@ "Vous aurez besoin d’une adresse pour le rendre visible dans l’annuaire public." "Modifier l’adresse" + "Espaces où les membres peuvent rejoindre le salon sans invitation." + "Gérer les espaces" + "(Espace inconnu)" + "Autres espaces dont vous n’êtes pas membre" + "Vos espaces" "Ajouter une adresse" + "Toute personne se trouvant dans un espace autorisé peut participer, mais toutes les autres doivent demander l’accès." "Tout le monde doit demander un accès." "Demander à rejoindre" + "Tout membre de %1$s peut rejoindre l’espace, mais les autres doivent demander un accès." "Oui, activer le chiffrement" "Une fois activé, le chiffrement d’un salon ne peut pas être désactivé. L’historique des messages ne sera visible que pour les membres depuis qu’ils ont été invités ou depuis qu’ils ont rejoint le salon. Personne d’autre que les membres du salon ne pourra lire les messages. Cela peut empêcher les bots et les bridges de fonctionner correctement. @@ -15,9 +22,13 @@ Nous ne recommandons pas d’activer le chiffrement pour les salons que tout le "Activer le chiffrement de bout en bout" "Tout le monde peut rejoindre." "Tout le monde" + "Choisissez les espaces dont les membres peuvent rejoindre ce salon sans invitation. %1$s" + "Gérer les espaces" "Seules les personnes invitées peuvent rejoindre." "Sur invitation uniquement" "Accès" + "Toute personne se trouvant dans un espace autorisé peut joindre le salon." + "Toute personne de l’espace %1$s peut joindre le salon." "Membres de l’espace" "Les Espaces ne sont pas encore supportés" "Vous aurez besoin d’une adresse pour le rendre visible dans l’annuaire public." @@ -25,10 +36,11 @@ Nous ne recommandons pas d’activer le chiffrement pour les salons que tout le "Autoriser le salon à apparaître dans les résultats de recherche dans le répertoire %1$s des salons publics" "Permet d’être trouvé en recherchant dans l’annuaire public." "Visible dans l’annuaire public" - "Tout le monde" + "Tout le monde (l’historique est public)" + "Les changements n’affecteront pas les anciens messages, seulement les nouveaux. %1$s" "Qui peux lire l’historique" - "Les membres uniquement depuis qu’ils ont été invités" - "Les membres uniquement depuis la sélection de cette option" + "Seulement les membres, depuis leur invitation" + "Membres (historique complet)" "Les adresses de salon sont un moyen de trouver et d’accéder aux salons. Cela vous permet également de partager facilement votre salon avec d’autres personnes. Vous pouvez choisir de publier votre salon dans l’annuaire des salons publics de votre serveur d’accueil." "Publication du salon" diff --git a/features/securityandprivacy/impl/src/main/res/values-hr/translations.xml b/features/securityandprivacy/impl/src/main/res/values-hr/translations.xml new file mode 100644 index 00000000000..8d237c32d26 --- /dev/null +++ b/features/securityandprivacy/impl/src/main/res/values-hr/translations.xml @@ -0,0 +1,48 @@ + + + "Trebat će vam adresa kako bi bila vidljiva u javnom direktoriju." + "Uredi adresu" + "Prostori u kojima se članovi mogu pridružiti sobi bez pozivnice." + "Upravljaj prostorima" + "(nepoznati prostor)" + "Drugi prostori čiji niste član" + "Vaši prostori" + "Dodaj adresu" + "Svatko tko se nalazi u ovlaštenim prostorima može se pridružiti, ali svi ostali moraju zatražiti pristup." + "Svi moraju zatražiti pristup." + "Svatko u %1$s može se pridružiti, ali svi ostali moraju zatražiti pristup." + "Da, omogući šifriranje" + "Nakon što se šifriranje za sobu omogući, više se neće moći onemogućiti. Povijest poruka bit će vidljiva samo članovima sobe otkad su pozvani ili otkad su joj se pridružili. +Nitko osim članova sobe neće moći čitati poruke. Zbog toga botovi i mostovi možda neće ispravno funkcionirati. +Ne preporučujemo omogućavanje šifriranja za sobe koje svatko može pronaći i pridružiti im se." + "Želite li omogućiti šifriranje?" + "Nakon što se šifriranje omogući, više se neće moći onemogućiti." + "Šifriranje" + "Omogući sveobuhvatno šifriranje" + "Svatko se može pridružiti." + "Odaberite iz kojih se prostora članovi mogu pridružiti ovoj sobi bez pozivnice. %1$s" + "Upravljaj prostorima" + "Samo pozvane osobe mogu se pridružiti." + "Samo s pozivnicom" + "Pristup" + "Svatko tko se nalazi u ovlaštenim prostorima može se pridružiti." + "Svatko u %1$s može se pridružiti." + "Članovi prostora" + "Prostori trenutačno nisu podržani" + "Trebat će vam adresa kako bi bila vidljiva u javnom direktoriju." + "Adresa" + "Omogući pronalazak ove sobe pretraživanjem %1$s javnog direktorija soba" + "Omogući pronalazak pretraživanjem javnog direktorija." + "Vidljivo u javnom direktoriju" + "Svatko (povijest je javna)" + "Promjene neće utjecati na prethodne poruke, samo na nove. %1$s" + "Tko zna čitati povijest" + "Samo za članove nakon što su pozvani" + "Članovi (cjelokupna povijest)" + "Adrese soba služe za pronalaženje i pristup sobama. Time se također osigurava jednostavno dijeljenje sobe s drugim korisnicima. +Možete odabrati objavljivanje svoje sobe u javnom direktoriju soba na matičnom poslužitelju." + "Objavljivanje sobe" + "Adrese služe za pronalaženje soba i prostora te pristup njima. Tako ih ujedno možete jednostavno dijeliti s drugima." + "Vidljivost" + "Sigurnost i privatnost" + diff --git a/features/securityandprivacy/impl/src/main/res/values-hu/translations.xml b/features/securityandprivacy/impl/src/main/res/values-hu/translations.xml index 503fdc8efe9..980b55d52fe 100644 --- a/features/securityandprivacy/impl/src/main/res/values-hu/translations.xml +++ b/features/securityandprivacy/impl/src/main/res/values-hu/translations.xml @@ -14,7 +14,7 @@ Nem javasoljuk a titkosítás engedélyezését az olyan szobákban, amelyeket b "Titkosítás" "Végpontok közötti titkosítás engedélyezése" "Bárki csatlakozhat." - "Bárki" + "Nyilvános" "Csak a meghívott emberek léphetnek be." "Csak meghívásos" "Hozzáférés" diff --git a/features/securityandprivacy/impl/src/main/res/values-in/translations.xml b/features/securityandprivacy/impl/src/main/res/values-in/translations.xml index 17f14057cb7..5b475a41f65 100644 --- a/features/securityandprivacy/impl/src/main/res/values-in/translations.xml +++ b/features/securityandprivacy/impl/src/main/res/values-in/translations.xml @@ -4,7 +4,6 @@ "Alamat ruangan" "Tambahkan alamat ruangan" "Siapa pun dapat meminta untuk bergabung dengan ruangan tetapi administrator atau moderator harus menerima permintaan tersebut." - "Minta untuk bergabung" "Ya, aktifkan enkripsi" "Setelah diaktifkan, encryption untuk sebuah ruangan tidak dapat dinonaktifkan, Riwayat pesan hanya akan terlihat oleh anggota ruangan sejak mereka diundang atau sejak mereka bergabung dengan ruangan tersebut. Tidak ada orang lain selain anggota ruangan yang dapat membaca pesan. Hal ini dapat mencegah bot dan jembatan bekerja dengan benar. @@ -14,7 +13,6 @@ Kami tidak menyarankan untuk mengaktifkan enkripsi untuk ruangan yang dapat dite "Enkripsi" "Aktifkan enkripsi ujung ke ujung" "Siapa pun dapat menemukan dan bergabung" - "Siapa pun" "Orang hanya dapat bergabung jika mereka diundang" "Hanya undangan" "Akses ruangan" @@ -23,7 +21,6 @@ Kami tidak menyarankan untuk mengaktifkan enkripsi untuk ruangan yang dapat dite "Anda akan memerlukan alamat ruangan untuk membuatnya terlihat dalam direktori." "Izinkan ruangan ini ditemukan dengan mencari direktori ruangan %1$s publik" "Terlihat di direktori ruangan publik" - "Siapa pun" "Siapa yang bisa membaca riwayat" "Hanya anggota sejak mereka diundang" "Hanya anggota sejak memilih opsi ini" diff --git a/features/securityandprivacy/impl/src/main/res/values-ko/translations.xml b/features/securityandprivacy/impl/src/main/res/values-ko/translations.xml index 8afa1987eb1..98e9f002ac5 100644 --- a/features/securityandprivacy/impl/src/main/res/values-ko/translations.xml +++ b/features/securityandprivacy/impl/src/main/res/values-ko/translations.xml @@ -4,7 +4,6 @@ "방 주소" "방 주소 추가" "누구나 방에 참여 요청을 할 수 있지만, 관리자나 운영자가 요청을 수락해야 합니다." - "참가 요청" "예, 암호화 활성화" "일단 활성화되면, 방의 암호화는 비활성화할 수 없습니다. 메시지 기록은 방에 초대된 후 또는 방에 참여한 이후부터 방 구성원만 볼 수 있습니다. 방 구성원 외에는 아무도 메시지를 읽을 수 없습니다. 이로 인해 봇과 브리지가 제대로 작동하지 않을 수 있습니다. @@ -14,7 +13,6 @@ "암호화" "종단간 암호화 활성화" "누구나 찾을 수 있고 참여할 수 있습니다." - "누구나" "초대받은 사용자만 가입할 수 있습니다." "초대 전용" "방 액세스" @@ -23,7 +21,6 @@ "디렉토리에 표시하려면 방 주소가 필요합니다." "%1$s 공개 방 디렉토리에서 이 방을 검색할 수 있도록 허용합니다" "공개 룸 디렉토리에 표시됨" - "누구나" "누가 기록을 읽을 수 있는가" "초대받은 회원만 이용 가능합니다" "이 옵션을 선택한 회원만 이용 가능합니다." diff --git a/features/securityandprivacy/impl/src/main/res/values-nb/translations.xml b/features/securityandprivacy/impl/src/main/res/values-nb/translations.xml index 2d0f8db8a57..8dd32205653 100644 --- a/features/securityandprivacy/impl/src/main/res/values-nb/translations.xml +++ b/features/securityandprivacy/impl/src/main/res/values-nb/translations.xml @@ -1,10 +1,9 @@ - "Du trenger en adresse til rommet for å gjøre det synlig i katalogen." - "Romadresse" - "Legg til romadresse" - "Alle kan be om å bli med i rommet, men en administrator eller moderator må godta forespørselen." - "Be om å bli med" + "Du trenger en adresse for å gjøre den synlig i den offentlige katalogen." + "Rediger adresse" + "Legg til adresse" + "Alle må be om tilgang." "Ja, aktiver kryptering" "Når kryptering for et rom er aktivert, kan den ikke deaktiveres. Meldingshistorikken vil bare være synlig for rommedlemmer siden de ble invitert eller siden de ble med i rommet. Ingen andre enn rommedlemmene vil kunne lese meldingene. Dette kan føre til at bots og broer ikke fungerer som de skal. @@ -13,22 +12,23 @@ Vi anbefaler ikke å aktivere kryptering for rom som hvem som helst kan finne og "Når kryptering er aktivert, kan det ikke deaktiveres." "Kryptering" "Aktiver ende-til-ende-kryptering" - "Alle kan finne og bli med" - "Alle" - "Folk kan bare bli med hvis de er invitert" + "Alle kan bli med." + "Bare inviterte personer kan bli med." "Kun for inviterte" - "Tilgang til rom" + "Tilgang" "Medlemmer av område" "Områder støttes ikke for øyeblikket" - "Du trenger en adresse til rommet for å gjøre det synlig i katalogen." + "Du trenger en adresse for å gjøre den synlig i den offentlige katalogen." + "Adresse" "Tillat at dette rommet blir funnet ved å søke %1$s offentlig romkatalog" - "Synlig i offentlig romkatalog" - "Alle" + "Synlig i offentlig katalog" + "Alle (historikken er offentlig)" "Hvem kan lese historikk" - "Medlemmer bare siden de ble invitert" - "Kun medlemmer siden du valgte dette alternativet" + "Medlemmer siden de ble invitert" + "Medlemmer (full historikk)" "Romadresser er måter å finne og få tilgang til rom på. Dette sikrer også at du enkelt kan dele rommet ditt med andre. Du kan velge å publisere rommet ditt i hjemeserverens offentlige romkatalog." "Publisering av rom" + "Synlighet" "Sikkerhet og personvern" diff --git a/features/securityandprivacy/impl/src/main/res/values-nl/translations.xml b/features/securityandprivacy/impl/src/main/res/values-nl/translations.xml deleted file mode 100644 index 5fdc9466b5d..00000000000 --- a/features/securityandprivacy/impl/src/main/res/values-nl/translations.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - "Vraag om toe te treden" - "Iedereen" - "Iedereen" - diff --git a/features/securityandprivacy/impl/src/main/res/values-pl/translations.xml b/features/securityandprivacy/impl/src/main/res/values-pl/translations.xml index 11b8189cc4c..c85ec8eae42 100644 --- a/features/securityandprivacy/impl/src/main/res/values-pl/translations.xml +++ b/features/securityandprivacy/impl/src/main/res/values-pl/translations.xml @@ -14,7 +14,7 @@ Odradzamy włączanie szyfrowania dla pokoi, które każdy może znaleźć i do "Szyfrowanie" "Włącz szyfrowanie end-to-end" "Każdy może znaleźć i dołączyć" - "Wszyscy" + "Każdy" "Tylko osoby z zaproszeniem mogą dołączyć" "Tylko zaproszenie" "Dostęp do pokoju" @@ -24,7 +24,7 @@ Odradzamy włączanie szyfrowania dla pokoi, które każdy może znaleźć i do "Adres pokoju" "Zezwól na znalezienie tego pokoju wyszukując %1$s w katalogu pokoi publicznych" "Widoczny w katalogu pokoi publicznych" - "Wszyscy" + "Ktokolwiek" "Kto może czytać historię" "Od momentu kiedy członkowie zostali zaproszeni" "Członkowie od momentu włączenia tej opcji" diff --git a/features/securityandprivacy/impl/src/main/res/values-pt-rBR/translations.xml b/features/securityandprivacy/impl/src/main/res/values-pt-rBR/translations.xml index 584e94dc20c..26d86601807 100644 --- a/features/securityandprivacy/impl/src/main/res/values-pt-rBR/translations.xml +++ b/features/securityandprivacy/impl/src/main/res/values-pt-rBR/translations.xml @@ -2,9 +2,15 @@ "Você precisará de um endereço para torná-la visível no diretório." "Editar endereço" + "Os espaços dos quais os membros podem entrar na sala sem um convite." + "Gerenciar espaços" + "(Espaço desconhecido)" + "Outros espaços dos quais você não é um membro" + "Seus espaços" "Adicionar endereço" + "Qualquer um nos espaços autorizados podem entrar, mas todos os outros devem pedir acesso." "Qualquer um pode pedir acesso, mas um administrador terá que aceitar o pedido." - "Pedir para entrar" + "Qualquer um em %1$s pode entrar, mas todos os outros devem pedir acesso." "Sim, ativar a criptografia" "Uma vez ativada, a criptografia de uma sala não pode ser desativada. O histórico de mensagens só será visível para os membros da sala desde que foram convidados ou desde que entraram na sala. Ninguém além dos membros da sala poderá ler as mensagens. Isso pode impedir que os bots e as pontes funcionem corretamente. @@ -14,10 +20,13 @@ Não recomendamos que você ative a criptografia para salas que qualquer pessoa "Criptografia" "Ativar a criptografia de ponta a ponta" "Qualquer um pode entrar" - "Qualquer pessoa" + "Escolha os espaços dos quais os membros podem entrar nesta sala sem um convite. %1$s" + "Gerenciar espaços" "Apenas pessoas convidadas podem entrar." "Privado" "Acesso" + "Qualquer um em espaços autorizados podem entrar." + "Qualquer pessoa em %1$s pode participar." "Membros do espaço" "No momento, não há suporte aos espaços" "Você precisará de um endereço para torná-la visível no diretório." @@ -25,10 +34,11 @@ Não recomendamos que você ative a criptografia para salas que qualquer pessoa "Permitir que esta sala seja encontrada pesquisando diretório de salas públicas de %1$s" "Permite que seja encontrada ao buscar no diretório público." "Visível no diretório público" - "Qualquer pessoa" + "Qualquer um (histórico público)" + "As alterações não afetarão mensagens anteriores, somente as novas. %1$s" "Quem pode ler o histórico" - "Somente membros, desde que foram convidados" - "Somente para membros após selecionar esta opção" + "Membros desde o convite" + "Membros (histórico completo)" "Os endereços das salas são formas de encontrar e acessar as salas. Isso também garante que você possa compartilhar facilmente sua sala com outras pessoas. Você pode optar por publicar sua sala no diretório público de salas do seu servidor-casa." "Publicação da sala" diff --git a/features/securityandprivacy/impl/src/main/res/values-pt/translations.xml b/features/securityandprivacy/impl/src/main/res/values-pt/translations.xml index 26585dc4f97..f06e7d89454 100644 --- a/features/securityandprivacy/impl/src/main/res/values-pt/translations.xml +++ b/features/securityandprivacy/impl/src/main/res/values-pt/translations.xml @@ -4,7 +4,7 @@ "Endereço da sala" "Adicionar endereço de sala" "Qualquer pessoa pode pedir para entrar na sala, mas um administrador ou moderador tem que aceitar o pedido." - "Pedir para participar" + "Pedir para entrar" "Sim, ativar cifragem" "Uma vez ativada, a cifragem não pode ser desativada. O histórico de mensagens só será visível a membros a partir do momento em que foram convidados ou que entraram na sala. Ninguém além dos membros poderão ler quaisquer mensagens. Isto pode impedir que robôs (\"bots\") e pontes (\"bridges\") funcionem devidamente. 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 213e35562da..a6c17d76191 100644 --- a/features/securityandprivacy/impl/src/main/res/values-ro/translations.xml +++ b/features/securityandprivacy/impl/src/main/res/values-ro/translations.xml @@ -1,10 +1,16 @@ - "Veți avea nevoie de o adresă de cameră pentru a o face vizibilă în director." - "Adresa camerei" - "Adăugați adresa camerei" - "Oricine poate cere să se alăture camerei, dar un administrator sau moderator va trebui să accepte cererea." - "Cereți să vă alăturați" + "Veți avea nevoie de o adresă pentru a o face vizibilă în directorul public." + "Editați adresa" + "Spațile 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ă" + "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." + "Oricine în %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. @@ -13,22 +19,28 @@ Nu recomandăm activarea criptării pentru camerele pe care oricine le poate gă "Odată activată, criptarea nu poate fi dezactivată." "Criptare" "Activați criptarea end-to-end" - "Oricine poate găsi și alătura camerei" - "Oricine" - "Persoanele se pot alătura numai dacă invitate" + "Oricine se poate alătura." + "Alegeți membrii căror spații 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" - "Acces la cameră" + "Acces" + "Oricine se află într-un spațiu autorizat poate participa." + "Oricine din %1$s se poate alătura." "Membrii spațiului" "Spațiile nu sunt momentan suportate." - "Veți avea nevoie de o adresă de cameră pentru a o face vizibilă în director." + "Veți avea nevoie de o adresă pentru a o face vizibilă în directorul public." + "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" - "Oricine" "Cine poate citi mesajele anterioare" "Doar pentru membri, de la momentul în care au fost invitați" "Doar pentru membri, după selectarea acestei opțiuni" "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ă." "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" diff --git a/features/securityandprivacy/impl/src/main/res/values-ru/translations.xml b/features/securityandprivacy/impl/src/main/res/values-ru/translations.xml index 1a138fbb94b..2fc0101a0c1 100644 --- a/features/securityandprivacy/impl/src/main/res/values-ru/translations.xml +++ b/features/securityandprivacy/impl/src/main/res/values-ru/translations.xml @@ -4,7 +4,7 @@ "Редактировать адрес комнаты" "Добавить адрес" "Каждый должен запросить доступ." - "Попросить присоединиться" + "Присоединиться" "Да, включить шифрование" "Шифрование комнаты нельзя будет отключить, история сообщений будет видна только участникам комнаты с момента их приглашения или с момента присоединения к комнате. Никто, кроме членов комнаты, не сможет читать сообщения. Это может помешать ботам и мостам работать корректно. @@ -14,7 +14,7 @@ "Шифрование" "Включить сквозное шифрование" "Любой желающий может найти и присоединиться" - "Любой" + "Публичный" "Присоединиться могут только приглашенные люди." "Только по приглашению" "Доступ" diff --git a/features/securityandprivacy/impl/src/main/res/values-sk/translations.xml b/features/securityandprivacy/impl/src/main/res/values-sk/translations.xml index a9e6b1dd1bd..3853fc5f8aa 100644 --- a/features/securityandprivacy/impl/src/main/res/values-sk/translations.xml +++ b/features/securityandprivacy/impl/src/main/res/values-sk/translations.xml @@ -1,9 +1,14 @@ - "Budete potrebovať adresu miestnosti, aby bola viditeľná v adresári." - "Adresa miestnosti" - "Pridať adresu miestnosti" - "Ktokoľvek môže požiadať o pripojenie do miestnosti, ale správca alebo moderátor bude musieť žiadosť prijať." + "Budete potrebovať adresu, aby sa zobrazovala vo verejnom adresári." + "Upraviť adresu" + "Priestory, kde sa členovia môžu pripojiť k miestnosti bez pozvania." + "Spravovať priestory" + "(Neznámy priestor)" + "Iné priestory, ktorých nie ste členom" + "Vaše priestory" + "Pridať adresu" + "Všetci musia požiadať o prístup." "Požiadať o pripojenie" "Áno, povoliť šifrovanie" "Po aktivácii nie je možné zakázať šifrovanie pre miestnosť. História správ bude viditeľná len pre členov miestnosti, odkedy boli pozvaní alebo keď vstúpili do miestnosti. @@ -13,17 +18,22 @@ To môže brániť správnemu fungovaniu robotov a premostení. Neodporúčame p "Po zapnutí už šifrovanie nie je možné vypnúť." "Šifrovanie" "Povoliť end-to-end šifrovanie" - "Ktokoľvek môže nájsť a pripojiť sa" + "Pripojiť sa môže ktokoľvek." "Ktokoľvek" - "Ľudia sa môžu pripojiť len vtedy, ak sú pozvaní" + "Vyberte, ktorých členovia priestorov sa môžu pripojiť k tejto miestnosti bez pozvánky. %1$s" + "Spravovať priestory" + "Pripojiť sa môžu iba pozvaní ľudia." "Iba na pozvánku" - "Prístup do miestnosti" + "Prístup" + "Ktokoľvek v povolených priestoroch sa môže pripojiť." + "Ktokoľvek v %1$s sa môže pripojiť." "Členovia priestoru" "Priestory momentálne nie sú podporované" - "Budete potrebovať adresu miestnosti, aby bola viditeľná v adresári." - "Adresa miestnosti" + "Budete potrebovať adresu, aby sa zobrazovala vo verejnom adresári." + "Adresa" "Umožniť vyhľadanie tejto miestnosti v adresári verejných miestností %1$s" - "Viditeľné v adresári verejných miestností" + "Umožniť nájdenie vyhľadávaním vo verejnom adresári." + "Viditeľné vo verejnom adresári" "Ktokoľvek" "Kto môže čítať históriu" "Len pre členov, odkedy boli pozvaní" @@ -31,6 +41,7 @@ To môže brániť správnemu fungovaniu robotov a premostení. Neodporúčame p "Adresy miestností predstavujú spôsoby, ako nájsť a získať prístup k miestnostiam. To tiež zaisťuje, že môžete jednoducho zdieľať svoju miestnosť s ostatnými. Môžete sa rozhodnúť zverejniť svoju miestnosť v adresári verejných miestností vášho domovského servera." "Zverejnenie miestnosti" - "Viditeľnosť miestnosti" + "Adresy sú spôsob, ako nájsť a získať prístup do miestností a priestorov. To tiež zabezpečuje, že ich môžete jednoducho zdieľať s ostatnými." + "Viditeľnosť" "Bezpečnosť a súkromie" diff --git a/features/securityandprivacy/impl/src/main/res/values-sv/translations.xml b/features/securityandprivacy/impl/src/main/res/values-sv/translations.xml index 4fbfe47ba70..c77201aa5b5 100644 --- a/features/securityandprivacy/impl/src/main/res/values-sv/translations.xml +++ b/features/securityandprivacy/impl/src/main/res/values-sv/translations.xml @@ -23,7 +23,6 @@ Vi rekommenderar inte att aktivera kryptering för rum som vem som helst kan hit "Du behöver en rumsadress för att göra den synlig i katalogen." "Tillåt att detta rum hittas genom att söka i den offentliga rumskatalogen på %1$s" "Synlig i katalogen för offentliga rum" - "Vem som helst" "Vem kan läsa historik" "Endast medlemmar sedan de bjöds in" "Endast medlemmar sedan det här alternativet har valts" diff --git a/features/securityandprivacy/impl/src/main/res/values-tr/translations.xml b/features/securityandprivacy/impl/src/main/res/values-tr/translations.xml index 940fdc9a919..8dec5001e0f 100644 --- a/features/securityandprivacy/impl/src/main/res/values-tr/translations.xml +++ b/features/securityandprivacy/impl/src/main/res/values-tr/translations.xml @@ -4,7 +4,7 @@ "Oda adresi" "Oda adresi ekle" "Herkes odaya katılma isteğinde bulunabilir ancak bir yönetici veya moderatörün isteği kabul etmesi gerekir." - "Katılmak için sor" + "Katılma isteği gönder" "Evet, şifrelemeyi etkinleştir" "Etkinleştirildikten sonra, bir oda için şifreleme devre dışı bırakılamaz, Mesaj geçmişi yalnızca davet edildiklerinden veya odaya katıldıklarından beri oda üyeleri için görünür olacaktır. Oda üyeleri dışında hiç kimse mesajları okuyamayacaktır. Bu, botların ve köprülerin düzgün çalışmasını engelleyebilir. diff --git a/features/securityandprivacy/impl/src/main/res/values-uk/translations.xml b/features/securityandprivacy/impl/src/main/res/values-uk/translations.xml index 55c7d4fda74..7d48ca3acd1 100644 --- a/features/securityandprivacy/impl/src/main/res/values-uk/translations.xml +++ b/features/securityandprivacy/impl/src/main/res/values-uk/translations.xml @@ -4,7 +4,7 @@ "Адреса кімнати" "Додати адресу кімнати" "Будь-хто може надіслати запит приєднатися до кімнати, але адміністратор або модератор повинні прийняти запит." - "Запросити приєднатися" + "Запит на приєднання" "Так, увімкнути шифрування" "Після ввімкнення шифрування кімнати, його неможливо вимкнути, історію повідомлень бачитимуть лише учасники кімнати, яких було запрошено або які приєдналися до кімнати. Ніхто, крім учасників кімнати, не зможе прочитати повідомлення. Це може перешкоджати коректній роботі ботів і мостів. @@ -14,7 +14,7 @@ "Шифрування" "Увімкнути наскрізне шифрування" "Будь-хто може знайти та приєднатися." - "Кожний" + "Будь-хто" "Люди можуть приєднатися, лише якщо їх запросили" "Лише запрошені" "Доступ до кімнати" @@ -24,7 +24,7 @@ "Адреса кімнати" "Дозвольте, щоб цю кімнату можна було знайти за допомогою пошуку в каталозі загальнодоступних кімнат %1$s " "Видима в каталозі загальнодоступних кімнат" - "Кожний" + "Будь-хто" "Хто може читати історію" "Лише учасники з моменту запрошення" "Лише учасники після вибору цього параметра" diff --git a/features/securityandprivacy/impl/src/main/res/values-uz/translations.xml b/features/securityandprivacy/impl/src/main/res/values-uz/translations.xml index 06f0337bf10..6709080b038 100644 --- a/features/securityandprivacy/impl/src/main/res/values-uz/translations.xml +++ b/features/securityandprivacy/impl/src/main/res/values-uz/translations.xml @@ -4,7 +4,6 @@ "Xona manzili" "Xona manzilini kiritish" "Xonaga qo‘shilishni istalgan kishi so‘rashi mumkin, lekin administrator yoki moderator so‘rovni qabul qilishi kerak" - "Qo‘shilishni so‘rang" "Ha, shifrlashni yoqish" "Yoqilgandan so‘ng, xona uchun shifrlashni o‘chirib bo‘lmaydi. Xabarlar tarixi faqat xona a’zolari taklif qilinganidan yoki xonaga qo‘shilganidan keyingi davrdan boshlab ko‘rinadi. Xona a’zolaridan tashqari hech kim xabarlarni o‘qiy olmaydi. Bu botlar va ko‘priklarning to‘g‘ri ishlashiga to‘sqinlik qilishi mumkin. Shu sababli, har kim topishi va qo‘shilishi mumkin bo‘lgan xonalar uchun shifrlashni yoqishni tavsiya etmaymiz." @@ -13,7 +12,6 @@ Shu sababli, har kim topishi va qo‘shilishi mumkin bo‘lgan xonalar uchun shi "Shifrlash" "End-to-end shifrlashni yoqish" "Istalgan kishi topishi va qo‘shilishi mumkin" - "Har kim" "Odamlar faqat taklif qilingan taqdirdagina qo‘shilishi mumkin" "Faqat taklif qilish" "Xonaga kirish huquqi" @@ -22,7 +20,6 @@ Shu sababli, har kim topishi va qo‘shilishi mumkin bo‘lgan xonalar uchun shi "Katalogda ko‘rinadigan qilish uchun xona manzili kerak bo‘ladi." "Bu xonani %1$s umumiy xonalar ro‘yxatidan qidirib topish imkoniyatini berish" "Umumiy xona ro‘yxatida ko‘rinadi" - "Har kim" "Tarixni kim o‘qiy oladi" "Taklif qilinganidan buyon faqat a’zolar" "A’zolar faqat bu parametr tanlanganidan keyin" diff --git a/features/securityandprivacy/impl/src/main/res/values/localazy.xml b/features/securityandprivacy/impl/src/main/res/values/localazy.xml index 859be7670b9..5cb5cdf5eca 100644 --- a/features/securityandprivacy/impl/src/main/res/values/localazy.xml +++ b/features/securityandprivacy/impl/src/main/res/values/localazy.xml @@ -2,9 +2,16 @@ "You’ll need an address in order to make it visible in the public directory." "Edit address" + "Spaces where members can join the room without an invitation." + "Manage spaces" + "(Unknown space)" + "Other spaces you’re not a member of" + "Your spaces" "Add address" + "Anyone in authorised spaces can join, but everyone else must request access." "Everyone must request access." "Ask to join" + "Anyone in %1$s can join, but everyone else must request access." "Yes, enable encryption" "Once enabled, encryption for a room cannot be disabled, Message history will only be visible for room members since they were invited or since they joined the room. No one besides the room members will be able to read messages. This may prevent bots and bridges to work correctly. @@ -15,12 +22,12 @@ We do not recommend enabling encryption for rooms that anyone can find and join. "Enable end-to-end encryption" "Anyone can join." "Anyone" - "Choose which spaces’ members can join this room without an invitation. %1$s" + "Choose which spaces’ members can join this room without an invitation. %1$s" "Manage spaces" "Only invited people can join." "Invite only" "Access" - "Anyone in authorized spaces can join." + "Anyone in authorised spaces can join." "Anyone in %1$s can join." "Space members" "Spaces are not currently supported" @@ -29,10 +36,11 @@ We do not recommend enabling encryption for rooms that anyone can find and join. "Allow for this room to be found by searching %1$s public room directory" "Allow to be found by searching the public directory." "Visible in public directory" - "Anyone" + "Anyone (history is public)" + "Changes won\'t affect past messages, only new ones. %1$s" "Who can read history" - "Members only since they were invited" - "Members only since selecting this option" + "Members since invited" + "Members (full history)" "Room addresses are ways to find and access rooms. This also ensures you can easily share your room with others. You can choose to publish your room in your homeserver public room directory." "Room publishing" diff --git a/features/securityandprivacy/impl/src/test/kotlin/io/element/android/features/securityandprivacy/impl/FakeSecurityAndPrivacyNavigator.kt b/features/securityandprivacy/impl/src/test/kotlin/io/element/android/features/securityandprivacy/impl/FakeSecurityAndPrivacyNavigator.kt index 9ca3cc52889..c0a7ca8e7f3 100644 --- a/features/securityandprivacy/impl/src/test/kotlin/io/element/android/features/securityandprivacy/impl/FakeSecurityAndPrivacyNavigator.kt +++ b/features/securityandprivacy/impl/src/test/kotlin/io/element/android/features/securityandprivacy/impl/FakeSecurityAndPrivacyNavigator.kt @@ -11,9 +11,16 @@ package io.element.android.features.securityandprivacy.impl import io.element.android.tests.testutils.lambda.lambdaError class FakeSecurityAndPrivacyNavigator( + private val onDoneLambda: () -> Unit = { lambdaError() }, private val openEditRoomAddressLambda: () -> Unit = { lambdaError() }, private val closeEditRoomAddressLambda: () -> Unit = { lambdaError() }, + private val openManageAuthorizedSpacesLambda: () -> Unit = { lambdaError() }, + private val closeManageAuthorizedSpacesLambda: () -> Unit = { lambdaError() }, ) : SecurityAndPrivacyNavigator { + override fun onDone() { + onDoneLambda() + } + override fun openEditRoomAddress() { openEditRoomAddressLambda() } @@ -21,4 +28,12 @@ class FakeSecurityAndPrivacyNavigator( override fun closeEditRoomAddress() { closeEditRoomAddressLambda() } + + override fun openManageAuthorizedSpaces() { + openManageAuthorizedSpacesLambda() + } + + override fun closeManageAuthorizedSpaces() { + closeManageAuthorizedSpacesLambda() + } } diff --git a/features/securityandprivacy/impl/src/test/kotlin/io/element/android/features/securityandprivacy/impl/SecurityAndPrivacyFlowNodeTest.kt b/features/securityandprivacy/impl/src/test/kotlin/io/element/android/features/securityandprivacy/impl/SecurityAndPrivacyFlowNodeTest.kt new file mode 100644 index 00000000000..a6f21c01620 --- /dev/null +++ b/features/securityandprivacy/impl/src/test/kotlin/io/element/android/features/securityandprivacy/impl/SecurityAndPrivacyFlowNodeTest.kt @@ -0,0 +1,107 @@ +/* + * 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.securityandprivacy.impl + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.bumble.appyx.core.modality.AncestryInfo +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.navmodel.backstack.activeElement +import com.bumble.appyx.utils.customisations.NodeCustomisationDirectoryImpl +import com.google.common.truth.Truth.assertThat +import io.element.android.features.securityandprivacy.api.SecurityAndPrivacyEntryPoint +import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibility +import io.element.android.libraries.matrix.api.room.join.JoinRule +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 kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class SecurityAndPrivacyFlowNodeTest { + @Test + fun `initial backstack contains SecurityAndPrivacy`() = runTest { + val flowNode = createFlowNode() + assertThat(flowNode.currentNavTarget()).isEqualTo(SecurityAndPrivacyFlowNode.NavTarget.SecurityAndPrivacy) + } + + @Test + fun `openEditRoomAddress navigates to EditRoomAddress`() = runTest { + val flowNode = createFlowNode() + flowNode.navigator.openEditRoomAddress() + assertThat(flowNode.currentNavTarget()).isEqualTo(SecurityAndPrivacyFlowNode.NavTarget.EditRoomAddress) + } + + @Test + fun `closeEditRoomAddress pops backstack`() = runTest { + val flowNode = createFlowNode() + flowNode.navigator.openEditRoomAddress() + assertThat(flowNode.currentNavTarget()).isEqualTo(SecurityAndPrivacyFlowNode.NavTarget.EditRoomAddress) + flowNode.navigator.closeEditRoomAddress() + assertThat(flowNode.currentNavTarget()).isEqualTo(SecurityAndPrivacyFlowNode.NavTarget.SecurityAndPrivacy) + } + + @Test + fun `openManageAuthorizedSpaces navigates to ManageAuthorizedSpaces`() = runTest { + val flowNode = createFlowNode() + flowNode.navigator.openManageAuthorizedSpaces() + assertThat(flowNode.currentNavTarget()).isEqualTo(SecurityAndPrivacyFlowNode.NavTarget.ManageAuthorizedSpaces) + } + + @Test + fun `closeManageAuthorizedSpaces pops backstack`() = runTest { + val flowNode = createFlowNode() + flowNode.navigator.openManageAuthorizedSpaces() + assertThat(flowNode.currentNavTarget()) + .isInstanceOf(SecurityAndPrivacyFlowNode.NavTarget.ManageAuthorizedSpaces::class.java) + flowNode.navigator.closeManageAuthorizedSpaces() + assertThat(flowNode.currentNavTarget()).isEqualTo(SecurityAndPrivacyFlowNode.NavTarget.SecurityAndPrivacy) + } + + @Test + fun `onDone invokes callback`() = runTest { + var onDoneCalled = false + val callback = object : SecurityAndPrivacyEntryPoint.Callback { + override fun onDone() { + onDoneCalled = true + } + } + val flowNode = createFlowNode(callback = callback) + flowNode.navigator.onDone() + assertThat(onDoneCalled).isTrue() + } + + private fun createFlowNode( + callback: SecurityAndPrivacyEntryPoint.Callback = object : SecurityAndPrivacyEntryPoint.Callback { + override fun onDone() {} + }, + ): SecurityAndPrivacyFlowNode { + val buildContext = BuildContext( + ancestryInfo = AncestryInfo.Root, + savedStateMap = null, + customisations = NodeCustomisationDirectoryImpl() + ) + val room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + initialRoomInfo = aRoomInfo( + joinRule = JoinRule.Invite, + historyVisibility = RoomHistoryVisibility.Shared + ) + ) + ) + return SecurityAndPrivacyFlowNode( + buildContext = buildContext, + plugins = listOf(callback), + room = room, + ) + } + + private fun SecurityAndPrivacyFlowNode.currentNavTarget() = backstack.activeElement +} diff --git a/features/securityandprivacy/impl/src/test/kotlin/io/element/android/features/securityandprivacy/impl/SecurityAndPrivacyPresenterTest.kt b/features/securityandprivacy/impl/src/test/kotlin/io/element/android/features/securityandprivacy/impl/SecurityAndPrivacyPresenterTest.kt deleted file mode 100644 index bdae5274f0f..00000000000 --- a/features/securityandprivacy/impl/src/test/kotlin/io/element/android/features/securityandprivacy/impl/SecurityAndPrivacyPresenterTest.kt +++ /dev/null @@ -1,390 +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.securityandprivacy.impl - -import com.google.common.truth.Truth.assertThat -import io.element.android.features.securityandprivacy.impl.root.SecurityAndPrivacyEvents -import io.element.android.features.securityandprivacy.impl.root.SecurityAndPrivacyHistoryVisibility -import io.element.android.features.securityandprivacy.impl.root.SecurityAndPrivacyPresenter -import io.element.android.features.securityandprivacy.impl.root.SecurityAndPrivacyRoomAccess -import io.element.android.libraries.architecture.AsyncAction -import io.element.android.libraries.architecture.AsyncData -import io.element.android.libraries.featureflag.api.FeatureFlagService -import io.element.android.libraries.featureflag.api.FeatureFlags -import io.element.android.libraries.featureflag.test.FakeFeatureFlagService -import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibility -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.test.A_ROOM_ALIAS -import io.element.android.libraries.matrix.test.FakeMatrixClient -import io.element.android.libraries.matrix.test.room.FakeBaseRoom -import io.element.android.libraries.matrix.test.room.FakeJoinedRoom -import io.element.android.libraries.matrix.test.room.aRoomInfo -import io.element.android.tests.testutils.lambda.assert -import io.element.android.tests.testutils.lambda.lambdaRecorder -import io.element.android.tests.testutils.test -import kotlinx.coroutines.test.runTest -import org.junit.Test - -class SecurityAndPrivacyPresenterTest { - @Test - fun `present - initial states`() = runTest { - val presenter = createSecurityAndPrivacyPresenter() - presenter.test { - with(awaitItem()) { - assertThat(editedSettings).isEqualTo(savedSettings) - assertThat(canBeSaved).isFalse() - assertThat(showEnableEncryptionConfirmation).isFalse() - assertThat(saveAction).isEqualTo(AsyncAction.Uninitialized) - assertThat(showRoomAccessSection).isFalse() - assertThat(showRoomVisibilitySections).isFalse() - assertThat(showHistoryVisibilitySection).isFalse() - assertThat(showEncryptionSection).isFalse() - assertThat(isKnockEnabled).isFalse() - } - with(awaitItem()) { - assertThat(editedSettings).isEqualTo(savedSettings) - assertThat(canBeSaved).isFalse() - assertThat(showEnableEncryptionConfirmation).isFalse() - assertThat(saveAction).isEqualTo(AsyncAction.Uninitialized) - assertThat(showRoomAccessSection).isTrue() - assertThat(showRoomVisibilitySections).isFalse() - assertThat(showHistoryVisibilitySection).isTrue() - assertThat(showEncryptionSection).isTrue() - assertThat(isKnockEnabled).isFalse() - } - } - } - - @Test - fun `present - room info change updates saved and edited settings`() = runTest { - val room = FakeJoinedRoom( - baseRoom = FakeBaseRoom( - canSendStateResult = { _, _ -> Result.success(true) }, - initialRoomInfo = aRoomInfo( - joinRule = JoinRule.Public, - historyVisibility = RoomHistoryVisibility.WorldReadable, - canonicalAlias = A_ROOM_ALIAS, - ) - ) - ) - val presenter = createSecurityAndPrivacyPresenter(room = room) - presenter.test { - skipItems(1) - with(awaitItem()) { - assertThat(editedSettings).isEqualTo(savedSettings) - assertThat(editedSettings.roomAccess).isEqualTo(SecurityAndPrivacyRoomAccess.Anyone) - assertThat(editedSettings.historyVisibility).isEqualTo(SecurityAndPrivacyHistoryVisibility.Anyone) - assertThat(editedSettings.address).isEqualTo(A_ROOM_ALIAS.value) - assertThat(canBeSaved).isFalse() - } - cancelAndIgnoreRemainingEvents() - } - } - - @Test - fun `present - change room access`() = runTest { - val presenter = createSecurityAndPrivacyPresenter() - presenter.test { - skipItems(1) - with(awaitItem()) { - assertThat(editedSettings.roomAccess).isEqualTo(SecurityAndPrivacyRoomAccess.InviteOnly) - assertThat(showRoomVisibilitySections).isFalse() - eventSink(SecurityAndPrivacyEvents.ChangeRoomAccess(SecurityAndPrivacyRoomAccess.Anyone)) - } - with(awaitItem()) { - assertThat(editedSettings.roomAccess).isEqualTo(SecurityAndPrivacyRoomAccess.Anyone) - assertThat(showRoomVisibilitySections).isTrue() - assertThat(canBeSaved).isTrue() - eventSink(SecurityAndPrivacyEvents.ChangeRoomAccess(SecurityAndPrivacyRoomAccess.InviteOnly)) - } - with(awaitItem()) { - assertThat(editedSettings.roomAccess).isEqualTo(SecurityAndPrivacyRoomAccess.InviteOnly) - assertThat(showRoomVisibilitySections).isFalse() - assertThat(canBeSaved).isFalse() - } - } - } - - @Test - fun `present - change history visibility`() = runTest { - val presenter = createSecurityAndPrivacyPresenter() - presenter.test { - skipItems(1) - with(awaitItem()) { - assertThat(editedSettings.historyVisibility).isEqualTo(SecurityAndPrivacyHistoryVisibility.SinceSelection) - eventSink(SecurityAndPrivacyEvents.ChangeHistoryVisibility(SecurityAndPrivacyHistoryVisibility.SinceInvite)) - } - with(awaitItem()) { - assertThat(editedSettings.historyVisibility).isEqualTo(SecurityAndPrivacyHistoryVisibility.SinceInvite) - assertThat(canBeSaved).isTrue() - eventSink(SecurityAndPrivacyEvents.ChangeHistoryVisibility(SecurityAndPrivacyHistoryVisibility.SinceSelection)) - } - with(awaitItem()) { - assertThat(editedSettings.historyVisibility).isEqualTo(SecurityAndPrivacyHistoryVisibility.SinceSelection) - assertThat(canBeSaved).isFalse() - } - } - } - - @Test - fun `present - enable encryption`() = runTest { - val presenter = createSecurityAndPrivacyPresenter() - presenter.test { - skipItems(1) - with(awaitItem()) { - assertThat(editedSettings.isEncrypted).isFalse() - eventSink(SecurityAndPrivacyEvents.ToggleEncryptionState) - } - with(awaitItem()) { - assertThat(showEnableEncryptionConfirmation).isTrue() - eventSink(SecurityAndPrivacyEvents.CancelEnableEncryption) - } - with(awaitItem()) { - assertThat(showEnableEncryptionConfirmation).isFalse() - eventSink(SecurityAndPrivacyEvents.ToggleEncryptionState) - } - with(awaitItem()) { - assertThat(showEnableEncryptionConfirmation).isTrue() - eventSink(SecurityAndPrivacyEvents.ConfirmEnableEncryption) - } - skipItems(1) - with(awaitItem()) { - assertThat(editedSettings.isEncrypted).isTrue() - assertThat(showEnableEncryptionConfirmation).isFalse() - assertThat(canBeSaved).isTrue() - eventSink(SecurityAndPrivacyEvents.ToggleEncryptionState) - } - skipItems(1) - with(awaitItem()) { - assertThat(editedSettings.isEncrypted).isFalse() - assertThat(canBeSaved).isFalse() - } - } - } - - @Test - fun `present - room visibility loading and change`() = runTest { - val room = FakeJoinedRoom( - baseRoom = FakeBaseRoom( - canSendStateResult = { _, _ -> Result.success(true) }, - getRoomVisibilityResult = { Result.success(RoomVisibility.Private) }, - initialRoomInfo = aRoomInfo(historyVisibility = RoomHistoryVisibility.Shared) - ) - ) - val presenter = createSecurityAndPrivacyPresenter(room = room) - presenter.test { - skipItems(1) - with(awaitItem()) { - assertThat(editedSettings.isVisibleInRoomDirectory).isEqualTo(AsyncData.Loading()) - } - with(awaitItem()) { - assertThat(editedSettings.isVisibleInRoomDirectory).isEqualTo(AsyncData.Success(false)) - eventSink(SecurityAndPrivacyEvents.ToggleRoomVisibility) - } - with(awaitItem()) { - assertThat(editedSettings.isVisibleInRoomDirectory).isEqualTo(AsyncData.Success(true)) - assertThat(canBeSaved).isTrue() - eventSink(SecurityAndPrivacyEvents.ToggleRoomVisibility) - } - with(awaitItem()) { - assertThat(editedSettings.isVisibleInRoomDirectory).isEqualTo(AsyncData.Success(false)) - assertThat(canBeSaved).isFalse() - } - } - } - - @Test - fun `present - edit room address`() = runTest { - val openEditRoomAddressLambda = lambdaRecorder { } - val navigator = FakeSecurityAndPrivacyNavigator(openEditRoomAddressLambda) - val presenter = createSecurityAndPrivacyPresenter(navigator = navigator) - presenter.test { - skipItems(1) - with(awaitItem()) { - eventSink(SecurityAndPrivacyEvents.EditRoomAddress) - } - assert(openEditRoomAddressLambda).isCalledOnce() - } - } - - @Test - fun `present - save success`() = runTest { - val enableEncryptionLambda = lambdaRecorder> { Result.success(Unit) } - val updateJoinRuleLambda = lambdaRecorder> { Result.success(Unit) } - val updateRoomVisibilityLambda = lambdaRecorder> { Result.success(Unit) } - val updateRoomHistoryVisibilityLambda = lambdaRecorder> { Result.success(Unit) } - val room = FakeJoinedRoom( - baseRoom = FakeBaseRoom( - canSendStateResult = { _, _ -> Result.success(true) }, - getRoomVisibilityResult = { Result.success(RoomVisibility.Private) }, - initialRoomInfo = aRoomInfo(joinRule = JoinRule.Invite, historyVisibility = RoomHistoryVisibility.Shared) - ), - enableEncryptionResult = enableEncryptionLambda, - updateJoinRuleResult = updateJoinRuleLambda, - updateRoomVisibilityResult = updateRoomVisibilityLambda, - updateRoomHistoryVisibilityResult = updateRoomHistoryVisibilityLambda, - ) - val presenter = createSecurityAndPrivacyPresenter(room = room) - presenter.test { - skipItems(2) - with(awaitItem()) { - assertThat(editedSettings.roomAccess).isEqualTo(SecurityAndPrivacyRoomAccess.InviteOnly) - eventSink(SecurityAndPrivacyEvents.ChangeRoomAccess(SecurityAndPrivacyRoomAccess.Anyone)) - } - with(awaitItem()) { - eventSink(SecurityAndPrivacyEvents.ChangeHistoryVisibility(SecurityAndPrivacyHistoryVisibility.Anyone)) - } - with(awaitItem()) { - assertThat(editedSettings.historyVisibility).isEqualTo(SecurityAndPrivacyHistoryVisibility.Anyone) - eventSink(SecurityAndPrivacyEvents.ConfirmEnableEncryption) - } - skipItems(1) - with(awaitItem()) { - assertThat(editedSettings.isEncrypted).isTrue() - eventSink(SecurityAndPrivacyEvents.ToggleRoomVisibility) - } - with(awaitItem()) { - assertThat(editedSettings.isVisibleInRoomDirectory).isEqualTo(AsyncData.Success(true)) - eventSink(SecurityAndPrivacyEvents.Save) - } - with(awaitItem()) { - assertThat(saveAction).isEqualTo(AsyncAction.Loading) - } - - room.givenRoomInfo( - aRoomInfo( - joinRule = JoinRule.Public, - historyVisibility = RoomHistoryVisibility.WorldReadable, - isEncrypted = true, - ) - ) - // Saved settings are updated 3 times to match the edited settings - skipItems(3) - with(awaitItem()) { - assertThat(saveAction).isEqualTo(AsyncAction.Success(Unit)) - assertThat(savedSettings).isEqualTo(editedSettings) - assertThat(canBeSaved).isFalse() - } - assert(enableEncryptionLambda).isCalledOnce() - assert(updateJoinRuleLambda).isCalledOnce() - assert(updateRoomVisibilityLambda).isCalledOnce() - assert(updateRoomHistoryVisibilityLambda).isCalledOnce() - } - } - - @Test - fun `present - save failure`() = runTest { - val enableEncryptionLambda = lambdaRecorder> { Result.success(Unit) } - val updateJoinRuleLambda = lambdaRecorder> { Result.success(Unit) } - val updateRoomVisibilityLambda = lambdaRecorder> { - Result.failure(Exception("Failed to update room visibility")) - } - val updateRoomHistoryVisibilityLambda = lambdaRecorder> { Result.success(Unit) } - val room = FakeJoinedRoom( - baseRoom = FakeBaseRoom( - canSendStateResult = { _, _ -> Result.success(true) }, - getRoomVisibilityResult = { Result.success(RoomVisibility.Private) }, - initialRoomInfo = aRoomInfo(historyVisibility = RoomHistoryVisibility.Shared, joinRule = JoinRule.Private) - ), - enableEncryptionResult = enableEncryptionLambda, - updateJoinRuleResult = updateJoinRuleLambda, - updateRoomVisibilityResult = updateRoomVisibilityLambda, - updateRoomHistoryVisibilityResult = updateRoomHistoryVisibilityLambda, - ) - val presenter = createSecurityAndPrivacyPresenter(room = room) - presenter.test { - skipItems(2) - with(awaitItem()) { - assertThat(editedSettings.roomAccess).isEqualTo(SecurityAndPrivacyRoomAccess.InviteOnly) - eventSink(SecurityAndPrivacyEvents.ChangeRoomAccess(SecurityAndPrivacyRoomAccess.Anyone)) - } - with(awaitItem()) { - eventSink(SecurityAndPrivacyEvents.ChangeHistoryVisibility(SecurityAndPrivacyHistoryVisibility.Anyone)) - } - with(awaitItem()) { - assertThat(editedSettings.historyVisibility).isEqualTo(SecurityAndPrivacyHistoryVisibility.Anyone) - eventSink(SecurityAndPrivacyEvents.ConfirmEnableEncryption) - } - skipItems(1) - with(awaitItem()) { - assertThat(editedSettings.isEncrypted).isTrue() - eventSink(SecurityAndPrivacyEvents.ToggleRoomVisibility) - } - with(awaitItem()) { - assertThat(editedSettings.isVisibleInRoomDirectory).isEqualTo(AsyncData.Success(true)) - eventSink(SecurityAndPrivacyEvents.Save) - } - with(awaitItem()) { - assertThat(saveAction).isEqualTo(AsyncAction.Loading) - } - - room.givenRoomInfo( - aRoomInfo( - joinRule = JoinRule.Public, - historyVisibility = RoomHistoryVisibility.WorldReadable, - ) - ) - // Saved settings are updated 2 times to match the edited settings - skipItems(3) - val state = awaitItem() - with(state) { - assertThat(saveAction).isInstanceOf(AsyncAction.Failure::class.java) - assertThat(savedSettings.isVisibleInRoomDirectory).isNotEqualTo(editedSettings.isVisibleInRoomDirectory) - assertThat(canBeSaved).isTrue() - } - assert(enableEncryptionLambda).isCalledOnce() - assert(updateJoinRuleLambda).isCalledOnce() - assert(updateRoomVisibilityLambda).isCalledOnce() - assert(updateRoomHistoryVisibilityLambda).isCalledOnce() - // Clear error - state.eventSink(SecurityAndPrivacyEvents.DismissSaveError) - with(awaitItem()) { - assertThat(saveAction).isEqualTo(AsyncAction.Uninitialized) - } - } - } - - @Test - fun `present - isKnockEnabled is true if the Knock feature flag is enabled`() = runTest { - val presenter = createSecurityAndPrivacyPresenter( - featureFlagService = FakeFeatureFlagService( - initialState = mapOf( - FeatureFlags.Knock.key to true, - ) - ) - ) - presenter.test { - assertThat(awaitItem().isKnockEnabled).isFalse() - assertThat(awaitItem().isKnockEnabled).isTrue() - } - } - - private fun createSecurityAndPrivacyPresenter( - serverName: String = "matrix.org", - room: FakeJoinedRoom = FakeJoinedRoom( - baseRoom = FakeBaseRoom( - canSendStateResult = { _, _ -> Result.success(true) }, - getRoomVisibilityResult = { Result.success(RoomVisibility.Private) }, - initialRoomInfo = aRoomInfo(historyVisibility = RoomHistoryVisibility.Shared, joinRule = JoinRule.Private) - ), - ), - navigator: SecurityAndPrivacyNavigator = FakeSecurityAndPrivacyNavigator(), - featureFlagService: FeatureFlagService = FakeFeatureFlagService(), - ): SecurityAndPrivacyPresenter { - return SecurityAndPrivacyPresenter( - room = room, - matrixClient = FakeMatrixClient( - userIdServerNameLambda = { serverName }, - ), - navigator = navigator, - featureFlagService = featureFlagService, - ) - } -} diff --git a/features/securityandprivacy/impl/src/test/kotlin/io/element/android/features/securityandprivacy/impl/manageauthorizedspaces/ManageAuthorizedSpacesPresenterTest.kt b/features/securityandprivacy/impl/src/test/kotlin/io/element/android/features/securityandprivacy/impl/manageauthorizedspaces/ManageAuthorizedSpacesPresenterTest.kt new file mode 100644 index 00000000000..8314aca4cd1 --- /dev/null +++ b/features/securityandprivacy/impl/src/test/kotlin/io/element/android/features/securityandprivacy/impl/manageauthorizedspaces/ManageAuthorizedSpacesPresenterTest.kt @@ -0,0 +1,126 @@ +/* + * 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.securityandprivacy.impl.manageauthorizedspaces + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.tests.testutils.test +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.persistentSetOf +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class ManageAuthorizedSpacesPresenterTest { + @Test + fun `present - initial state reflects shared state`() = runTest { + val sharedStateHolder = SpaceSelectionStateHolder() + val presenter = ManageAuthorizedSpacesPresenter(sharedStateHolder) + presenter.test { + with(awaitItem()) { + assertThat(selectedIds).isEmpty() + assertThat(isDoneButtonEnabled).isFalse() + } + } + } + + @Test + fun `present - state reflects shared state with pre-selected spaces`() = runTest { + val sharedStateHolder = SpaceSelectionStateHolder() + val roomId = A_ROOM_ID + sharedStateHolder.update { + it.copy(selectedSpaceIds = persistentListOf(roomId)) + } + val presenter = ManageAuthorizedSpacesPresenter(sharedStateHolder) + presenter.test { + with(awaitItem()) { + assertThat(selectedIds).containsExactly(roomId) + assertThat(isDoneButtonEnabled).isTrue() + } + } + } + + @Test + fun `present - ToggleSpace event adds space to selectedIds in shared state`() = runTest { + val sharedStateHolder = SpaceSelectionStateHolder() + val presenter = ManageAuthorizedSpacesPresenter(sharedStateHolder) + presenter.test { + val initialState = awaitItem() + val roomId = A_ROOM_ID + initialState.eventSink(ManageAuthorizedSpacesEvent.ToggleSpace(roomId)) + with(awaitItem()) { + assertThat(selectedIds).containsExactly(roomId) + assertThat(isDoneButtonEnabled).isTrue() + } + // Verify the shared state is also updated + assertThat(sharedStateHolder.state.value.selectedSpaceIds).containsExactly(roomId) + } + } + + @Test + fun `present - ToggleSpace event removes space when already selected`() = runTest { + val sharedStateHolder = SpaceSelectionStateHolder() + sharedStateHolder.updateSelectedSpaceIds(persistentListOf(A_ROOM_ID)) + val presenter = ManageAuthorizedSpacesPresenter(sharedStateHolder) + presenter.test { + val initialState = awaitItem() + assertThat(initialState.selectedIds).containsExactly(A_ROOM_ID) + initialState.eventSink(ManageAuthorizedSpacesEvent.ToggleSpace(A_ROOM_ID)) + with(awaitItem()) { + assertThat(selectedIds).isEmpty() + assertThat(isDoneButtonEnabled).isFalse() + } + // Verify the shared state is also updated + assertThat(sharedStateHolder.state.value.selectedSpaceIds).isEmpty() + } + } + + @Test + fun `present - Done event sets completion to Completed`() = runTest { + val sharedStateHolder = SpaceSelectionStateHolder() + val presenter = ManageAuthorizedSpacesPresenter(sharedStateHolder) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(ManageAuthorizedSpacesEvent.Done) + cancelAndIgnoreRemainingEvents() + assertThat(sharedStateHolder.state.value.completion) + .isEqualTo(SpaceSelectionState.Completion.Completed) + } + } + + @Test + fun `present - Cancel event sets completion to Cancelled`() = runTest { + val sharedStateHolder = SpaceSelectionStateHolder() + val presenter = ManageAuthorizedSpacesPresenter(sharedStateHolder) + presenter.test { + val initialState = awaitItem() + initialState.eventSink(ManageAuthorizedSpacesEvent.Cancel) + cancelAndIgnoreRemainingEvents() + assertThat(sharedStateHolder.state.value.completion) + .isEqualTo(SpaceSelectionState.Completion.Cancelled) + } + } + + @Test + fun `present - displays spaces from shared state`() = runTest { + val sharedStateHolder = SpaceSelectionStateHolder() + sharedStateHolder.update { + it.copy( + selectableSpaces = persistentSetOf(), + unknownSpaceIds = persistentListOf(A_ROOM_ID), + ) + } + val presenter = ManageAuthorizedSpacesPresenter(sharedStateHolder) + presenter.test { + with(awaitItem()) { + assertThat(selectableSpaces).isEmpty() + assertThat(unknownSpaceIds).containsExactly(A_ROOM_ID) + } + } + } +} diff --git a/features/securityandprivacy/impl/src/test/kotlin/io/element/android/features/securityandprivacy/impl/manageauthorizedspaces/ManageAuthorizedSpacesViewTest.kt b/features/securityandprivacy/impl/src/test/kotlin/io/element/android/features/securityandprivacy/impl/manageauthorizedspaces/ManageAuthorizedSpacesViewTest.kt new file mode 100644 index 00000000000..c732df6df0b --- /dev/null +++ b/features/securityandprivacy/impl/src/test/kotlin/io/element/android/features/securityandprivacy/impl/manageauthorizedspaces/ManageAuthorizedSpacesViewTest.kt @@ -0,0 +1,104 @@ +/* + * 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.securityandprivacy.impl.manageauthorizedspaces + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.spaces.SpaceRoom +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.previewutils.room.aSpaceRoom +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.pressBack +import kotlinx.collections.immutable.toImmutableList +import kotlinx.collections.immutable.toImmutableSet +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class ManageAuthorizedSpacesViewTest { + @get:Rule val rule = createAndroidComposeRule() + + @Test + fun `clicking back emits Cancel event`() { + val recorder = EventsRecorder() + val state = aManageAuthorizedSpacesState(eventSink = recorder) + rule.setManageAuthorizedSpacesView(state) + rule.pressBack() + recorder.assertSingle(ManageAuthorizedSpacesEvent.Cancel) + } + + @Test + fun `clicking space checkbox emits ToggleSpace event`() { + val roomId = A_ROOM_ID + val space = aSpaceRoom(roomId = roomId, displayName = "Test Space") + val recorder = EventsRecorder() + val state = aManageAuthorizedSpacesState( + selectableSpaces = listOf(space), + eventSink = recorder + ) + rule.setManageAuthorizedSpacesView(state) + rule.onNodeWithText("Test Space").performClick() + recorder.assertSingle(ManageAuthorizedSpacesEvent.ToggleSpace(roomId)) + } + + @Test + fun `clicking done button emits Done event`() { + val recorder = EventsRecorder() + val state = aManageAuthorizedSpacesState( + selectedIds = listOf(A_ROOM_ID), + eventSink = recorder + ) + rule.setManageAuthorizedSpacesView(state) + rule.clickOn(CommonStrings.action_done) + recorder.assertSingle(ManageAuthorizedSpacesEvent.Done) + } + + @Test + fun `done button is disabled when no spaces selected`() { + val recorder = EventsRecorder(expectEvents = false) + val state = aManageAuthorizedSpacesState( + selectedIds = emptyList(), + eventSink = recorder + ) + rule.setManageAuthorizedSpacesView(state) + rule.clickOn(CommonStrings.action_done) + recorder.assertEmpty() + } +} + +private fun AndroidComposeTestRule.setManageAuthorizedSpacesView( + state: ManageAuthorizedSpacesState = aManageAuthorizedSpacesState( + eventSink = EventsRecorder(expectEvents = false) + ), +) { + setContent { + ManageAuthorizedSpacesView(state = state) + } +} + +private fun aManageAuthorizedSpacesState( + selectableSpaces: List = emptyList(), + unknownSpaceIds: List = emptyList(), + selectedIds: List = emptyList(), + eventSink: (ManageAuthorizedSpacesEvent) -> Unit = {}, +) = ManageAuthorizedSpacesState( + selectableSpaces = selectableSpaces.toImmutableSet(), + unknownSpaceIds = unknownSpaceIds.toImmutableList(), + selectedIds = selectedIds.toImmutableList(), + eventSink = eventSink, +) 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 new file mode 100644 index 00000000000..e9cd49cc94d --- /dev/null +++ b/features/securityandprivacy/impl/src/test/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyPresenterTest.kt @@ -0,0 +1,1116 @@ +/* + * 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.securityandprivacy.impl.root + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.securityandprivacy.impl.FakeSecurityAndPrivacyNavigator +import io.element.android.features.securityandprivacy.impl.SecurityAndPrivacyNavigator +import io.element.android.features.securityandprivacy.impl.manageauthorizedspaces.SpaceSelectionStateHolder +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.featureflag.api.FeatureFlagService +import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.featureflag.test.FakeFeatureFlagService +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.room.StateEventType +import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibility +import io.element.android.libraries.matrix.api.room.join.AllowRule +import io.element.android.libraries.matrix.api.room.join.JoinRule +import io.element.android.libraries.matrix.api.room.powerlevels.RoomPermissions +import io.element.android.libraries.matrix.api.roomdirectory.RoomVisibility +import io.element.android.libraries.matrix.test.A_ROOM_ALIAS +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.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.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.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 kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.test.runTest +import org.junit.Test + +@Suppress("LargeClass") +class SecurityAndPrivacyPresenterTest { + @Test + fun `present - initial states`() = runTest { + val presenter = createSecurityAndPrivacyPresenter() + presenter.test { + with(awaitItem()) { + assertThat(editedSettings).isEqualTo(savedSettings) + assertThat(canBeSaved).isFalse() + assertThat(showEnableEncryptionConfirmation).isFalse() + assertThat(saveAction).isEqualTo(AsyncAction.Uninitialized) + assertThat(showRoomAccessSection).isFalse() + assertThat(showRoomVisibilitySections).isFalse() + assertThat(showHistoryVisibilitySection).isFalse() + assertThat(showEncryptionSection).isFalse() + } + with(awaitItem()) { + assertThat(editedSettings).isEqualTo(savedSettings) + assertThat(canBeSaved).isFalse() + assertThat(showEnableEncryptionConfirmation).isFalse() + assertThat(saveAction).isEqualTo(AsyncAction.Uninitialized) + assertThat(showRoomAccessSection).isTrue() + assertThat(showRoomVisibilitySections).isFalse() + assertThat(showHistoryVisibilitySection).isTrue() + assertThat(showEncryptionSection).isTrue() + } + } + } + + @Test + fun `present - room info change updates saved and edited settings`() = runTest { + val room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + roomPermissions = roomPermissions(), + initialRoomInfo = aRoomInfo( + joinRule = JoinRule.Public, + historyVisibility = RoomHistoryVisibility.WorldReadable, + canonicalAlias = A_ROOM_ALIAS, + ) + ) + ) + val presenter = createSecurityAndPrivacyPresenter(room = room) + presenter.test { + skipItems(1) + with(awaitItem()) { + assertThat(editedSettings).isEqualTo(savedSettings) + assertThat(editedSettings.roomAccess).isEqualTo(SecurityAndPrivacyRoomAccess.Anyone) + assertThat(editedSettings.historyVisibility).isEqualTo(SecurityAndPrivacyHistoryVisibility.WorldReadable) + assertThat(editedSettings.address).isEqualTo(A_ROOM_ALIAS.value) + assertThat(canBeSaved).isFalse() + } + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - change room access`() = runTest { + val presenter = createSecurityAndPrivacyPresenter() + presenter.test { + skipItems(1) + with(awaitItem()) { + assertThat(editedSettings.roomAccess).isEqualTo(SecurityAndPrivacyRoomAccess.InviteOnly) + assertThat(showRoomVisibilitySections).isFalse() + eventSink(SecurityAndPrivacyEvent.ChangeRoomAccess(SecurityAndPrivacyRoomAccess.Anyone)) + } + with(awaitItem()) { + assertThat(editedSettings.roomAccess).isEqualTo(SecurityAndPrivacyRoomAccess.Anyone) + assertThat(showRoomVisibilitySections).isTrue() + assertThat(canBeSaved).isTrue() + eventSink(SecurityAndPrivacyEvent.ChangeRoomAccess(SecurityAndPrivacyRoomAccess.InviteOnly)) + } + with(awaitItem()) { + assertThat(editedSettings.roomAccess).isEqualTo(SecurityAndPrivacyRoomAccess.InviteOnly) + assertThat(showRoomVisibilitySections).isFalse() + assertThat(canBeSaved).isFalse() + } + } + } + + @Test + fun `present - change history visibility`() = runTest { + val presenter = createSecurityAndPrivacyPresenter() + presenter.test { + skipItems(1) + with(awaitItem()) { + assertThat(editedSettings.historyVisibility).isEqualTo(SecurityAndPrivacyHistoryVisibility.Shared) + eventSink(SecurityAndPrivacyEvent.ChangeHistoryVisibility(SecurityAndPrivacyHistoryVisibility.Invited)) + } + with(awaitItem()) { + assertThat(editedSettings.historyVisibility).isEqualTo(SecurityAndPrivacyHistoryVisibility.Invited) + assertThat(canBeSaved).isTrue() + eventSink(SecurityAndPrivacyEvent.ChangeHistoryVisibility(SecurityAndPrivacyHistoryVisibility.Shared)) + } + with(awaitItem()) { + assertThat(editedSettings.historyVisibility).isEqualTo(SecurityAndPrivacyHistoryVisibility.Shared) + assertThat(canBeSaved).isFalse() + } + } + } + + @Test + fun `present - enable encryption`() = runTest { + val presenter = createSecurityAndPrivacyPresenter() + presenter.test { + skipItems(1) + with(awaitItem()) { + assertThat(editedSettings.isEncrypted).isFalse() + eventSink(SecurityAndPrivacyEvent.ToggleEncryptionState) + } + with(awaitItem()) { + assertThat(showEnableEncryptionConfirmation).isTrue() + eventSink(SecurityAndPrivacyEvent.CancelEnableEncryption) + } + with(awaitItem()) { + assertThat(showEnableEncryptionConfirmation).isFalse() + eventSink(SecurityAndPrivacyEvent.ToggleEncryptionState) + } + with(awaitItem()) { + assertThat(showEnableEncryptionConfirmation).isTrue() + eventSink(SecurityAndPrivacyEvent.ConfirmEnableEncryption) + } + skipItems(1) + with(awaitItem()) { + assertThat(editedSettings.isEncrypted).isTrue() + assertThat(showEnableEncryptionConfirmation).isFalse() + assertThat(canBeSaved).isTrue() + eventSink(SecurityAndPrivacyEvent.ToggleEncryptionState) + } + skipItems(1) + with(awaitItem()) { + assertThat(editedSettings.isEncrypted).isFalse() + assertThat(canBeSaved).isFalse() + } + } + } + + @Test + fun `present - room visibility loading and change`() = runTest { + val room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + roomPermissions = roomPermissions(), + getRoomVisibilityResult = { Result.success(RoomVisibility.Private) }, + initialRoomInfo = aRoomInfo(historyVisibility = RoomHistoryVisibility.Shared) + ) + ) + val presenter = createSecurityAndPrivacyPresenter(room = room) + presenter.test { + skipItems(1) + with(awaitItem()) { + assertThat(editedSettings.isVisibleInRoomDirectory).isEqualTo(AsyncData.Loading()) + } + with(awaitItem()) { + assertThat(editedSettings.isVisibleInRoomDirectory).isEqualTo(AsyncData.Success(false)) + eventSink(SecurityAndPrivacyEvent.ToggleRoomVisibility) + } + with(awaitItem()) { + assertThat(editedSettings.isVisibleInRoomDirectory).isEqualTo(AsyncData.Success(true)) + assertThat(canBeSaved).isTrue() + eventSink(SecurityAndPrivacyEvent.ToggleRoomVisibility) + } + with(awaitItem()) { + assertThat(editedSettings.isVisibleInRoomDirectory).isEqualTo(AsyncData.Success(false)) + assertThat(canBeSaved).isFalse() + } + } + } + + @Test + fun `present - edit room address`() = runTest { + val openEditRoomAddressLambda = lambdaRecorder { } + val navigator = + FakeSecurityAndPrivacyNavigator(openEditRoomAddressLambda = openEditRoomAddressLambda) + val presenter = createSecurityAndPrivacyPresenter(navigator = navigator) + presenter.test { + skipItems(1) + with(awaitItem()) { + eventSink(SecurityAndPrivacyEvent.EditRoomAddress) + } + assert(openEditRoomAddressLambda).isCalledOnce() + } + } + + @Test + fun `present - save success`() = runTest { + val enableEncryptionLambda = lambdaRecorder> { Result.success(Unit) } + val updateJoinRuleLambda = lambdaRecorder> { Result.success(Unit) } + val updateRoomVisibilityLambda = lambdaRecorder> { Result.success(Unit) } + val updateRoomHistoryVisibilityLambda = lambdaRecorder> { Result.success(Unit) } + val room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + roomPermissions = roomPermissions(), + getRoomVisibilityResult = { Result.success(RoomVisibility.Private) }, + initialRoomInfo = aRoomInfo(joinRule = JoinRule.Invite, historyVisibility = RoomHistoryVisibility.Shared) + ), + enableEncryptionResult = enableEncryptionLambda, + updateJoinRuleResult = updateJoinRuleLambda, + updateRoomVisibilityResult = updateRoomVisibilityLambda, + updateRoomHistoryVisibilityResult = updateRoomHistoryVisibilityLambda, + ) + val onDoneLambda = lambdaRecorder { } + val navigator = FakeSecurityAndPrivacyNavigator( + onDoneLambda = onDoneLambda, + ) + val presenter = createSecurityAndPrivacyPresenter( + room = room, + navigator = navigator, + ) + presenter.test { + skipItems(1) + with(awaitItem()) { + assertThat(editedSettings.roomAccess).isEqualTo(SecurityAndPrivacyRoomAccess.InviteOnly) + eventSink(SecurityAndPrivacyEvent.ChangeRoomAccess(SecurityAndPrivacyRoomAccess.Anyone)) + } + with(awaitItem()) { + eventSink(SecurityAndPrivacyEvent.ChangeHistoryVisibility(SecurityAndPrivacyHistoryVisibility.WorldReadable)) + } + with(awaitItem()) { + assertThat(editedSettings.historyVisibility).isEqualTo(SecurityAndPrivacyHistoryVisibility.WorldReadable) + eventSink(SecurityAndPrivacyEvent.ConfirmEnableEncryption) + } + skipItems(1) + with(awaitItem()) { + assertThat(editedSettings.isEncrypted).isTrue() + eventSink(SecurityAndPrivacyEvent.ToggleRoomVisibility) + } + with(awaitItem()) { + assertThat(editedSettings.isVisibleInRoomDirectory).isEqualTo(AsyncData.Success(true)) + eventSink(SecurityAndPrivacyEvent.Save) + } + with(awaitItem()) { + assertThat(saveAction).isEqualTo(AsyncAction.Loading) + } + + room.givenRoomInfo( + aRoomInfo( + joinRule = JoinRule.Public, + historyVisibility = RoomHistoryVisibility.WorldReadable, + isEncrypted = true, + ) + ) + // Saved settings are updated 2 times to match the edited settings + skipItems(2) + with(awaitItem()) { + assertThat(saveAction).isEqualTo(AsyncAction.Success(Unit)) + assertThat(savedSettings).isEqualTo(editedSettings) + assertThat(canBeSaved).isFalse() + } + assert(enableEncryptionLambda).isCalledOnce() + assert(updateJoinRuleLambda).isCalledOnce() + assert(updateRoomVisibilityLambda).isCalledOnce() + assert(updateRoomHistoryVisibilityLambda).isCalledOnce() + onDoneLambda.assertions().isCalledOnce() + } + } + + @Test + fun `present - save failure`() = runTest { + val enableEncryptionLambda = lambdaRecorder> { Result.success(Unit) } + val updateJoinRuleLambda = lambdaRecorder> { Result.success(Unit) } + val updateRoomVisibilityLambda = lambdaRecorder> { + Result.failure(Exception("Failed to update room visibility")) + } + val updateRoomHistoryVisibilityLambda = lambdaRecorder> { Result.success(Unit) } + val room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + roomPermissions = roomPermissions(), + getRoomVisibilityResult = { Result.success(RoomVisibility.Private) }, + initialRoomInfo = aRoomInfo(historyVisibility = RoomHistoryVisibility.Shared, joinRule = JoinRule.Private) + ), + enableEncryptionResult = enableEncryptionLambda, + updateJoinRuleResult = updateJoinRuleLambda, + updateRoomVisibilityResult = updateRoomVisibilityLambda, + updateRoomHistoryVisibilityResult = updateRoomHistoryVisibilityLambda, + ) + val presenter = createSecurityAndPrivacyPresenter(room = room) + presenter.test { + skipItems(1) + with(awaitItem()) { + assertThat(editedSettings.roomAccess).isEqualTo(SecurityAndPrivacyRoomAccess.InviteOnly) + eventSink(SecurityAndPrivacyEvent.ChangeRoomAccess(SecurityAndPrivacyRoomAccess.Anyone)) + } + with(awaitItem()) { + eventSink(SecurityAndPrivacyEvent.ChangeHistoryVisibility(SecurityAndPrivacyHistoryVisibility.WorldReadable)) + } + with(awaitItem()) { + assertThat(editedSettings.historyVisibility).isEqualTo(SecurityAndPrivacyHistoryVisibility.WorldReadable) + eventSink(SecurityAndPrivacyEvent.ConfirmEnableEncryption) + } + skipItems(1) + with(awaitItem()) { + assertThat(editedSettings.isEncrypted).isTrue() + eventSink(SecurityAndPrivacyEvent.ToggleRoomVisibility) + } + with(awaitItem()) { + assertThat(editedSettings.isVisibleInRoomDirectory).isEqualTo(AsyncData.Success(true)) + eventSink(SecurityAndPrivacyEvent.Save) + } + with(awaitItem()) { + assertThat(saveAction).isEqualTo(AsyncAction.Loading) + } + + room.givenRoomInfo( + aRoomInfo( + joinRule = JoinRule.Public, + historyVisibility = RoomHistoryVisibility.WorldReadable, + ) + ) + // Saved settings are updated 2 times to match the edited settings + skipItems(2) + val state = awaitItem() + with(state) { + assertThat(saveAction).isInstanceOf(AsyncAction.Failure::class.java) + assertThat(savedSettings.isVisibleInRoomDirectory).isNotEqualTo(editedSettings.isVisibleInRoomDirectory) + assertThat(canBeSaved).isTrue() + } + assert(enableEncryptionLambda).isCalledOnce() + assert(updateJoinRuleLambda).isCalledOnce() + assert(updateRoomVisibilityLambda).isCalledOnce() + assert(updateRoomHistoryVisibilityLambda).isCalledOnce() + // Clear error + state.eventSink(SecurityAndPrivacyEvent.DismissSaveError) + with(awaitItem()) { + assertThat(saveAction).isEqualTo(AsyncAction.Uninitialized) + } + } + } + + @Test + fun `present - Restricted join rule maps to SpaceMember`() = runTest { + val room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + roomPermissions = roomPermissions(), + initialRoomInfo = aRoomInfo( + joinRule = JoinRule.Restricted( + rules = persistentListOf(AllowRule.RoomMembership(A_ROOM_ID)) + ), + historyVisibility = RoomHistoryVisibility.Shared, + ) + ) + ) + val presenter = createSecurityAndPrivacyPresenter(room = room) + presenter.test { + skipItems(1) + with(awaitItem()) { + assertThat(editedSettings.roomAccess).isInstanceOf(SecurityAndPrivacyRoomAccess.SpaceMember::class.java) + val access = editedSettings.roomAccess as SecurityAndPrivacyRoomAccess.SpaceMember + assertThat(access.spaceIds).containsExactly(A_ROOM_ID) + } + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - SelectSpaceMemberAccess with single space auto-selects`() = runTest { + val room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + roomPermissions = roomPermissions(), + initialRoomInfo = aRoomInfo( + historyVisibility = RoomHistoryVisibility.Shared, + joinRule = JoinRule.Invite + ) + ) + ) + val client = FakeMatrixClient( + userIdServerNameLambda = { "matrix.org" }, + spaceService = FakeSpaceService( + joinedParentsResult = { _ -> + Result.success(listOf(aSpaceRoom(roomId = A_ROOM_ID))) + } + ) + ) + val presenter = createSecurityAndPrivacyPresenter( + room = room, + matrixClient = client, + featureFlagService = FakeFeatureFlagService( + initialState = mapOf( + FeatureFlags.SpaceSettings.key to true, + ) + ) + ) + presenter.test { + skipItems(1) + val state = awaitItem() + assertThat(state.isSpaceMemberSelectable).isTrue() + state.eventSink(SecurityAndPrivacyEvent.SelectSpaceMemberAccess) + with(awaitItem()) { + assertThat(editedSettings.roomAccess).isInstanceOf(SecurityAndPrivacyRoomAccess.SpaceMember::class.java) + val access = editedSettings.roomAccess as SecurityAndPrivacyRoomAccess.SpaceMember + assertThat(access.spaceIds).containsExactly(A_ROOM_ID) + } + } + } + + @Test + fun `present - SelectSpaceMemberAccess with multiple spaces opens ManageAuthorizedSpaces`() = runTest { + val openManageAuthorizedSpacesLambda = lambdaRecorder { } + val navigator = + FakeSecurityAndPrivacyNavigator(openManageAuthorizedSpacesLambda = openManageAuthorizedSpacesLambda) + val room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + roomPermissions = roomPermissions(), + initialRoomInfo = aRoomInfo( + historyVisibility = RoomHistoryVisibility.Shared, + joinRule = JoinRule.Invite + ) + ) + ) + val client = FakeMatrixClient( + userIdServerNameLambda = { "matrix.org" }, + spaceService = FakeSpaceService( + joinedParentsResult = { _ -> + Result.success(listOf(aSpaceRoom(roomId = A_ROOM_ID), aSpaceRoom(roomId = RoomId("!space2:matrix.org")))) + } + ) + ) + val presenter = createSecurityAndPrivacyPresenter( + room = room, + navigator = navigator, + matrixClient = client, + featureFlagService = FakeFeatureFlagService( + initialState = mapOf( + FeatureFlags.SpaceSettings.key to true, + ) + ) + ) + presenter.test { + skipItems(1) + val state = awaitItem() + assertThat(state.isSpaceMemberSelectable).isTrue() + state.eventSink(SecurityAndPrivacyEvent.SelectSpaceMemberAccess) + assert(openManageAuthorizedSpacesLambda).isCalledOnce() + } + } + + @Test + fun `present - SpaceMember saves as Restricted join rule`() = runTest { + val updateJoinRuleLambda = lambdaRecorder> { Result.success(Unit) } + val updateRoomVisibilityLambda = lambdaRecorder> { Result.success(Unit) } + val room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + roomPermissions = roomPermissions(), + getRoomVisibilityResult = { Result.success(RoomVisibility.Private) }, + initialRoomInfo = aRoomInfo( + historyVisibility = RoomHistoryVisibility.Shared, + joinRule = JoinRule.Invite + ) + ), + updateJoinRuleResult = updateJoinRuleLambda, + updateRoomVisibilityResult = updateRoomVisibilityLambda, + ) + val onDoneLambda = lambdaRecorder { } + val navigator = FakeSecurityAndPrivacyNavigator(onDoneLambda = onDoneLambda) + val presenter = createSecurityAndPrivacyPresenter(room = room, navigator = navigator) + presenter.test { + skipItems(1) + with(awaitItem()) { + assertThat(editedSettings.roomAccess).isEqualTo(SecurityAndPrivacyRoomAccess.InviteOnly) + val spaceMemberAccess = SecurityAndPrivacyRoomAccess.SpaceMember( + spaceIds = persistentListOf(A_ROOM_ID) + ) + eventSink(SecurityAndPrivacyEvent.ChangeRoomAccess(spaceMemberAccess)) + } + with(awaitItem()) { + assertThat(editedSettings.roomAccess).isInstanceOf(SecurityAndPrivacyRoomAccess.SpaceMember::class.java) + assertThat(canBeSaved).isTrue() + eventSink(SecurityAndPrivacyEvent.Save) + } + with(awaitItem()) { + assertThat(saveAction).isEqualTo(AsyncAction.Loading) + } + room.givenRoomInfo( + aRoomInfo( + joinRule = JoinRule.Restricted( + rules = persistentListOf(AllowRule.RoomMembership(A_ROOM_ID)) + ), + historyVisibility = RoomHistoryVisibility.Shared, + ) + ) + skipItems(2) + with(awaitItem()) { + assertThat(saveAction).isEqualTo(AsyncAction.Success(Unit)) + } + assert(updateJoinRuleLambda).isCalledOnce().with( + value(JoinRule.Restricted(rules = persistentListOf(AllowRule.RoomMembership(A_ROOM_ID)))) + ) + onDoneLambda.assertions().isCalledOnce() + } + } + + @Test + fun `present - room visibility is NOT configurable for SpaceMember`() = runTest { + val room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + roomPermissions = roomPermissions(), + getRoomVisibilityResult = { Result.success(RoomVisibility.Private) }, + initialRoomInfo = aRoomInfo( + historyVisibility = RoomHistoryVisibility.Shared, + joinRule = JoinRule.Restricted( + rules = persistentListOf(AllowRule.RoomMembership(A_ROOM_ID)) + ) + ) + ) + ) + val presenter = createSecurityAndPrivacyPresenter(room = room) + presenter.test { + skipItems(1) + with(awaitItem()) { + assertThat(editedSettings.roomAccess).isInstanceOf(SecurityAndPrivacyRoomAccess.SpaceMember::class.java) + assertThat(showRoomVisibilitySections).isFalse() + } + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - KnockRestricted join rule maps to AskToJoinWithSpaceMembers`() = runTest { + val room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + roomPermissions = roomPermissions(), + initialRoomInfo = aRoomInfo( + joinRule = JoinRule.KnockRestricted( + rules = persistentListOf(AllowRule.RoomMembership(A_ROOM_ID)) + ), + historyVisibility = RoomHistoryVisibility.Shared, + ) + ) + ) + val presenter = createSecurityAndPrivacyPresenter(room = room) + presenter.test { + skipItems(1) + with(awaitItem()) { + assertThat(editedSettings.roomAccess).isInstanceOf(SecurityAndPrivacyRoomAccess.AskToJoinWithSpaceMember::class.java) + val access = editedSettings.roomAccess as SecurityAndPrivacyRoomAccess.AskToJoinWithSpaceMember + assertThat(access.spaceIds).containsExactly(A_ROOM_ID) + } + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - showAskToJoinWithSpaceMembersOption is true when both FFs enabled and spaces available`() = runTest { + val presenter = createSecurityAndPrivacyPresenter( + featureFlagService = FakeFeatureFlagService( + initialState = mapOf( + FeatureFlags.Knock.key to true, + FeatureFlags.SpaceSettings.key to true, + ) + ) + ) + presenter.test { + skipItems(1) + // Without spaces available, AskToJoinWithSpaceMembers should not be selectable + with(awaitItem()) { + assertThat(isAskToJoinWithSpaceMembersSelectable).isFalse() + assertThat(showAskToJoinWithSpaceMemberOption).isFalse() + // AskToJoin should be shown instead + assertThat(showAskToJoinOption).isTrue() + } + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - SelectAskToJoinWithSpaceMembersAccess with multiple spaces opens ManageAuthorizedSpaces`() = runTest { + val openManageAuthorizedSpacesLambda = lambdaRecorder { } + val navigator = + FakeSecurityAndPrivacyNavigator(openManageAuthorizedSpacesLambda = openManageAuthorizedSpacesLambda) + val room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + roomPermissions = roomPermissions(), + initialRoomInfo = aRoomInfo( + historyVisibility = RoomHistoryVisibility.Shared, + joinRule = JoinRule.Invite + ) + ) + ) + val client = FakeMatrixClient( + userIdServerNameLambda = { "matrix.org" }, + spaceService = FakeSpaceService( + joinedParentsResult = { _ -> + Result.success(listOf(aSpaceRoom(roomId = A_ROOM_ID), aSpaceRoom(roomId = RoomId("!space2:matrix.org")))) + } + ) + ) + val presenter = createSecurityAndPrivacyPresenter( + room = room, + navigator = navigator, + matrixClient = client, + featureFlagService = FakeFeatureFlagService( + initialState = mapOf( + FeatureFlags.Knock.key to true, + FeatureFlags.SpaceSettings.key to true, + ) + ) + ) + presenter.test { + skipItems(1) + // Wait for space selection mode to be set + val state = awaitItem() + assertThat(state.isAskToJoinWithSpaceMembersSelectable).isTrue() + state.eventSink(SecurityAndPrivacyEvent.SelectAskToJoinWithSpaceMembersAccess) + assert(openManageAuthorizedSpacesLambda).isCalledOnce() + } + } + + @Test + fun `present - AskToJoinWithSpaceMember saves as KnockRestricted join rule`() = runTest { + val updateJoinRuleLambda = lambdaRecorder> { Result.success(Unit) } + val room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + roomPermissions = roomPermissions(), + getRoomVisibilityResult = { Result.success(RoomVisibility.Private) }, + initialRoomInfo = aRoomInfo( + historyVisibility = RoomHistoryVisibility.Shared, + joinRule = JoinRule.Invite + ) + ), + updateJoinRuleResult = updateJoinRuleLambda, + ) + val onDoneLambda = lambdaRecorder { } + val navigator = FakeSecurityAndPrivacyNavigator(onDoneLambda = onDoneLambda) + val presenter = createSecurityAndPrivacyPresenter(room = room, navigator = navigator) + presenter.test { + skipItems(1) + with(awaitItem()) { + assertThat(editedSettings.roomAccess).isEqualTo(SecurityAndPrivacyRoomAccess.InviteOnly) + val askToJoinAccess = SecurityAndPrivacyRoomAccess.AskToJoinWithSpaceMember( + spaceIds = persistentListOf(A_ROOM_ID) + ) + eventSink(SecurityAndPrivacyEvent.ChangeRoomAccess(askToJoinAccess)) + } + with(awaitItem()) { + assertThat(editedSettings.roomAccess).isInstanceOf(SecurityAndPrivacyRoomAccess.AskToJoinWithSpaceMember::class.java) + assertThat(canBeSaved).isTrue() + eventSink(SecurityAndPrivacyEvent.Save) + } + with(awaitItem()) { + assertThat(saveAction).isEqualTo(AsyncAction.Loading) + } + room.givenRoomInfo( + aRoomInfo( + joinRule = JoinRule.KnockRestricted( + rules = persistentListOf(AllowRule.RoomMembership(A_ROOM_ID)) + ), + historyVisibility = RoomHistoryVisibility.Shared, + ) + ) + // Saved settings are updated multiple times to match the edited settings + skipItems(2) + with(awaitItem()) { + assertThat(saveAction).isEqualTo(AsyncAction.Success(Unit)) + } + assert(updateJoinRuleLambda).isCalledOnce().with( + value(JoinRule.KnockRestricted(rules = persistentListOf(AllowRule.RoomMembership(A_ROOM_ID)))) + ) + onDoneLambda.assertions().isCalledOnce() + } + } + + @Test + fun `present - room visibility is configurable for AskToJoinWithSpaceMember`() = runTest { + val room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + roomPermissions = roomPermissions(), + getRoomVisibilityResult = { Result.success(RoomVisibility.Private) }, + initialRoomInfo = aRoomInfo( + historyVisibility = RoomHistoryVisibility.Shared, + joinRule = JoinRule.KnockRestricted( + rules = persistentListOf(AllowRule.RoomMembership(A_ROOM_ID)) + ) + ) + ) + ) + val presenter = createSecurityAndPrivacyPresenter(room = room) + presenter.test { + skipItems(1) + with(awaitItem()) { + assertThat(editedSettings.roomAccess).isInstanceOf(SecurityAndPrivacyRoomAccess.AskToJoinWithSpaceMember::class.java) + assertThat(showRoomVisibilitySections).isTrue() + } + with(awaitItem()) { + assertThat(editedSettings.isVisibleInRoomDirectory).isEqualTo(AsyncData.Success(false)) + eventSink(SecurityAndPrivacyEvent.ToggleRoomVisibility) + } + with(awaitItem()) { + assertThat(editedSettings.isVisibleInRoomDirectory).isEqualTo(AsyncData.Success(true)) + assertThat(canBeSaved).isTrue() + } + } + } + + @Test + fun `present - availableHistoryVisibilities includes WorldReadable for Anyone without encryption`() = runTest { + val room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + roomPermissions = roomPermissions(), + initialRoomInfo = aRoomInfo( + joinRule = JoinRule.Public, + historyVisibility = RoomHistoryVisibility.Shared, + ) + ) + ) + val presenter = createSecurityAndPrivacyPresenter(room = room) + presenter.test { + skipItems(1) + with(awaitItem()) { + assertThat(editedSettings.roomAccess).isEqualTo(SecurityAndPrivacyRoomAccess.Anyone) + assertThat(editedSettings.isEncrypted).isFalse() + assertThat(availableHistoryVisibilities).contains(SecurityAndPrivacyHistoryVisibility.WorldReadable) + assertThat(availableHistoryVisibilities).doesNotContain(SecurityAndPrivacyHistoryVisibility.Invited) + } + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - availableHistoryVisibilities includes Invited for InviteOnly access`() = runTest { + val presenter = createSecurityAndPrivacyPresenter() + presenter.test { + skipItems(1) + with(awaitItem()) { + assertThat(editedSettings.roomAccess).isEqualTo(SecurityAndPrivacyRoomAccess.InviteOnly) + assertThat(availableHistoryVisibilities).contains(SecurityAndPrivacyHistoryVisibility.Invited) + assertThat(availableHistoryVisibilities).doesNotContain(SecurityAndPrivacyHistoryVisibility.WorldReadable) + } + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - availableHistoryVisibilities excludes WorldReadable when encrypted`() = runTest { + val room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + roomPermissions = roomPermissions(), + initialRoomInfo = aRoomInfo( + joinRule = JoinRule.Public, + historyVisibility = RoomHistoryVisibility.Shared, + isEncrypted = true, + ) + ) + ) + val presenter = createSecurityAndPrivacyPresenter(room = room) + presenter.test { + skipItems(1) + with(awaitItem()) { + assertThat(editedSettings.roomAccess).isEqualTo(SecurityAndPrivacyRoomAccess.Anyone) + assertThat(editedSettings.isEncrypted).isTrue() + assertThat(availableHistoryVisibilities).contains(SecurityAndPrivacyHistoryVisibility.Invited) + assertThat(availableHistoryVisibilities).doesNotContain(SecurityAndPrivacyHistoryVisibility.WorldReadable) + } + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - showSpaceMemberOption is true when savedSettings has SpaceMember`() = runTest { + val room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + roomPermissions = roomPermissions(), + initialRoomInfo = aRoomInfo( + joinRule = JoinRule.Restricted( + rules = persistentListOf(AllowRule.RoomMembership(A_ROOM_ID)) + ), + historyVisibility = RoomHistoryVisibility.Shared, + ) + ) + ) + // No spaces available, so isSpaceMemberSelectable should be false + val presenter = createSecurityAndPrivacyPresenter(room = room) + presenter.test { + skipItems(1) + with(awaitItem()) { + assertThat(savedSettings.roomAccess).isInstanceOf(SecurityAndPrivacyRoomAccess.SpaceMember::class.java) + assertThat(isSpaceMemberSelectable).isFalse() + // showSpaceMemberOption should still be true because savedSettings has SpaceMember + assertThat(showSpaceMemberOption).isTrue() + } + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - showSpaceMemberOption is false when not selectable and savedSettings is not SpaceMember`() = runTest { + // No spaces available, default InviteOnly join rule + val presenter = createSecurityAndPrivacyPresenter() + presenter.test { + skipItems(1) + with(awaitItem()) { + assertThat(savedSettings.roomAccess).isEqualTo(SecurityAndPrivacyRoomAccess.InviteOnly) + assertThat(isSpaceMemberSelectable).isFalse() + assertThat(showSpaceMemberOption).isFalse() + } + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - showManageSpaceFooter is true when Multiple mode and SpaceMember access`() = runTest { + val room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + roomPermissions = roomPermissions(), + initialRoomInfo = aRoomInfo( + historyVisibility = RoomHistoryVisibility.Shared, + joinRule = JoinRule.Invite + ) + ) + ) + val client = FakeMatrixClient( + userIdServerNameLambda = { "matrix.org" }, + spaceService = FakeSpaceService( + joinedParentsResult = { _ -> + Result.success(listOf(aSpaceRoom(roomId = A_ROOM_ID), aSpaceRoom(roomId = RoomId("!space2:matrix.org")))) + } + ) + ) + val presenter = createSecurityAndPrivacyPresenter( + room = room, + matrixClient = client, + featureFlagService = FakeFeatureFlagService( + initialState = mapOf(FeatureFlags.SpaceSettings.key to true) + ) + ) + presenter.test { + skipItems(1) + val state = awaitItem() + // Change to SpaceMember access + val spaceMemberAccess = SecurityAndPrivacyRoomAccess.SpaceMember( + spaceIds = persistentListOf(A_ROOM_ID) + ) + state.eventSink(SecurityAndPrivacyEvent.ChangeRoomAccess(spaceMemberAccess)) + with(awaitItem()) { + assertThat(editedSettings.roomAccess).isInstanceOf(SecurityAndPrivacyRoomAccess.SpaceMember::class.java) + assertThat(showManageSpaceFooter).isTrue() + } + } + } + + @Test + fun `present - showManageSpaceFooter is false when Single mode`() = runTest { + val room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + roomPermissions = roomPermissions(), + initialRoomInfo = aRoomInfo( + historyVisibility = RoomHistoryVisibility.Shared, + joinRule = JoinRule.Invite + ) + ) + ) + // Single space available + val client = FakeMatrixClient( + userIdServerNameLambda = { "matrix.org" }, + spaceService = FakeSpaceService( + joinedParentsResult = { _ -> + Result.success(listOf(aSpaceRoom(roomId = A_ROOM_ID))) + } + ) + ) + val presenter = createSecurityAndPrivacyPresenter( + room = room, + matrixClient = client, + featureFlagService = FakeFeatureFlagService( + initialState = mapOf(FeatureFlags.SpaceSettings.key to true) + ) + ) + presenter.test { + skipItems(1) + val state = awaitItem() + // Select SpaceMember access (single space auto-selects) + state.eventSink(SecurityAndPrivacyEvent.SelectSpaceMemberAccess) + with(awaitItem()) { + assertThat(editedSettings.roomAccess).isInstanceOf(SecurityAndPrivacyRoomAccess.SpaceMember::class.java) + // Single mode, so no footer + assertThat(showManageSpaceFooter).isFalse() + } + } + } + + @Test + fun `present - isAskToJoinSelectable is true when Knock FF enabled`() = runTest { + val presenter = createSecurityAndPrivacyPresenter( + featureFlagService = FakeFeatureFlagService( + initialState = mapOf(FeatureFlags.Knock.key to true) + ) + ) + presenter.test { + skipItems(1) + with(awaitItem()) { + assertThat(isAskToJoinSelectable).isTrue() + } + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - isAskToJoinSelectable is false when Knock FF disabled`() = runTest { + val presenter = createSecurityAndPrivacyPresenter( + featureFlagService = FakeFeatureFlagService( + initialState = mapOf(FeatureFlags.Knock.key to false) + ) + ) + presenter.test { + skipItems(1) + with(awaitItem()) { + assertThat(isAskToJoinSelectable).isFalse() + } + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - SelectAskToJoinWithSpaceMembersAccess with single space auto-selects`() = runTest { + val room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + roomPermissions = roomPermissions(), + initialRoomInfo = aRoomInfo( + historyVisibility = RoomHistoryVisibility.Shared, + joinRule = JoinRule.Invite + ) + ) + ) + val client = FakeMatrixClient( + userIdServerNameLambda = { "matrix.org" }, + spaceService = FakeSpaceService( + joinedParentsResult = { _ -> + Result.success(listOf(aSpaceRoom(roomId = A_ROOM_ID))) + } + ) + ) + val presenter = createSecurityAndPrivacyPresenter( + room = room, + matrixClient = client, + featureFlagService = FakeFeatureFlagService( + initialState = mapOf( + FeatureFlags.Knock.key to true, + FeatureFlags.SpaceSettings.key to true, + ) + ) + ) + presenter.test { + skipItems(1) + val state = awaitItem() + assertThat(state.isAskToJoinWithSpaceMembersSelectable).isTrue() + state.eventSink(SecurityAndPrivacyEvent.SelectAskToJoinWithSpaceMembersAccess) + with(awaitItem()) { + assertThat(editedSettings.roomAccess).isInstanceOf(SecurityAndPrivacyRoomAccess.AskToJoinWithSpaceMember::class.java) + val access = editedSettings.roomAccess as SecurityAndPrivacyRoomAccess.AskToJoinWithSpaceMember + assertThat(access.spaceIds).containsExactly(A_ROOM_ID) + } + } + } + + @Test + fun `present - showAskToJoinOption is true when savedSettings is AskToJoin`() = runTest { + val room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + roomPermissions = roomPermissions(), + initialRoomInfo = aRoomInfo( + joinRule = JoinRule.Knock, + historyVisibility = RoomHistoryVisibility.Shared, + ) + ) + ) + // Knock FF disabled, but showAskToJoinOption should still be true because savedSettings has AskToJoin + val presenter = createSecurityAndPrivacyPresenter( + room = room, + featureFlagService = FakeFeatureFlagService( + initialState = mapOf(FeatureFlags.Knock.key to false) + ) + ) + presenter.test { + skipItems(1) + with(awaitItem()) { + assertThat(savedSettings.roomAccess).isEqualTo(SecurityAndPrivacyRoomAccess.AskToJoin) + assertThat(isAskToJoinSelectable).isFalse() + assertThat(showAskToJoinOption).isTrue() + } + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - showHistoryVisibilitySection is false for space`() = runTest { + val room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + roomPermissions = roomPermissions(), + initialRoomInfo = aRoomInfo( + historyVisibility = RoomHistoryVisibility.Shared, + joinRule = JoinRule.Invite, + isSpace = true, + ) + ) + ) + val presenter = createSecurityAndPrivacyPresenter(room = room) + presenter.test { + skipItems(1) + with(awaitItem()) { + assertThat(showHistoryVisibilitySection).isFalse() + } + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - showEncryptionSection is false for space`() = runTest { + val room = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + roomPermissions = roomPermissions(), + initialRoomInfo = aRoomInfo( + historyVisibility = RoomHistoryVisibility.Shared, + joinRule = JoinRule.Invite, + isSpace = true, + ) + ) + ) + val presenter = createSecurityAndPrivacyPresenter(room = room) + presenter.test { + skipItems(1) + with(awaitItem()) { + assertThat(showEncryptionSection).isFalse() + } + cancelAndIgnoreRemainingEvents() + } + } + + private fun roomPermissions( + canChangeRoomAccess: Boolean = true, + canChangeHistoryVisibility: Boolean = true, + canChangeEncryption: Boolean = true, + canChangeRoomVisibility: Boolean = true, + ): RoomPermissions { + return FakeRoomPermissions( + canSendState = { eventType -> + when (eventType) { + StateEventType.RoomJoinRules -> canChangeRoomAccess + StateEventType.RoomHistoryVisibility -> canChangeHistoryVisibility + StateEventType.RoomEncryption -> canChangeEncryption + StateEventType.RoomCanonicalAlias -> canChangeRoomVisibility + else -> lambdaError() + } + } + ) + } + + private fun createSecurityAndPrivacyPresenter( + serverName: String = "matrix.org", + room: FakeJoinedRoom = FakeJoinedRoom( + baseRoom = FakeBaseRoom( + roomPermissions = roomPermissions(), + getRoomVisibilityResult = { Result.success(RoomVisibility.Private) }, + initialRoomInfo = aRoomInfo(historyVisibility = RoomHistoryVisibility.Shared, joinRule = JoinRule.Private) + ), + ), + navigator: SecurityAndPrivacyNavigator = FakeSecurityAndPrivacyNavigator(), + featureFlagService: FeatureFlagService = FakeFeatureFlagService(), + matrixClient: MatrixClient = FakeMatrixClient( + userIdServerNameLambda = { serverName }, + spaceService = FakeSpaceService( + joinedParentsResult = { Result.success(emptyList()) }, + getSpaceRoomResult = { null } + ), + ), + spaceSelectionStateHolder: SpaceSelectionStateHolder = SpaceSelectionStateHolder(), + ): SecurityAndPrivacyPresenter { + return SecurityAndPrivacyPresenter( + room = room, + matrixClient = matrixClient, + navigator = navigator, + featureFlagService = featureFlagService, + spaceSelectionStateHolder = spaceSelectionStateHolder, + ) + } +} diff --git a/features/securityandprivacy/impl/src/test/kotlin/io/element/android/features/securityandprivacy/impl/SecurityAndPrivacyViewTest.kt b/features/securityandprivacy/impl/src/test/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyViewTest.kt similarity index 52% rename from features/securityandprivacy/impl/src/test/kotlin/io/element/android/features/securityandprivacy/impl/SecurityAndPrivacyViewTest.kt rename to features/securityandprivacy/impl/src/test/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyViewTest.kt index 78b840d823e..a1f46b29381 100644 --- a/features/securityandprivacy/impl/src/test/kotlin/io/element/android/features/securityandprivacy/impl/SecurityAndPrivacyViewTest.kt +++ b/features/securityandprivacy/impl/src/test/kotlin/io/element/android/features/securityandprivacy/impl/root/SecurityAndPrivacyViewTest.kt @@ -1,12 +1,11 @@ /* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2025 New Vector Ltd. + * 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.securityandprivacy.impl +package io.element.android.features.securityandprivacy.impl.root import androidx.activity.ComponentActivity import androidx.compose.ui.test.junit4.AndroidComposeTestRule @@ -14,20 +13,16 @@ import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.test.ext.junit.runners.AndroidJUnit4 -import io.element.android.features.securityandprivacy.impl.root.SecurityAndPrivacyEvents -import io.element.android.features.securityandprivacy.impl.root.SecurityAndPrivacyHistoryVisibility -import io.element.android.features.securityandprivacy.impl.root.SecurityAndPrivacyRoomAccess -import io.element.android.features.securityandprivacy.impl.root.SecurityAndPrivacyState -import io.element.android.features.securityandprivacy.impl.root.SecurityAndPrivacyView -import io.element.android.features.securityandprivacy.impl.root.aSecurityAndPrivacySettings -import io.element.android.features.securityandprivacy.impl.root.aSecurityAndPrivacyState +import io.element.android.features.securityandprivacy.impl.R import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.matrix.test.A_ROOM_ID 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.pressBack +import kotlinx.collections.immutable.persistentListOf import org.junit.Rule import org.junit.Test import org.junit.rules.TestRule @@ -40,53 +35,53 @@ class SecurityAndPrivacyViewTest { @Test fun `click on back invokes emits the expected event`() { - val recorder = EventsRecorder() + val recorder = EventsRecorder() val state = aSecurityAndPrivacyState( eventSink = recorder, ) rule.setSecurityAndPrivacyView(state) rule.pressBack() - recorder.assertSingle(SecurityAndPrivacyEvents.Exit) + recorder.assertSingle(SecurityAndPrivacyEvent.Exit) } @Test - fun `confirm cancellation emits the expected event`() { - val recorder = EventsRecorder() + fun `discard cancellation emits the expected event`() { + val recorder = EventsRecorder() val state = aSecurityAndPrivacyState( - confirmExitAction = AsyncAction.ConfirmingCancellation, + saveAction = AsyncAction.ConfirmingCancellation, eventSink = recorder, ) rule.setSecurityAndPrivacyView(state) - rule.clickOn(CommonStrings.action_ok) - recorder.assertSingle(SecurityAndPrivacyEvents.Exit) + rule.clickOn(CommonStrings.action_discard) + recorder.assertSingle(SecurityAndPrivacyEvent.Exit) } @Test - fun `dismiss cancellation confirmation emits the expected event`() { - val recorder = EventsRecorder() + fun `save cancellation confirmation emits the expected event`() { + val recorder = EventsRecorder() val state = aSecurityAndPrivacyState( - confirmExitAction = AsyncAction.ConfirmingCancellation, + saveAction = AsyncAction.ConfirmingCancellation, eventSink = recorder, ) rule.setSecurityAndPrivacyView(state) - rule.clickOn(CommonStrings.action_cancel) - recorder.assertSingle(SecurityAndPrivacyEvents.DismissExitConfirmation) + rule.clickOn(CommonStrings.action_save, inDialog = true) + recorder.assertSingle(SecurityAndPrivacyEvent.Save) } @Test fun `click on room access item emits the expected event`() { - val recorder = EventsRecorder() + val recorder = EventsRecorder() val state = aSecurityAndPrivacyState( eventSink = recorder, ) rule.setSecurityAndPrivacyView(state) rule.clickOn(R.string.screen_security_and_privacy_room_access_invite_only_option_title) - recorder.assertSingle(SecurityAndPrivacyEvents.ChangeRoomAccess(SecurityAndPrivacyRoomAccess.InviteOnly)) + recorder.assertSingle(SecurityAndPrivacyEvent.ChangeRoomAccess(SecurityAndPrivacyRoomAccess.InviteOnly)) } @Test fun `click on disabled save doesn't emit event`() { - val recorder = EventsRecorder(expectEvents = false) + val recorder = EventsRecorder(expectEvents = false) val state = aSecurityAndPrivacyState(eventSink = recorder) rule.setSecurityAndPrivacyView(state) rule.clickOn(CommonStrings.action_save) @@ -95,7 +90,7 @@ class SecurityAndPrivacyViewTest { @Test fun `click on enabled save emits the expected event`() { - val recorder = EventsRecorder() + val recorder = EventsRecorder() val state = aSecurityAndPrivacyState( eventSink = recorder, editedSettings = aSecurityAndPrivacySettings( @@ -104,14 +99,14 @@ class SecurityAndPrivacyViewTest { ) rule.setSecurityAndPrivacyView(state) rule.clickOn(CommonStrings.action_save) - recorder.assertSingle(SecurityAndPrivacyEvents.Save) + recorder.assertSingle(SecurityAndPrivacyEvent.Save) } @Test @Config(qualifiers = "h640dp") fun `click on room address item emits the expected event`() { val address = "@alias:matrix.org" - val recorder = EventsRecorder() + val recorder = EventsRecorder() val state = aSecurityAndPrivacyState( eventSink = recorder, editedSettings = aSecurityAndPrivacySettings( @@ -121,13 +116,13 @@ class SecurityAndPrivacyViewTest { ) rule.setSecurityAndPrivacyView(state) rule.onNodeWithText(address).performClick() - recorder.assertSingle(SecurityAndPrivacyEvents.EditRoomAddress) + recorder.assertSingle(SecurityAndPrivacyEvent.EditRoomAddress) } @Test @Config(qualifiers = "h1024dp") fun `click on room visibility item emits the expected event`() { - val recorder = EventsRecorder() + val recorder = EventsRecorder() val state = aSecurityAndPrivacyState( eventSink = recorder, editedSettings = aSecurityAndPrivacySettings( @@ -137,47 +132,91 @@ class SecurityAndPrivacyViewTest { ) rule.setSecurityAndPrivacyView(state) rule.clickOn(R.string.screen_security_and_privacy_room_directory_visibility_toggle_title) - recorder.assertSingle(SecurityAndPrivacyEvents.ToggleRoomVisibility) + recorder.assertSingle(SecurityAndPrivacyEvent.ToggleRoomVisibility) } @Test - @Config(qualifiers = "h640dp") + @Config(qualifiers = "h1024dp") fun `click on history visibility item emits the expected event`() { - val recorder = EventsRecorder() + val recorder = EventsRecorder() val state = aSecurityAndPrivacyState( eventSink = recorder, editedSettings = aSecurityAndPrivacySettings( - historyVisibility = SecurityAndPrivacyHistoryVisibility.SinceSelection, + historyVisibility = SecurityAndPrivacyHistoryVisibility.Invited, ), ) rule.setSecurityAndPrivacyView(state) - rule.clickOn(R.string.screen_security_and_privacy_room_history_since_selecting_option_title) - recorder.assertSingle(SecurityAndPrivacyEvents.ChangeHistoryVisibility(SecurityAndPrivacyHistoryVisibility.SinceSelection)) + rule.clickOn(R.string.screen_security_and_privacy_room_history_since_invite_option_title) + recorder.assertSingle(SecurityAndPrivacyEvent.ChangeHistoryVisibility(SecurityAndPrivacyHistoryVisibility.Invited)) } @Test - @Config(qualifiers = "h640dp") + @Config(qualifiers = "h1024dp") fun `click on encryption item emits the expected event`() { - val recorder = EventsRecorder() + val recorder = EventsRecorder() val state = aSecurityAndPrivacyState( eventSink = recorder, savedSettings = aSecurityAndPrivacySettings(isEncrypted = false), ) rule.setSecurityAndPrivacyView(state) rule.clickOn(R.string.screen_security_and_privacy_encryption_toggle_title) - recorder.assertSingle(SecurityAndPrivacyEvents.ToggleEncryptionState) + recorder.assertSingle(SecurityAndPrivacyEvent.ToggleEncryptionState) } @Test fun `click on encryption confirm emits the expected event`() { - val recorder = EventsRecorder() + val recorder = EventsRecorder() val state = aSecurityAndPrivacyState( eventSink = recorder, showEncryptionConfirmation = true, ) rule.setSecurityAndPrivacyView(state) rule.clickOn(R.string.screen_security_and_privacy_enable_encryption_alert_confirm_button_title) - recorder.assertSingle(SecurityAndPrivacyEvents.ConfirmEnableEncryption) + recorder.assertSingle(SecurityAndPrivacyEvent.ConfirmEnableEncryption) + } + + @Test + @Config(qualifiers = "h1024dp") + fun `click on space member access emits the expected event`() { + val recorder = EventsRecorder() + val state = aSecurityAndPrivacyState( + eventSink = recorder, + spaceSelectionMode = SpaceSelectionMode.Single(A_ROOM_ID, null), + ) + rule.setSecurityAndPrivacyView(state) + rule.clickOn(R.string.screen_security_and_privacy_room_access_space_members_option_title) + recorder.assertSingle(SecurityAndPrivacyEvent.SelectSpaceMemberAccess) + } + + @Test + @Config(qualifiers = "h1024dp") + fun `click on ask to join with space members emits the expected event`() { + val recorder = EventsRecorder() + val state = aSecurityAndPrivacyState( + eventSink = recorder, + spaceSelectionMode = SpaceSelectionMode.Single(A_ROOM_ID, null), + ) + rule.setSecurityAndPrivacyView(state) + rule.clickOn(R.string.screen_security_and_privacy_ask_to_join_option_title) + recorder.assertSingle(SecurityAndPrivacyEvent.SelectAskToJoinWithSpaceMembersAccess) + } + + @Test + @Config(qualifiers = "h1024dp") + fun `manage spaces footer is shown when space member access is selected`() { + val recorder = EventsRecorder(expectEvents = false) + val state = aSecurityAndPrivacyState( + eventSink = recorder, + spaceSelectionMode = SpaceSelectionMode.Multiple, + editedSettings = aSecurityAndPrivacySettings( + roomAccess = SecurityAndPrivacyRoomAccess.SpaceMember(persistentListOf(A_ROOM_ID)), + ), + ) + rule.setSecurityAndPrivacyView(state) + // The footer text uses AnnotatedString with a link. Verify the footer text is displayed. + val actionFooterText = rule.activity.getString(R.string.screen_security_and_privacy_room_access_footer_manage_spaces_action) + val footerText = rule.activity.getString(R.string.screen_security_and_privacy_room_access_footer, actionFooterText) + rule.onNodeWithText(footerText).assertExists() } } @@ -185,12 +224,12 @@ private fun AndroidComposeTestRule.setSecur state: SecurityAndPrivacyState = aSecurityAndPrivacyState( eventSink = EventsRecorder(expectEvents = false), ), - onBackClick: () -> Unit = EnsureNeverCalled(), + onLinkClick: (String) -> Unit = EnsureNeverCalledWithParam(), ) { setContent { SecurityAndPrivacyView( state = state, - onBackClick = onBackClick, + onLinkClick = onLinkClick, ) } } diff --git a/features/securityandprivacy/test/src/main/kotlin/io/element/android/features/securityandprivacy/test/FakeSecurityAndPrivacyEntryPoint.kt b/features/securityandprivacy/test/src/main/kotlin/io/element/android/features/securityandprivacy/test/FakeSecurityAndPrivacyEntryPoint.kt index f316b2fe96e..a66fe55bffe 100644 --- a/features/securityandprivacy/test/src/main/kotlin/io/element/android/features/securityandprivacy/test/FakeSecurityAndPrivacyEntryPoint.kt +++ b/features/securityandprivacy/test/src/main/kotlin/io/element/android/features/securityandprivacy/test/FakeSecurityAndPrivacyEntryPoint.kt @@ -13,7 +13,11 @@ import io.element.android.features.securityandprivacy.api.SecurityAndPrivacyEntr import io.element.android.tests.testutils.lambda.lambdaError class FakeSecurityAndPrivacyEntryPoint : SecurityAndPrivacyEntryPoint { - override fun createNode(parentNode: Node, buildContext: BuildContext): Node { + override fun createNode( + parentNode: Node, + buildContext: BuildContext, + callback: SecurityAndPrivacyEntryPoint.Callback, + ): Node { lambdaError() } } diff --git a/features/signedout/impl/src/main/res/values-hr/translations.xml b/features/signedout/impl/src/main/res/values-hr/translations.xml new file mode 100644 index 00000000000..17af7626b13 --- /dev/null +++ b/features/signedout/impl/src/main/res/values-hr/translations.xml @@ -0,0 +1,8 @@ + + + "Promijenili ste zaporku u drugoj sesiji" + "Izbrisali ste sesiju iz druge sesije" + "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" + diff --git a/features/space/impl/build.gradle.kts b/features/space/impl/build.gradle.kts index d212bac36b6..03c8cda4ac2 100644 --- a/features/space/impl/build.gradle.kts +++ b/features/space/impl/build.gradle.kts @@ -40,6 +40,9 @@ dependencies { implementation(projects.libraries.featureflag.api) implementation(projects.features.invite.api) implementation(projects.libraries.previewutils) + implementation(projects.features.securityandprivacy.api) + implementation(projects.features.rolesandpermissions.api) + implementation(projects.features.roomdetailsedit.api) api(projects.features.space.api) testCommonDependencies(libs, true) 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 036eab1c220..4c91da03014 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 @@ -28,7 +28,7 @@ import io.element.android.features.space.api.SpaceEntryPoint import io.element.android.features.space.impl.di.SpaceFlowGraph import io.element.android.features.space.impl.leave.LeaveSpaceNode import io.element.android.features.space.impl.root.SpaceNode -import io.element.android.features.space.impl.settings.SpaceSettingsNode +import io.element.android.features.space.impl.settings.SpaceSettingsFlowNode import io.element.android.libraries.architecture.BackstackView import io.element.android.libraries.architecture.BaseFlowNode import io.element.android.libraries.architecture.callback @@ -65,7 +65,7 @@ class SpaceFlowNode( data object Root : NavTarget @Parcelize - data object Settings : NavTarget + data class Settings(val initialTarget: SpaceSettingsFlowNode.NavTarget = SpaceSettingsFlowNode.NavTarget.Root) : NavTarget @Parcelize data object Leave : NavTarget @@ -89,7 +89,7 @@ class SpaceFlowNode( } override fun navigateToRolesAndPermissions() { - // TODO + backstack.push(NavTarget.Settings(SpaceSettingsFlowNode.NavTarget.RolesAndPermissions)) } } createNode(buildContext, listOf(callback)) @@ -101,7 +101,7 @@ class SpaceFlowNode( } override fun navigateToSpaceSettings() { - backstack.push(NavTarget.Settings) + backstack.push(NavTarget.Settings()) } override fun navigateToRoomMemberList() { @@ -114,33 +114,23 @@ class SpaceFlowNode( } createNode(buildContext, listOf(callback)) } - NavTarget.Settings -> { - val callback = object : SpaceSettingsNode.Callback { - override fun closeSettings() { - backstack.pop() - } - - override fun navigateToSpaceInfo() { - // TODO - } + is NavTarget.Settings -> { + val callback = object : SpaceSettingsFlowNode.Callback { + override fun initialTarget() = navTarget.initialTarget override fun navigateToSpaceMembers() { callback.navigateToRoomMemberList() } - override fun navigateToRolesAndPermissions() { - // TODO - } - - override fun navigateToSecurityAndPrivacy() { - // TODO - } - override fun startLeaveSpaceFlow() { backstack.push(NavTarget.Leave) } + + override fun closeSettings() { + backstack.pop() + } } - createNode(buildContext, listOf(callback)) + createNode(buildContext, listOf(callback)) } } } 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 02598f25edc..d405162b881 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 @@ -132,8 +132,7 @@ fun LeaveSpaceView( state.eventSink(LeaveSpaceEvents.LeaveSpace) }, onCancel = onCancel, - // TODO enable when navigation is ready - showRolesAndPermissionsButton = false, // state.isLastAdmin, + showRolesAndPermissionsButton = state.isLastAdmin, onRolesAndPermissionsClick = onRolesAndPermissionsClick, ) } diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceEvents.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceEvents.kt index 16a6ad1c3f2..0f17a2f6f79 100644 --- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceEvents.kt +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceEvents.kt @@ -8,6 +8,7 @@ package io.element.android.features.space.impl.root +import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.spaces.SpaceRoom sealed interface SpaceEvents { @@ -19,4 +20,12 @@ sealed interface SpaceEvents { data class ShowTopicViewer(val topic: String) : SpaceEvents data object HideTopicViewer : SpaceEvents + + // Manage mode events + data object EnterManageMode : SpaceEvents + data object ExitManageMode : SpaceEvents + data class ToggleRoomSelection(val roomId: RoomId) : SpaceEvents + data object ConfirmRoomRemoval : SpaceEvents + data object RemoveSelectedRooms : SpaceEvents + data object ClearRemoveAction : SpaceEvents } diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceNode.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceNode.kt index 40fa1273e16..240bc899904 100644 --- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceNode.kt +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceNode.kt @@ -80,7 +80,7 @@ class SpaceNode( onRoomClick = { spaceRoom -> callback.navigateToRoom(spaceRoom.roomId, spaceRoom.via) }, - onDetailsClick = { + onSettingsClick = { callback.navigateToSpaceSettings() }, onShareSpace = { diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpacePermissions.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpacePermissions.kt new file mode 100644 index 00000000000..fc9f5c9b0c6 --- /dev/null +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpacePermissions.kt @@ -0,0 +1,37 @@ +/* + * 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.space.impl.root + +import io.element.android.features.space.impl.settings.SpaceSettingsPermissions +import io.element.android.features.space.impl.settings.spaceSettingsPermissions +import io.element.android.libraries.matrix.api.room.StateEventType +import io.element.android.libraries.matrix.api.room.powerlevels.RoomPermissions + +/** + * Permissions needed for different actions in the Space screen. + * @param settingsPermissions Permissions related to space settings. + * @param canEditSpaceGraph Whether the user can edit the space graph (add/remove children). + */ +data class SpacePermissions( + val settingsPermissions: SpaceSettingsPermissions, + val canEditSpaceGraph: Boolean, +) { + companion object { + val DEFAULT = SpacePermissions( + settingsPermissions = SpaceSettingsPermissions.DEFAULT, + canEditSpaceGraph = false, + ) + } +} + +fun RoomPermissions.spacePermissions(): SpacePermissions { + return SpacePermissions( + settingsPermissions = spaceSettingsPermissions(), + canEditSpaceGraph = canOwnUserSendState(StateEventType.SpaceChild) || canOwnUserSendState(StateEventType.SpaceParent), + ) +} diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpacePresenter.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpacePresenter.kt index ce76aa457f0..a5bc2ec52eb 100644 --- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpacePresenter.kt +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpacePresenter.kt @@ -11,13 +11,14 @@ package io.element.android.features.space.impl.root import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf 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 dev.zacsweers.metro.Inject -import im.vector.app.features.analytics.plan.JoinedRoom +import im.vector.app.features.analytics.plan.JoinedRoom.Trigger import io.element.android.features.invite.api.SeenInvitesStore import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteEvents import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteState @@ -26,13 +27,18 @@ import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.core.coroutine.mapState 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.RoomId import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias +import io.element.android.libraries.matrix.api.room.BaseRoom import io.element.android.libraries.matrix.api.room.CurrentUserMembership import io.element.android.libraries.matrix.api.room.join.JoinRoom +import io.element.android.libraries.matrix.api.room.powerlevels.permissionsAsState import io.element.android.libraries.matrix.api.spaces.SpaceRoom import io.element.android.libraries.matrix.api.spaces.SpaceRoomList +import io.element.android.libraries.matrix.api.spaces.SpaceService import io.element.android.libraries.matrix.ui.safety.rememberHideInvitesAvatar import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -41,18 +47,22 @@ import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableMap import kotlinx.collections.immutable.toImmutableSet import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch -import kotlin.jvm.optionals.getOrNull @Inject class SpacePresenter( private val spaceRoomList: SpaceRoomList, + private val room: BaseRoom, private val client: MatrixClient, private val seenInvitesStore: SeenInvitesStore, private val joinRoom: JoinRoom, private val acceptDeclineInvitePresenter: Presenter, @SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope, + private val featureFlagService: FeatureFlagService, + private val spaceService: SpaceService, ) : Presenter { private var children by mutableStateOf>(persistentListOf()) @@ -79,11 +89,46 @@ class SpacePresenter( } }.collectAsState() - val currentSpace by spaceRoomList.currentSpaceFlow.collectAsState() + val permissions by room.permissionsAsState(SpacePermissions.DEFAULT) { perms -> + perms.spacePermissions() + } + val isSpaceSettingsEnabled by remember { + featureFlagService.isFeatureEnabledFlow(FeatureFlags.SpaceSettings) + }.collectAsState(false) + + val roomInfo by room.roomInfoFlow.collectAsState() + val canAccessSpaceSettings by remember { + derivedStateOf { isSpaceSettingsEnabled && permissions.settingsPermissions.hasAny(roomInfo.joinRule) } + } + val canEditSpaceGraph by remember { + derivedStateOf { isSpaceSettingsEnabled && permissions.canEditSpaceGraph } + } val (joinActions, setJoinActions) = remember { mutableStateOf(emptyMap>()) } var topicViewerState: TopicViewerState by remember { mutableStateOf(TopicViewerState.Hidden) } + // Manage mode state + var isManageMode by remember { mutableStateOf(false) } + var selectedRoomIds by remember { mutableStateOf>(emptySet()) } + var removeRoomsAction by remember { mutableStateOf>(AsyncAction.Uninitialized) } + var removedRoomIds by remember { mutableStateOf>(emptySet()) } + + val filteredChildren by remember { + derivedStateOf { + children + .filterNot { it.roomId in removedRoomIds } + .let { list -> + if (isManageMode) { + // In manage mode, only show rooms (not spaces) + list.filter { !it.isSpace } + } else { + list + } + } + .toImmutableList() + } + } + LaunchedEffect(children) { // Remove joined children from the join actions val joinedChildren = children @@ -118,17 +163,71 @@ class SpacePresenter( } SpaceEvents.HideTopicViewer -> topicViewerState = TopicViewerState.Hidden is SpaceEvents.ShowTopicViewer -> topicViewerState = TopicViewerState.Shown(event.topic) + + // Manage mode events + SpaceEvents.EnterManageMode -> { + isManageMode = true + selectedRoomIds = emptySet() + } + SpaceEvents.ExitManageMode -> { + isManageMode = false + selectedRoomIds = emptySet() + } + is SpaceEvents.ToggleRoomSelection -> { + selectedRoomIds = if (event.roomId in selectedRoomIds) { + selectedRoomIds - event.roomId + } else { + selectedRoomIds + event.roomId + } + } + SpaceEvents.RemoveSelectedRooms -> { + removeRoomsAction = AsyncAction.ConfirmingNoParams + } + SpaceEvents.ConfirmRoomRemoval -> { + localCoroutineScope.launch { + removeRoomsAction = AsyncAction.Loading + val spaceId = spaceRoomList.roomId + val roomsToRemove = selectedRoomIds.toSet() + val successfullyRemoved = mutableSetOf() + val results = roomsToRemove.map { roomId -> + async { + spaceService.removeChildFromSpace(spaceId, roomId) + .onSuccess { successfullyRemoved.add(roomId) } + } + } + results.awaitAll() + if (successfullyRemoved.isNotEmpty()) { + removedRoomIds = removedRoomIds + successfullyRemoved + } + val hasError = successfullyRemoved.size < roomsToRemove.size + if (hasError) { + removeRoomsAction = AsyncAction.Failure(Exception("Failed to remove some rooms")) + } else { + removeRoomsAction = AsyncAction.Success(Unit) + isManageMode = false + selectedRoomIds = emptySet() + } + } + } + SpaceEvents.ClearRemoveAction -> { + removeRoomsAction = AsyncAction.Uninitialized + } } } return SpaceState( - currentSpace = currentSpace.getOrNull(), - children = children, + spaceInfo = roomInfo, + children = filteredChildren, seenSpaceInvites = seenSpaceInvites, hideInvitesAvatar = hideInvitesAvatar, hasMoreToLoad = hasMoreToLoad, joinActions = joinActions.toImmutableMap(), acceptDeclineInviteState = acceptDeclineInviteState, topicViewerState = topicViewerState, + canAccessSpaceSettings = canAccessSpaceSettings, + isManageMode = isManageMode, + selectedRoomIds = selectedRoomIds.toImmutableSet(), + canEditSpaceGraph = canEditSpaceGraph, + removeRoomsAction = removeRoomsAction, eventSink = ::handleEvent, ) } @@ -142,7 +241,7 @@ class SpacePresenter( joinRoom.invoke( roomIdOrAlias = spaceRoom.roomId.toRoomIdOrAlias(), serverNames = spaceRoom.via, - trigger = JoinedRoom.Trigger.SpaceHierarchy, + trigger = Trigger.SpaceHierarchy, ).onFailure { setJoinActions(joinActions + mapOf(spaceRoom.roomId to AsyncAction.Failure(it))) } diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceState.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceState.kt index 031721ee26c..a669c294b52 100644 --- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceState.kt +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceState.kt @@ -12,13 +12,14 @@ import androidx.compose.runtime.Immutable import io.element.android.features.invite.api.acceptdecline.AcceptDeclineInviteState import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.room.RoomInfo import io.element.android.libraries.matrix.api.spaces.SpaceRoom import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableMap import kotlinx.collections.immutable.ImmutableSet data class SpaceState( - val currentSpace: SpaceRoom?, + val spaceInfo: RoomInfo, val children: ImmutableList, val seenSpaceInvites: ImmutableSet, val hideInvitesAvatar: Boolean, @@ -26,12 +27,22 @@ data class SpaceState( val joinActions: ImmutableMap>, val acceptDeclineInviteState: AcceptDeclineInviteState, val topicViewerState: TopicViewerState, + val canAccessSpaceSettings: Boolean, + val isManageMode: Boolean, + val selectedRoomIds: ImmutableSet, + val canEditSpaceGraph: Boolean, + val removeRoomsAction: AsyncAction, val eventSink: (SpaceEvents) -> Unit ) { fun isJoining(spaceId: RoomId): Boolean = joinActions[spaceId] == AsyncAction.Loading - val hasAnyFailure: Boolean = joinActions.values.any { + fun isSelected(spaceId: RoomId): Boolean = selectedRoomIds.contains(spaceId) + val hasAnyJoinFailures: Boolean = joinActions.values.any { it is AsyncAction.Failure } + + val showManageRoomsAction: Boolean = canEditSpaceGraph && children.any { spaceRoom -> !spaceRoom.isSpace } + val selectedCount: Int = selectedRoomIds.size + val isRemoveButtonEnabled: Boolean = selectedRoomIds.isNotEmpty() } @Immutable diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceStateProvider.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceStateProvider.kt index bfb63c63db7..cd8205a5ea8 100644 --- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceStateProvider.kt +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/root/SpaceStateProvider.kt @@ -15,6 +15,8 @@ import io.element.android.features.invite.api.acceptdecline.anAcceptDeclineInvit import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.room.CurrentUserMembership +import io.element.android.libraries.matrix.api.room.RoomInfo +import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibility import io.element.android.libraries.matrix.api.room.join.JoinRule import io.element.android.libraries.matrix.api.spaces.SpaceRoom import io.element.android.libraries.previewutils.room.aSpaceRoom @@ -27,11 +29,11 @@ open class SpaceStateProvider : PreviewParameterProvider { override val values: Sequence get() = sequenceOf( aSpaceState(), - aSpaceState(parentSpace = aParentSpace(joinRule = JoinRule.Public)), - aSpaceState(parentSpace = aParentSpace(joinRule = JoinRule.Restricted(persistentListOf()))), + aSpaceState(spaceInfo = aSpaceInfo(joinRule = JoinRule.Public)), + aSpaceState(spaceInfo = aSpaceInfo(joinRule = JoinRule.Restricted(persistentListOf()))), aSpaceState(children = aListOfSpaceRooms()), aSpaceState( - parentSpace = aParentSpace(), + spaceInfo = aSpaceInfo(), children = aListOfSpaceRooms(), joiningRooms = setOf(RoomId("!spaceId0:example.com")), hasMoreToLoad = false @@ -39,12 +41,31 @@ open class SpaceStateProvider : PreviewParameterProvider { aSpaceState( topicViewerState = TopicViewerState.Shown(topic = "Space description goes here." + LoremIpsum(20).values.first()), ), - // Add other states here + // Manage mode states + aSpaceState( + spaceInfo = aSpaceInfo(), + children = aListOfSpaceRooms(), + isManageMode = true, + selectedRoomIds = emptySet(), + ), + aSpaceState( + spaceInfo = aSpaceInfo(), + children = aListOfSpaceRooms(), + isManageMode = true, + selectedRoomIds = setOf(RoomId("!spaceId0:example.com"), RoomId("!spaceId1:example.com")), + ), + aSpaceState( + spaceInfo = aSpaceInfo(), + children = aListOfSpaceRooms(), + isManageMode = true, + selectedRoomIds = setOf(RoomId("!spaceId0:example.com")), + removeRoomsAction = AsyncAction.ConfirmingNoParams, + ), ) } fun aSpaceState( - parentSpace: SpaceRoom? = aParentSpace(), + spaceInfo: RoomInfo = aSpaceInfo(), children: List = emptyList(), seenSpaceInvites: Set = emptySet(), joiningRooms: Set = emptySet(), @@ -53,9 +74,14 @@ fun aSpaceState( hasMoreToLoad: Boolean = true, acceptDeclineInviteState: AcceptDeclineInviteState = anAcceptDeclineInviteState(), topicViewerState: TopicViewerState = TopicViewerState.Hidden, + canAccessSpaceSettings: Boolean = true, + isManageMode: Boolean = false, + selectedRoomIds: Set = emptySet(), + canManageRooms: Boolean = true, + removeRoomsAction: AsyncAction = AsyncAction.Uninitialized, eventSink: (SpaceEvents) -> Unit = { }, ) = SpaceState( - currentSpace = parentSpace, + spaceInfo = spaceInfo, children = children.toImmutableList(), seenSpaceInvites = seenSpaceInvites.toImmutableSet(), hideInvitesAvatar = hideInvitesAvatar, @@ -63,19 +89,55 @@ fun aSpaceState( joinActions = joinActions.toImmutableMap(), acceptDeclineInviteState = acceptDeclineInviteState, topicViewerState = topicViewerState, + canAccessSpaceSettings = canAccessSpaceSettings, + isManageMode = isManageMode, + selectedRoomIds = selectedRoomIds.toImmutableSet(), + canEditSpaceGraph = canManageRooms, + removeRoomsAction = removeRoomsAction, eventSink = eventSink, ) -private fun aParentSpace( +private fun aSpaceInfo( joinRule: JoinRule? = null, -): SpaceRoom { - return aSpaceRoom( - numJoinedMembers = 5, - childrenCount = 10, - worldReadable = true, - joinRule = joinRule, - roomId = RoomId("!spaceId0:example.com"), +): RoomInfo { + return RoomInfo( + id = RoomId("!spaceId0:example.com"), + name = "A Space", + rawName = "A Space", topic = "Space description goes here. " + LoremIpsum(20).values.first(), + avatarUrl = null, + isPublic = true, + isDirect = false, + isEncrypted = false, + joinRule = joinRule, + isSpace = true, + isFavorite = false, + canonicalAlias = null, + alternativeAliases = persistentListOf(), + currentUserMembership = CurrentUserMembership.JOINED, + inviter = null, + activeMembersCount = 5, + invitedMembersCount = 0, + joinedMembersCount = 5, + roomPowerLevels = null, + highlightCount = 0, + notificationCount = 0, + userDefinedNotificationMode = null, + hasRoomCall = false, + activeRoomCallParticipants = persistentListOf(), + isMarkedUnread = false, + numUnreadMessages = 0, + numUnreadNotifications = 0, + numUnreadMentions = 0, + heroes = persistentListOf(), + pinnedEventIds = persistentListOf(), + creators = persistentListOf(), + historyVisibility = RoomHistoryVisibility.Joined, + successorRoom = null, + roomVersion = "11", + privilegedCreatorRole = false, + // TCHAP external user + isOpenToExternalUsers = false, ) } 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 cd1b3de74dc..695b8874966 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 @@ -8,9 +8,17 @@ package io.element.android.features.space.impl.root +import androidx.activity.compose.BackHandler +import androidx.annotation.StringRes +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.expandVertically +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.shrinkVertically import androidx.compose.foundation.clickable import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth @@ -29,6 +37,8 @@ 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.graphics.vector.ImageVector +import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.semantics.heading import androidx.compose.ui.semantics.semantics @@ -38,9 +48,12 @@ 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.space.impl.R +import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.designsystem.atomic.molecules.InviteButtonsRowMolecule import io.element.android.libraries.designsystem.components.ClickableLinkText import io.element.android.libraries.designsystem.components.SimpleModalBottomSheet +import io.element.android.libraries.designsystem.components.async.AsyncActionView import io.element.android.libraries.designsystem.components.async.AsyncIndicator import io.element.android.libraries.designsystem.components.async.AsyncIndicatorHost import io.element.android.libraries.designsystem.components.async.rememberAsyncIndicatorState @@ -49,8 +62,10 @@ 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 import io.element.android.libraries.designsystem.components.button.BackButton +import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog 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.CircularProgressIndicator import io.element.android.libraries.designsystem.theme.components.DropdownMenu import io.element.android.libraries.designsystem.theme.components.DropdownMenuItem @@ -59,15 +74,18 @@ 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.Scaffold 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.api.room.CurrentUserMembership +import io.element.android.libraries.matrix.api.room.RoomInfo import io.element.android.libraries.matrix.api.spaces.SpaceRoom +import io.element.android.libraries.matrix.api.spaces.SpaceRoomVisibility import io.element.android.libraries.matrix.ui.components.JoinButton import io.element.android.libraries.matrix.ui.components.SpaceHeaderView import io.element.android.libraries.matrix.ui.components.SpaceRoomItemView import io.element.android.libraries.matrix.ui.model.getAvatarData +import io.element.android.libraries.ui.strings.CommonPlurals import io.element.android.libraries.ui.strings.CommonStrings -import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.delay @OptIn(ExperimentalMaterial3Api::class) @@ -78,22 +96,53 @@ fun SpaceView( onRoomClick: (spaceRoom: SpaceRoom) -> Unit, onShareSpace: () -> Unit, onLeaveSpaceClick: () -> Unit, - onDetailsClick: () -> Unit, + onSettingsClick: () -> Unit, onViewMembersClick: () -> Unit, modifier: Modifier = Modifier, acceptDeclineInviteView: @Composable () -> Unit, ) { + BackHandler { + if (state.isManageMode) { + state.eventSink(SpaceEvents.ExitManageMode) + } else { + onBackClick() + } + } + Scaffold( modifier = modifier, topBar = { - SpaceViewTopBar( - currentSpace = state.currentSpace, - onBackClick = onBackClick, - onLeaveSpaceClick = onLeaveSpaceClick, - onShareSpace = onShareSpace, - onDetailsClick = onDetailsClick, - onViewMembersClick = onViewMembersClick, - ) + Box { + AnimatedVisibility( + visible = state.isManageMode, + enter = fadeIn(), + exit = fadeOut() + ) { + ManageModeTopBar( + selectedCount = state.selectedCount, + isRemoveButtonEnabled = state.isRemoveButtonEnabled, + onCancelClick = { state.eventSink(SpaceEvents.ExitManageMode) }, + onRemoveClick = { state.eventSink(SpaceEvents.RemoveSelectedRooms) }, + ) + } + AnimatedVisibility( + visible = !state.isManageMode, + enter = fadeIn(), + exit = fadeOut() + ) { + SpaceViewTopBar( + spaceInfo = state.spaceInfo, + canAccessSpaceSettings = state.canAccessSpaceSettings, + showManageRoomsAction = state.showManageRoomsAction, + onBackClick = onBackClick, + onLeaveSpaceClick = onLeaveSpaceClick, + onSettingsClick = onSettingsClick, + onShareSpace = onShareSpace, + onViewMembersClick = onViewMembersClick, + onManageRoomsClick = { state.eventSink(SpaceEvents.EnterManageMode) }, + ) + } + } }, content = { padding -> Box( @@ -101,15 +150,28 @@ fun SpaceView( ) { SpaceViewContent( state = state, - onRoomClick = onRoomClick, + onRoomClick = { spaceRoom -> + if (state.isManageMode) { + state.eventSink(SpaceEvents.ToggleRoomSelection(spaceRoom.roomId)) + } else { + onRoomClick(spaceRoom) + } + }, onTopicClick = { topic -> state.eventSink(SpaceEvents.ShowTopicViewer(topic)) } ) - JoinRoomFailureEffect( - hasAnyFailure = state.hasAnyFailure, + JoinFailuresEffect( + hasAnyFailure = state.hasAnyJoinFailures, eventSink = state.eventSink ) + RemoveRoomsActionView( + spaceDisplayName = state.spaceInfo.name ?: state.spaceInfo.id.value, + removeRoomsAction = state.removeRoomsAction, + selectedCount = state.selectedCount, + onConfirm = { state.eventSink(SpaceEvents.ConfirmRoomRemoval) }, + onDismiss = { state.eventSink(SpaceEvents.ClearRemoveAction) }, + ) acceptDeclineInviteView() } }, @@ -125,7 +187,7 @@ fun SpaceView( } @Composable -private fun JoinRoomFailureEffect( +private fun JoinFailuresEffect( hasAnyFailure: Boolean, eventSink: (SpaceEvents) -> Unit, ) { @@ -173,22 +235,26 @@ private fun SpaceViewContent( modifier: Modifier = Modifier, ) { LazyColumn(modifier.fillMaxSize()) { - val currentSpace = state.currentSpace - if (currentSpace != null) { - item { - SpaceHeaderView( - avatarData = currentSpace.getAvatarData(AvatarSize.SpaceHeader), - name = currentSpace.displayName, - topic = currentSpace.topic, - topicMaxLines = 2, - visibility = currentSpace.visibility, - heroes = currentSpace.heroes.toImmutableList(), - numberOfMembers = currentSpace.numJoinedMembers, - onTopicClick = onTopicClick - ) - } - item { - HorizontalDivider() + val spaceInfo = state.spaceInfo + item(key = "space_header") { + AnimatedVisibility( + !state.isManageMode, + enter = fadeIn() + expandVertically(), + exit = fadeOut() + shrinkVertically() + ) { + Column { + SpaceHeaderView( + avatarData = spaceInfo.getAvatarData(AvatarSize.SpaceHeader), + name = spaceInfo.name, + topic = spaceInfo.topic, + topicMaxLines = 2, + visibility = SpaceRoomVisibility.fromJoinRule(spaceInfo.joinRule), + heroes = spaceInfo.heroes, + numberOfMembers = spaceInfo.joinedMembersCount.toInt(), + onTopicClick = onTopicClick + ) + HorizontalDivider() + } } } itemsIndexed( @@ -197,9 +263,11 @@ private fun SpaceViewContent( ) { index, spaceRoom -> val isInvitation = spaceRoom.state == CurrentUserMembership.INVITED val isCurrentlyJoining = state.isJoining(spaceRoom.roomId) + val isSelected = state.isSelected(spaceRoom.roomId) + val showUnreadIndicator = isInvitation && spaceRoom.roomId !in state.seenSpaceInvites && !state.isManageMode SpaceRoomItemView( spaceRoom = spaceRoom, - showUnreadIndicator = isInvitation && spaceRoom.roomId !in state.seenSpaceInvites, + showUnreadIndicator = showUnreadIndicator, hideAvatars = isInvitation && state.hideInvitesAvatar, onClick = { onRoomClick(spaceRoom) @@ -207,17 +275,30 @@ private fun SpaceViewContent( onLongClick = { // TODO }, - trailingAction = spaceRoom.trailingAction(isCurrentlyJoining = isCurrentlyJoining) { - state.eventSink(SpaceEvents.Join(spaceRoom)) - }, - bottomAction = spaceRoom.inviteButtons( - onAcceptClick = { - state.eventSink(SpaceEvents.AcceptInvite(spaceRoom)) - }, - onDeclineClick = { - state.eventSink(SpaceEvents.DeclineInvite(spaceRoom)) + trailingAction = if (state.isManageMode) { + { + Checkbox( + checked = isSelected, + onCheckedChange = null, + ) } - ) + } else { + spaceRoom.trailingAction(isCurrentlyJoining = isCurrentlyJoining) { + state.eventSink(SpaceEvents.Join(spaceRoom)) + } + }, + bottomAction = if (state.isManageMode) { + null + } else { + spaceRoom.inviteButtons( + onAcceptClick = { + state.eventSink(SpaceEvents.AcceptInvite(spaceRoom)) + }, + onDeclineClick = { + state.eventSink(SpaceEvents.DeclineInvite(spaceRoom)) + } + ) + } ) if (index != state.children.lastIndex) { HorizontalDivider() @@ -254,12 +335,15 @@ private fun LoadingMoreIndicator( @OptIn(ExperimentalMaterial3Api::class) @Composable private fun SpaceViewTopBar( - currentSpace: SpaceRoom?, + spaceInfo: RoomInfo, + canAccessSpaceSettings: Boolean, + showManageRoomsAction: Boolean, onBackClick: () -> Unit, onLeaveSpaceClick: () -> Unit, - onDetailsClick: () -> Unit, + onSettingsClick: () -> Unit, onShareSpace: () -> Unit, onViewMembersClick: () -> Unit, + onManageRoomsClick: () -> Unit, modifier: Modifier = Modifier, ) { TopAppBar( @@ -268,17 +352,14 @@ private fun SpaceViewTopBar( BackButton(onClick = onBackClick) }, title = { - if (currentSpace != null) { - val roundedCornerShape = RoundedCornerShape(8.dp) - SpaceAvatarAndNameRow( - name = currentSpace.displayName, - avatarData = currentSpace.getAvatarData(AvatarSize.TimelineRoom), - modifier = Modifier - .clip(roundedCornerShape) - // TODO enable when screen ready for space - .clickable(enabled = false, onClick = onDetailsClick) - ) - } + val roundedCornerShape = RoundedCornerShape(8.dp) + SpaceAvatarAndNameRow( + name = spaceInfo.name, + avatarData = spaceInfo.getAvatarData(AvatarSize.TimelineRoom), + modifier = Modifier + .clip(roundedCornerShape) + .clickable(enabled = canAccessSpaceSettings, onClick = onSettingsClick) + ) }, actions = { var showMenu by remember { mutableStateOf(false) } @@ -294,51 +375,51 @@ private fun SpaceViewTopBar( expanded = showMenu, onDismissRequest = { showMenu = false } ) { - DropdownMenuItem( + if (showManageRoomsAction) { + SpaceMenuItem( + titleRes = CommonStrings.action_manage_rooms, + icon = CompoundIcons.Edit(), + onClick = { + showMenu = false + onManageRoomsClick() + } + ) + HorizontalDivider() + } + SpaceMenuItem( + titleRes = R.string.screen_space_menu_action_members, + icon = CompoundIcons.User(), onClick = { showMenu = false - onShareSpace() - }, - text = { Text(stringResource(id = CommonStrings.action_share)) }, - leadingIcon = { - Icon( - imageVector = CompoundIcons.ShareAndroid(), - tint = ElementTheme.colors.iconSecondary, - contentDescription = null, - ) + onViewMembersClick() } ) - DropdownMenuItem( + SpaceMenuItem( + titleRes = CommonStrings.action_share, + icon = CompoundIcons.ShareAndroid(), onClick = { showMenu = false - onViewMembersClick() - }, - text = { Text(stringResource(id = CommonStrings.screen_space_menu_action_members)) }, - leadingIcon = { - Icon( - imageVector = CompoundIcons.User(), - tint = ElementTheme.colors.iconSecondary, - contentDescription = null, - ) + onShareSpace() } ) - DropdownMenuItem( + if (canAccessSpaceSettings) { + SpaceMenuItem( + titleRes = CommonStrings.common_settings, + icon = CompoundIcons.Settings(), + onClick = { + showMenu = false + onSettingsClick() + } + ) + } + HorizontalDivider() + SpaceMenuItem( + titleRes = CommonStrings.action_leave_space, + icon = CompoundIcons.Leave(), + isCritical = true, onClick = { showMenu = false onLeaveSpaceClick() - }, - text = { - Text( - text = stringResource(id = CommonStrings.action_leave_space), - color = ElementTheme.colors.textCriticalPrimary, - ) - }, - leadingIcon = { - Icon( - imageVector = CompoundIcons.Leave(), - tint = ElementTheme.colors.iconCriticalPrimary, - contentDescription = null, - ) } ) } @@ -346,6 +427,64 @@ private fun SpaceViewTopBar( ) } +@OptIn(ExperimentalMaterial3Api::class) +@Composable +private fun ManageModeTopBar( + selectedCount: Int, + isRemoveButtonEnabled: Boolean, + onCancelClick: () -> Unit, + onRemoveClick: () -> Unit, + modifier: Modifier = Modifier, +) { + TopAppBar( + modifier = modifier, + navigationIcon = { + BackButton( + onClick = onCancelClick, + imageVector = CompoundIcons.Close() + ) + }, + title = { + Text( + text = pluralStringResource(CommonPlurals.common_selected_count, selectedCount, selectedCount), + style = ElementTheme.typography.fontBodyLgMedium, + ) + }, + actions = { + TextButton( + text = stringResource(CommonStrings.action_remove), + onClick = onRemoveClick, + enabled = isRemoveButtonEnabled, + ) + }, + ) +} + +@Composable +private fun SpaceMenuItem( + @StringRes titleRes: Int, + icon: ImageVector, + onClick: () -> Unit, + isCritical: Boolean = false, +) { + DropdownMenuItem( + onClick = onClick, + text = { + Text( + text = stringResource(titleRes), + color = if (isCritical) ElementTheme.colors.textCriticalPrimary else ElementTheme.colors.textPrimary, + ) + }, + leadingIcon = { + Icon( + imageVector = icon, + tint = if (isCritical) ElementTheme.colors.iconCriticalPrimary else ElementTheme.colors.iconSecondary, + contentDescription = null, + ) + } + ) +} + @Composable private fun SpaceAvatarAndNameRow( name: String?, @@ -409,6 +548,45 @@ private fun SpaceRoom.inviteButtons( } } +@Composable +private fun RemoveRoomsActionView( + spaceDisplayName: String, + removeRoomsAction: AsyncAction, + selectedCount: Int, + onConfirm: () -> Unit, + onDismiss: () -> Unit, +) { + AsyncActionView( + async = removeRoomsAction, + confirmationDialog = { + ConfirmationDialog( + title = pluralStringResource(R.plurals.screen_space_remove_rooms_confirmation_title, selectedCount, selectedCount, spaceDisplayName), + content = stringResource(R.string.screen_space_remove_rooms_confirmation_content), + submitText = stringResource(CommonStrings.action_remove), + onSubmitClick = onConfirm, + onDismiss = onDismiss, + destructiveSubmit = true, + icon = { + Icon( + imageVector = CompoundIcons.Error(), + tint = ElementTheme.colors.textCriticalPrimary, + contentDescription = null + ) + } + ) + }, + onRetry = onConfirm, + errorTitle = { + stringResource(CommonStrings.common_something_went_wrong) + }, + errorMessage = { + stringResource(CommonStrings.error_network_or_server_issue) + }, + onSuccess = { onDismiss() }, + onErrorDismiss = onDismiss, + ) +} + @PreviewsDayNight @Composable internal fun SpaceViewPreview( @@ -420,7 +598,7 @@ internal fun SpaceViewPreview( onShareSpace = {}, onLeaveSpaceClick = {}, acceptDeclineInviteView = {}, - onDetailsClick = {}, + onSettingsClick = {}, onViewMembersClick = {}, onBackClick = {}, ) diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/settings/SpaceSettingsFlowNode.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/settings/SpaceSettingsFlowNode.kt new file mode 100644 index 00000000000..8fc7f51aa79 --- /dev/null +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/settings/SpaceSettingsFlowNode.kt @@ -0,0 +1,145 @@ +/* + * Copyright (c) 2025 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.space.impl.settings + +import android.os.Parcelable +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.plugin.Plugin +import com.bumble.appyx.navmodel.backstack.BackStack +import com.bumble.appyx.navmodel.backstack.operation.pop +import com.bumble.appyx.navmodel.backstack.operation.push +import dev.zacsweers.metro.Assisted +import dev.zacsweers.metro.AssistedInject +import io.element.android.annotations.ContributesNode +import io.element.android.features.rolesandpermissions.api.RolesAndPermissionsEntryPoint +import io.element.android.features.roomdetailsedit.api.RoomDetailsEditEntryPoint +import io.element.android.features.securityandprivacy.api.SecurityAndPrivacyEntryPoint +import io.element.android.features.space.impl.di.SpaceFlowScope +import io.element.android.libraries.architecture.BackstackView +import io.element.android.libraries.architecture.BaseFlowNode +import io.element.android.libraries.architecture.callback +import io.element.android.libraries.architecture.createNode +import kotlinx.parcelize.Parcelize + +@ContributesNode(SpaceFlowScope::class) +@AssistedInject +class SpaceSettingsFlowNode( + @Assisted buildContext: BuildContext, + @Assisted plugins: List, + private val securityAndPrivacyEntryPoint: SecurityAndPrivacyEntryPoint, + private val rolesAndPermissionsEntryPoint: RolesAndPermissionsEntryPoint, + private val roomDetailsEditEntryPoint: RoomDetailsEditEntryPoint +) : BaseFlowNode( + backstack = BackStack( + initialElement = initialElement(plugins), + savedStateMap = buildContext.savedStateMap, + ), + buildContext = buildContext, + plugins = plugins, +) { + interface Callback : Plugin { + fun initialTarget(): NavTarget = NavTarget.Root + fun navigateToSpaceMembers() + fun startLeaveSpaceFlow() + fun closeSettings() + } + + sealed interface NavTarget : Parcelable { + @Parcelize + data object Root : NavTarget + + @Parcelize + data object EditDetails : NavTarget + + @Parcelize + data object SecurityAndPrivacy : NavTarget + + @Parcelize + data object RolesAndPermissions : NavTarget + } + + private val callback: Callback = callback() + + override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { + return when (navTarget) { + is NavTarget.Root -> { + val callback = object : SpaceSettingsNode.Callback { + override fun closeSettings() { + callback.closeSettings() + } + + override fun navigateToEditDetails() { + backstack.push(NavTarget.EditDetails) + } + + override fun navigateToSpaceMembers() { + callback.navigateToSpaceMembers() + } + + override fun navigateToRolesAndPermissions() { + backstack.push(NavTarget.RolesAndPermissions) + } + + override fun navigateToSecurityAndPrivacy() { + backstack.push(NavTarget.SecurityAndPrivacy) + } + + override fun startLeaveSpaceFlow() { + callback.startLeaveSpaceFlow() + } + } + createNode( + buildContext = buildContext, + plugins = listOf(callback), + ) + } + is NavTarget.SecurityAndPrivacy -> { + val callback = object : SecurityAndPrivacyEntryPoint.Callback { + override fun onDone() { + backstack.pop() + } + } + securityAndPrivacyEntryPoint.createNode( + parentNode = this, + buildContext = buildContext, + callback = callback, + ) + } + is NavTarget.RolesAndPermissions -> { + val callback = object : RolesAndPermissionsEntryPoint.Callback { + override fun onDone() { + backstack.pop() + } + } + rolesAndPermissionsEntryPoint.createNode( + parentNode = this, + buildContext = buildContext, + callback = callback, + ) + } + NavTarget.EditDetails -> { + roomDetailsEditEntryPoint.createNode( + parentNode = this, + buildContext = buildContext, + ) + } + } + } + + @Composable + override fun View(modifier: Modifier) { + BackstackView(modifier) + } +} + +fun initialElement(plugins: List): SpaceSettingsFlowNode.NavTarget { + return plugins.callback().initialTarget() +} diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/settings/SpaceSettingsNode.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/settings/SpaceSettingsNode.kt index ae2f4857c0b..b1e64fbba10 100644 --- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/settings/SpaceSettingsNode.kt +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/settings/SpaceSettingsNode.kt @@ -32,7 +32,7 @@ class SpaceSettingsNode( interface Callback : Plugin { fun closeSettings() - fun navigateToSpaceInfo() + fun navigateToEditDetails() fun navigateToSpaceMembers() fun navigateToRolesAndPermissions() fun navigateToSecurityAndPrivacy() @@ -48,7 +48,7 @@ class SpaceSettingsNode( SpaceSettingsView( state = state, modifier = modifier, - onSpaceInfoClick = callback::navigateToSpaceInfo, + onSpaceInfoClick = callback::navigateToEditDetails, onBackClick = callback::closeSettings, onMembersClick = callback::navigateToSpaceMembers, onRolesAndPermissionsClick = callback::navigateToRolesAndPermissions, diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/settings/SpaceSettingsPermissions.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/settings/SpaceSettingsPermissions.kt new file mode 100644 index 00000000000..e3ec70a51df --- /dev/null +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/settings/SpaceSettingsPermissions.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2025 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.space.impl.settings + +import io.element.android.features.roomdetailsedit.api.RoomDetailsEditPermissions +import io.element.android.features.roomdetailsedit.api.roomDetailsEditPermissions +import io.element.android.features.securityandprivacy.api.SecurityAndPrivacyPermissions +import io.element.android.features.securityandprivacy.api.securityAndPrivacyPermissions +import io.element.android.libraries.matrix.api.room.join.JoinRule +import io.element.android.libraries.matrix.api.room.powerlevels.RoomPermissions +import io.element.android.libraries.matrix.api.room.powerlevels.canEditRolesAndPermissions + +data class SpaceSettingsPermissions( + val editDetailsPermissions: RoomDetailsEditPermissions, + val canEditRolesAndPermissions: Boolean, + val securityAndPrivacyPermissions: SecurityAndPrivacyPermissions, +) { + fun hasAny(joinRule: JoinRule?): Boolean { + return editDetailsPermissions.hasAny || + canEditRolesAndPermissions || + securityAndPrivacyPermissions.hasAny(isSpace = true, joinRule = joinRule) + } + + companion object { + val DEFAULT = SpaceSettingsPermissions( + editDetailsPermissions = RoomDetailsEditPermissions.DEFAULT, + canEditRolesAndPermissions = false, + securityAndPrivacyPermissions = SecurityAndPrivacyPermissions.DEFAULT, + ) + } +} + +fun RoomPermissions.spaceSettingsPermissions(): SpaceSettingsPermissions { + return SpaceSettingsPermissions( + editDetailsPermissions = roomDetailsEditPermissions(), + canEditRolesAndPermissions = canEditRolesAndPermissions(), + securityAndPrivacyPermissions = securityAndPrivacyPermissions(), + ) +} diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/settings/SpaceSettingsPresenter.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/settings/SpaceSettingsPresenter.kt index 5238914c563..565008a778c 100644 --- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/settings/SpaceSettingsPresenter.kt +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/settings/SpaceSettingsPresenter.kt @@ -10,11 +10,13 @@ package io.element.android.features.space.impl.settings import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import dev.zacsweers.metro.Inject import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.matrix.api.room.JoinedRoom -import io.element.android.libraries.matrix.ui.room.isOwnUserAdmin +import io.element.android.libraries.matrix.api.room.powerlevels.permissionsAsState @Inject class SpaceSettingsPresenter( @@ -23,15 +25,22 @@ class SpaceSettingsPresenter( @Composable override fun present(): SpaceSettingsState { val roomInfo by room.roomInfoFlow.collectAsState() - val isUserAdmin = room.isOwnUserAdmin() + val permissions by room.permissionsAsState(SpaceSettingsPermissions.DEFAULT) { perms -> + perms.spaceSettingsPermissions() + } + val showSecurityAndPrivacy by remember { + derivedStateOf { permissions.securityAndPrivacyPermissions.hasAny(isSpace = false, joinRule = roomInfo.joinRule) } + } + return SpaceSettingsState( roomId = room.roomId, name = roomInfo.name.orEmpty(), canonicalAlias = roomInfo.canonicalAlias, avatarUrl = roomInfo.avatarUrl, memberCount = roomInfo.activeMembersCount, - showRolesAndPermissions = isUserAdmin, - showSecurityAndPrivacy = isUserAdmin, + canEditDetails = permissions.editDetailsPermissions.hasAny, + showRolesAndPermissions = permissions.canEditRolesAndPermissions, + showSecurityAndPrivacy = showSecurityAndPrivacy, eventSink = {}, ) } diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/settings/SpaceSettingsState.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/settings/SpaceSettingsState.kt index 40ceadc112a..5c7e9b5c6eb 100644 --- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/settings/SpaceSettingsState.kt +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/settings/SpaceSettingsState.kt @@ -17,6 +17,7 @@ data class SpaceSettingsState( val canonicalAlias: RoomAlias?, val avatarUrl: String?, val memberCount: Long, + val canEditDetails: Boolean, val showRolesAndPermissions: Boolean, val showSecurityAndPrivacy: Boolean, val eventSink: (SpaceSettingsEvents) -> Unit diff --git a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/settings/SpaceSettingsStateProvider.kt b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/settings/SpaceSettingsStateProvider.kt index 2abe7efebee..2030b6885ab 100644 --- a/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/settings/SpaceSettingsStateProvider.kt +++ b/features/space/impl/src/main/kotlin/io/element/android/features/space/impl/settings/SpaceSettingsStateProvider.kt @@ -30,6 +30,7 @@ fun aSpaceSettingsState( memberCount: Long = 100, showRolesAndPermissions: Boolean = false, showSecurityAndPrivacy: Boolean = false, + canEditDetails: Boolean = false, eventSink: (SpaceSettingsEvents) -> Unit = {}, ) = SpaceSettingsState( roomId = roomId, @@ -37,6 +38,7 @@ fun aSpaceSettingsState( canonicalAlias = alias, avatarUrl = avatarUrl, memberCount = memberCount, + canEditDetails = canEditDetails, showRolesAndPermissions = showRolesAndPermissions, showSecurityAndPrivacy = showSecurityAndPrivacy, eventSink = eventSink, 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 379d75f1dd0..63b83516acd 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 @@ -73,6 +73,7 @@ fun SpaceSettingsView( name = state.name, avatarUrl = state.avatarUrl, canonicalAlias = state.canonicalAlias?.value, + canEditDetails = state.canEditDetails, onSpaceInfoClick = onSpaceInfoClick, ) Section(isVisible = state.showSecurityAndPrivacy, content = { @@ -101,19 +102,20 @@ private fun SpaceInfoSection( name: String, avatarUrl: String?, canonicalAlias: String?, + canEditDetails: Boolean, onSpaceInfoClick: () -> Unit, ) { Row( modifier = Modifier .fillMaxWidth() - .clickable(onClick = onSpaceInfoClick) + .clickable(enabled = canEditDetails, onClick = onSpaceInfoClick) .padding(16.dp), verticalAlignment = Alignment.CenterVertically, ) { Avatar( avatarData = AvatarData(roomId.value, name, avatarUrl, AvatarSize.SpaceListItem), avatarType = AvatarType.Space(), - contentDescription = avatarUrl?.let { stringResource(CommonStrings.a11y_avatar) }, + contentDescription = stringResource(CommonStrings.a11y_avatar), ) Spacer(Modifier.width(16.dp)) Column { diff --git a/features/space/impl/src/main/res/values-bg/translations.xml b/features/space/impl/src/main/res/values-bg/translations.xml index 0759934bbd9..de0870f3f4d 100644 --- a/features/space/impl/src/main/res/values-bg/translations.xml +++ b/features/space/impl/src/main/res/values-bg/translations.xml @@ -1,5 +1,6 @@ + "Преглед на членовете" "Напускане на пространството" "Роли и разрешения" "Защита и поверителност" diff --git a/features/space/impl/src/main/res/values-cs/translations.xml b/features/space/impl/src/main/res/values-cs/translations.xml index d4730fc62d8..d98124c714d 100644 --- a/features/space/impl/src/main/res/values-cs/translations.xml +++ b/features/space/impl/src/main/res/values-cs/translations.xml @@ -11,6 +11,7 @@ "Z následujících místností nebudete odstraněni, protože jste jediným administrátorem:" "Opustit %1$s?" "Jste jediným administrátorem pro %1$s" + "Zobrazit členy" "Opustit prostor" "Role a oprávnění" "Zabezpečení a soukromí" diff --git a/features/space/impl/src/main/res/values-da/translations.xml b/features/space/impl/src/main/res/values-da/translations.xml index 6422b9635d7..068712629dd 100644 --- a/features/space/impl/src/main/res/values-da/translations.xml +++ b/features/space/impl/src/main/res/values-da/translations.xml @@ -10,6 +10,7 @@ "Du vil ikke blive fjernet fra følgende rum, fordi du er den eneste administrator:" "Forlad %1$s?" "Du er den eneste administrator for %1$s" + "Vis medlemmer" "Forlad gruppe" "Roller og tilladelser" "Sikkerhed og privatliv" diff --git a/features/space/impl/src/main/res/values-de/translations.xml b/features/space/impl/src/main/res/values-de/translations.xml index a001756c6c0..1d0238cf7f9 100644 --- a/features/space/impl/src/main/res/values-de/translations.xml +++ b/features/space/impl/src/main/res/values-de/translations.xml @@ -10,6 +10,12 @@ "Du wirst aus den folgenden Chats nicht entfernt, weil du der einzige Admin bist:" "%1$s verlassen?" "Du bist der einzige Administrator für %1$s" + "Mitglieder anzeigen" + "Das Entfernen eines Chats hat keinen Einfluss auf die Beitrittsregeln. Um die Regeln zu ändern, gehe zu \"Raum Info\" und dann zu \"Datenschutz und Sicherheit\"" + + "%1$d chat aus %2$s entfernen" + "%1$d chats aus %2$s entfernen" + "Space verlassen" "Rollen und Berechtigungen" "Sicherheit & Datenschutz" diff --git a/features/space/impl/src/main/res/values-et/translations.xml b/features/space/impl/src/main/res/values-et/translations.xml index 43eaade3518..fdee05be055 100644 --- a/features/space/impl/src/main/res/values-et/translations.xml +++ b/features/space/impl/src/main/res/values-et/translations.xml @@ -10,6 +10,7 @@ "Sind ei saa järgnevatest jututubadest eemaldada, kuna oled seal/neis ainus peakasutaja:" "Kas lahkud %1$s kogukonnast?" "Sa oled siin ainus peakasutaja: %1$s" + "Vaata liikmeid" "Lahku kogukonnast" "Rollid ja õigused" "Turvalisus ja privaatsus" diff --git a/features/space/impl/src/main/res/values-fa/translations.xml b/features/space/impl/src/main/res/values-fa/translations.xml index bda53d09472..0f42a9f65f5 100644 --- a/features/space/impl/src/main/res/values-fa/translations.xml +++ b/features/space/impl/src/main/res/values-fa/translations.xml @@ -5,6 +5,7 @@ "از اتاق(های) زیر برداشته نخواهید شد؛ چرا که تنها مدیر هستید:" "ترک %1$s؟" "تنها مدیر %1$s هستید" + "دیدن اعضا" "ترک فضا" "نقش‌ها و اجازه‌ها" "امنیت و محرمانگی" diff --git a/features/space/impl/src/main/res/values-fi/translations.xml b/features/space/impl/src/main/res/values-fi/translations.xml index e43a4ae7f95..77771cf383e 100644 --- a/features/space/impl/src/main/res/values-fi/translations.xml +++ b/features/space/impl/src/main/res/values-fi/translations.xml @@ -10,6 +10,7 @@ "Sinua ei poisteta seuraavista huoneista, koska olet ainoa ylläpitäjä:" "Haluatko poistua tilasta %1$s?" "Olet ainoa ylläpitäjä tilassa %1$s" + "Näytä jäsenet" "Poistu tilasta" "Roolit ja oikeudet" "Turvallisuus ja yksityisyys" diff --git a/features/space/impl/src/main/res/values-fr/translations.xml b/features/space/impl/src/main/res/values-fr/translations.xml index befd4a7c92a..89cc3e619fa 100644 --- a/features/space/impl/src/main/res/values-fr/translations.xml +++ b/features/space/impl/src/main/res/values-fr/translations.xml @@ -10,6 +10,7 @@ "Vous ne quitterez pas le ou les salons suivants car vous y êtes le seul administrateur:" "Quitter %1$s?" "Vous êtes le seul administrateur de %1$s" + "Voir les membres" "Quitter l’espace" "Rôles & autorisations" "Sécurité & confidentialité" diff --git a/features/space/impl/src/main/res/values-hr/translations.xml b/features/space/impl/src/main/res/values-hr/translations.xml new file mode 100644 index 00000000000..5bd54001247 --- /dev/null +++ b/features/space/impl/src/main/res/values-hr/translations.xml @@ -0,0 +1,18 @@ + + + "%1$s (administrator)" + + "Napusti %1$d sobu i prostor" + "Napusti %1$d sobe i prostor" + "Napusti %1$d soba i prostor" + + "Odaberite sobe koje želite napustiti, a za koje niste jedini administrator:" + "Morate dodijeliti drugog administratora za ovaj prostor prije nego što ga napustite." + "Nećete biti uklonjeni iz sljedećih soba jer ste jedini administrator:" + "Želite li napustiti %1$s?" + "Vi ste jedini administrator za %1$s" + "Prikaži članove" + "Napusti prostor" + "Uloge i dopuštenja" + "Sigurnost i privatnost" + diff --git a/features/space/impl/src/main/res/values-hu/translations.xml b/features/space/impl/src/main/res/values-hu/translations.xml index 3ddbe6c8222..670e14cc3c6 100644 --- a/features/space/impl/src/main/res/values-hu/translations.xml +++ b/features/space/impl/src/main/res/values-hu/translations.xml @@ -10,6 +10,7 @@ "Nem lesz eltávolítva a következő szobá(k)ból, mert ön az egyetlen adminisztrátor:" "Kilép innen: %1$s?" "Ön az egyetlen adminisztrátor itt: %1$s" + "Tagok megtekintése" "Tér elhagyása" "Szerepkörök és jogosultságok" "Biztonság és adatvédelem" diff --git a/features/space/impl/src/main/res/values-it/translations.xml b/features/space/impl/src/main/res/values-it/translations.xml index e483f985131..f358c96d0d8 100644 --- a/features/space/impl/src/main/res/values-it/translations.xml +++ b/features/space/impl/src/main/res/values-it/translations.xml @@ -10,6 +10,7 @@ "Non verrai rimosso dalle seguenti stanze perché sei l\'unico amministratore:" "Uscire da %1$s?" "Sei l\'unico amministratore di %1$s" + "Visualizza membri" "Esci dallo spazio" "Ruoli e autorizzazioni" "Sicurezza e privacy" diff --git a/features/space/impl/src/main/res/values-nb/translations.xml b/features/space/impl/src/main/res/values-nb/translations.xml index 0e0709f80e5..ebbe7be342f 100644 --- a/features/space/impl/src/main/res/values-nb/translations.xml +++ b/features/space/impl/src/main/res/values-nb/translations.xml @@ -10,6 +10,7 @@ "Du vil ikke bli fjernet fra følgende rom fordi du er den eneste administratoren:" "Forlat %1$s?" "Du er den eneste administratoren for %1$s" + "Vis medlemmer" "Forlat område" "Roller og tillatelser" "Sikkerhet og personvern" diff --git a/features/space/impl/src/main/res/values-pt-rBR/translations.xml b/features/space/impl/src/main/res/values-pt-rBR/translations.xml index 3329be10974..c509b8caf85 100644 --- a/features/space/impl/src/main/res/values-pt-rBR/translations.xml +++ b/features/space/impl/src/main/res/values-pt-rBR/translations.xml @@ -10,6 +10,7 @@ "Você não será removido das seguintes salas porque você é o único administrador:" "Sair de %1$s?" "Você é o único administrador de %1$s" + "Ver membros" "Sair do espaço" "Cargos e permissões" "Segurança e privacidade" 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 588518a2490..7640d873a84 100644 --- a/features/space/impl/src/main/res/values-ro/translations.xml +++ b/features/space/impl/src/main/res/values-ro/translations.xml @@ -11,6 +11,7 @@ "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" + "Vizualizați membrii" "Părăsiți spațiul" "Roluri și permisiuni" "Securitate & confidențialitate" diff --git a/features/space/impl/src/main/res/values-ru/translations.xml b/features/space/impl/src/main/res/values-ru/translations.xml index 47cd467725c..090c551fd5e 100644 --- a/features/space/impl/src/main/res/values-ru/translations.xml +++ b/features/space/impl/src/main/res/values-ru/translations.xml @@ -11,6 +11,7 @@ "Вы не будете удалены из следующих комнат, поскольку вы являетесь единственным администратором:" "Выйти из %1$s?" "Вы единственный администратор для %1$s" + "Просмотреть участников" "Покинуть пространство" "Роли и разрешения" "Безопасность и конфиденциальность" diff --git a/features/space/impl/src/main/res/values-sk/translations.xml b/features/space/impl/src/main/res/values-sk/translations.xml index 2fd11ba58bf..79b8fbfbb6f 100644 --- a/features/space/impl/src/main/res/values-sk/translations.xml +++ b/features/space/impl/src/main/res/values-sk/translations.xml @@ -11,6 +11,7 @@ "Z nasledujúcich miestností nebudete odstránený/á, pretože ste jediným správcom:" "Opustiť %1$s?" "Ste jediným administrátorom pre %1$s" + "Zobraziť členov" "Opustiť priestor" "Roly a povolenia" "Bezpečnosť a súkromie" 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 abf495860f5..54da45642cd 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 @@ -9,6 +9,7 @@ "您不會被從以下聊天室移除,因為您是唯一的管理員:" "離開 %1$s?" "您是 %1$s 唯一的管理員" + "檢視成員" "離開空間" "角色與權限" "安全與隱私" diff --git a/features/space/impl/src/main/res/values-zh/translations.xml b/features/space/impl/src/main/res/values-zh/translations.xml index f0afff02f43..ea7011c9423 100644 --- a/features/space/impl/src/main/res/values-zh/translations.xml +++ b/features/space/impl/src/main/res/values-zh/translations.xml @@ -9,6 +9,7 @@ "您不会从以下房间中被移除,因为您是唯一的管理员:" "离开%1$s?" "您是 %1$s 的唯一管理员" + "查看成员" "离开空间" "角色与权限" "安全与隐私" diff --git a/features/space/impl/src/main/res/values/localazy.xml b/features/space/impl/src/main/res/values/localazy.xml index a4df5e767d6..10aa0fb28c4 100644 --- a/features/space/impl/src/main/res/values/localazy.xml +++ b/features/space/impl/src/main/res/values/localazy.xml @@ -10,6 +10,12 @@ "You will not be removed from the following room(s) because you\'re the only administrator:" "Leave %1$s?" "You are the only admin for %1$s" + "View members" + "Removing a room will not affect the room access. To change the access go to Room info > Privacy & security." + + "Remove %1$d room from %2$s" + "Remove %1$d rooms from %2$s" + "Leave space" "Roles & permissions" "Security & privacy" diff --git a/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/root/SpacePresenterTest.kt b/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/root/SpacePresenterTest.kt index 51e36343457..811e9158b9c 100644 --- a/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/root/SpacePresenterTest.kt +++ b/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/root/SpacePresenterTest.kt @@ -19,18 +19,27 @@ import io.element.android.features.invite.api.toInviteData import io.element.android.features.invite.test.InMemorySeenInvitesStore import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.featureflag.api.FeatureFlags +import io.element.android.libraries.featureflag.test.FakeFeatureFlagService import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomIdOrAlias import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias +import io.element.android.libraries.matrix.api.room.BaseRoom import io.element.android.libraries.matrix.api.room.CurrentUserMembership +import io.element.android.libraries.matrix.api.room.RoomType import io.element.android.libraries.matrix.api.room.join.JoinRoom import io.element.android.libraries.matrix.api.spaces.SpaceRoomList import io.element.android.libraries.matrix.test.AN_EXCEPTION import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_ROOM_ID_2 +import io.element.android.libraries.matrix.test.A_ROOM_ID_3 import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.matrix.test.room.FakeBaseRoom import io.element.android.libraries.matrix.test.room.join.FakeJoinRoom +import io.element.android.libraries.matrix.test.room.powerlevels.FakeRoomPermissions import io.element.android.libraries.matrix.test.spaces.FakeSpaceRoomList +import io.element.android.libraries.matrix.test.spaces.FakeSpaceService import io.element.android.libraries.previewutils.room.aSpaceRoom import io.element.android.tests.testutils.EventsRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder @@ -54,7 +63,7 @@ class SpacePresenterTest { val presenter = createSpacePresenter(spaceRoomList = spaceRoomList) presenter.test { val state = awaitItem() - assertThat(state.currentSpace).isNull() + assertThat(state.spaceInfo).isNotNull() assertThat(state.children).isEmpty() assertThat(state.seenSpaceInvites).isEmpty() assertThat(state.hideInvitesAvatar).isFalse() @@ -62,11 +71,39 @@ class SpacePresenterTest { assertThat(state.joinActions).isEmpty() assertThat(state.acceptDeclineInviteState).isEqualTo(anAcceptDeclineInviteState()) assertThat(state.topicViewerState).isEqualTo(TopicViewerState.Hidden) + assertThat(state.canAccessSpaceSettings).isFalse() advanceUntilIdle() paginateResult.assertions().isCalledOnce() } } + @Test + fun `present - canAccessSpaceSettings false when space settings ff is enabled but no permissions`() = runTest { + val presenter = createSpacePresenter(spaceSettingsEnabled = true) + presenter.test { + val state = awaitItem() + assertThat(state.canAccessSpaceSettings).isFalse() + } + } + + @Test + fun `present - canAccessSpaceSettings true when space settings ff is enabled and has permissions`() = runTest { + val room = FakeBaseRoom( + roomPermissions = FakeRoomPermissions( + canSendState = { true } + ) + ) + val presenter = createSpacePresenter( + room = room, + spaceSettingsEnabled = true, + ) + presenter.test { + skipItems(1) + val state = awaitItem() + assertThat(state.canAccessSpaceSettings).isTrue() + } + } + @Test fun `present - load more`() = runTest { val paginateResult = lambdaRecorder> { @@ -106,23 +143,6 @@ class SpacePresenterTest { } } - @Test - fun `present - current space value`() = runTest { - val paginateResult = lambdaRecorder> { - Result.success(Unit) - } - val spaceRoomList = FakeSpaceRoomList(paginateResult = paginateResult) - val presenter = createSpacePresenter(spaceRoomList = spaceRoomList) - presenter.test { - val state = awaitItem() - advanceUntilIdle() - assertThat(state.currentSpace).isNull() - val aSpace = aSpaceRoom() - spaceRoomList.emitCurrentSpace(aSpace) - assertThat(awaitItem().currentSpace).isEqualTo(aSpace) - } - } - @Test fun `present - children value`() = runTest { val paginateResult = lambdaRecorder> { @@ -320,22 +340,244 @@ class SpacePresenterTest { } } + @Test + fun `present - enter manage mode`() = runTest { + val presenter = createSpacePresenter() + presenter.test { + val state = awaitItem() + assertThat(state.isManageMode).isFalse() + state.eventSink(SpaceEvents.EnterManageMode) + val manageModeState = awaitItem() + assertThat(manageModeState.isManageMode).isTrue() + assertThat(manageModeState.selectedRoomIds).isEmpty() + } + } + + @Test + fun `present - exit manage mode clears selection`() = runTest { + val presenter = createSpacePresenter() + presenter.test { + val initialState = awaitItem() + initialState.eventSink(SpaceEvents.EnterManageMode) + initialState.eventSink(SpaceEvents.ToggleRoomSelection(A_ROOM_ID)) + initialState.eventSink(SpaceEvents.ExitManageMode) + val finalState = expectMostRecentItem() + assertThat(finalState.isManageMode).isFalse() + assertThat(finalState.selectedRoomIds).isEmpty() + } + } + + @Test + fun `present - toggle room selection`() = runTest { + val presenter = createSpacePresenter() + presenter.test { + val initialState = awaitItem() + initialState.eventSink(SpaceEvents.EnterManageMode) + // Select a room + initialState.eventSink(SpaceEvents.ToggleRoomSelection(A_ROOM_ID)) + var latestState = expectMostRecentItem() + assertThat(latestState.selectedRoomIds).containsExactly(A_ROOM_ID) + // Deselect the room + latestState.eventSink(SpaceEvents.ToggleRoomSelection(A_ROOM_ID)) + latestState = expectMostRecentItem() + assertThat(latestState.selectedRoomIds).isEmpty() + } + } + + @Test + fun `present - remove rooms success`() = runTest { + val removeChildFromSpaceResult = lambdaRecorder> { _, _ -> + Result.success(Unit) + } + val aRoom = aSpaceRoom( + roomId = A_ROOM_ID, + roomType = RoomType.Room, + ) + val fakeSpaceRoomList = FakeSpaceRoomList( + initialSpaceRoomsValue = listOf(aRoom), + paginateResult = { Result.success(Unit) }, + ) + val presenter = createSpacePresenter( + spaceRoomList = fakeSpaceRoomList, + spaceService = FakeSpaceService( + removeChildFromSpaceResult = removeChildFromSpaceResult, + ), + ) + presenter.test { + awaitItem() // Initial empty state + advanceUntilIdle() + val stateWithChildren = awaitItem() + assertThat(stateWithChildren.children).hasSize(1) + stateWithChildren.eventSink(SpaceEvents.EnterManageMode) + stateWithChildren.eventSink(SpaceEvents.ToggleRoomSelection(A_ROOM_ID)) + stateWithChildren.eventSink(SpaceEvents.RemoveSelectedRooms) + stateWithChildren.eventSink(SpaceEvents.ConfirmRoomRemoval) + advanceUntilIdle() + val successState = expectMostRecentItem() + assertThat(successState.removeRoomsAction).isEqualTo(AsyncAction.Success(Unit)) + assertThat(successState.isManageMode).isFalse() + assertThat(successState.children).isEmpty() + removeChildFromSpaceResult.assertions().isCalledOnce() + } + } + + @Test + fun `present - remove rooms partial failure`() = runTest { + val aRoom1 = aSpaceRoom( + roomId = A_ROOM_ID, + roomType = RoomType.Room, + ) + val aRoom2 = aSpaceRoom( + roomId = A_ROOM_ID_2, + roomType = RoomType.Room, + ) + val removeChildFromSpaceResult = lambdaRecorder> { _, childId -> + if (childId == A_ROOM_ID_2) { + Result.failure(AN_EXCEPTION) + } else { + Result.success(Unit) + } + } + val fakeSpaceRoomList = FakeSpaceRoomList( + initialSpaceRoomsValue = listOf(aRoom1, aRoom2), + paginateResult = { Result.success(Unit) }, + ) + val presenter = createSpacePresenter( + spaceRoomList = fakeSpaceRoomList, + spaceService = FakeSpaceService( + removeChildFromSpaceResult = removeChildFromSpaceResult, + ), + ) + presenter.test { + awaitItem() // Initial empty state + advanceUntilIdle() + val stateWithChildren = awaitItem() + assertThat(stateWithChildren.children).hasSize(2) + stateWithChildren.eventSink(SpaceEvents.EnterManageMode) + stateWithChildren.eventSink(SpaceEvents.ToggleRoomSelection(A_ROOM_ID)) + stateWithChildren.eventSink(SpaceEvents.ToggleRoomSelection(A_ROOM_ID_2)) + stateWithChildren.eventSink(SpaceEvents.RemoveSelectedRooms) + stateWithChildren.eventSink(SpaceEvents.ConfirmRoomRemoval) + advanceUntilIdle() + val failureState = expectMostRecentItem() + assertThat(failureState.removeRoomsAction.isFailure()).isTrue() + // Successfully removed room should be filtered out + assertThat(failureState.children.map { it.roomId }).doesNotContain(A_ROOM_ID) + // Failed room should still be present + assertThat(failureState.children.map { it.roomId }).contains(A_ROOM_ID_2) + removeChildFromSpaceResult.assertions().isCalledExactly(2) + } + } + + @Test + fun `present - children filtered in manage mode shows only rooms`() = runTest { + val aRoom = aSpaceRoom( + roomId = A_ROOM_ID, + roomType = RoomType.Room, + ) + val aSubSpace = aSpaceRoom( + roomId = A_ROOM_ID_2, + roomType = RoomType.Space, + ) + val fakeSpaceRoomList = FakeSpaceRoomList( + initialSpaceRoomsValue = listOf(aRoom, aSubSpace), + paginateResult = { Result.success(Unit) }, + ) + val presenter = createSpacePresenter(spaceRoomList = fakeSpaceRoomList) + presenter.test { + awaitItem() // Initial empty state + advanceUntilIdle() + val stateWithChildren = awaitItem() + // Both room and space visible initially + assertThat(stateWithChildren.children).hasSize(2) + assertThat(stateWithChildren.isManageMode).isFalse() + stateWithChildren.eventSink(SpaceEvents.EnterManageMode) + val manageModeState = expectMostRecentItem() + // Only rooms visible in manage mode + assertThat(manageModeState.children).hasSize(1) + assertThat(manageModeState.children.first().roomId).isEqualTo(A_ROOM_ID) + assertThat(manageModeState.children.first().isSpace).isFalse() + } + } + + @Test + fun `present - removed rooms persist after flow update`() = runTest { + val removeChildFromSpaceResult = lambdaRecorder> { _, _ -> + Result.success(Unit) + } + val aRoom1 = aSpaceRoom( + roomId = A_ROOM_ID, + roomType = RoomType.Room, + ) + val aRoom2 = aSpaceRoom( + roomId = A_ROOM_ID_2, + roomType = RoomType.Room, + ) + val aRoom3 = aSpaceRoom( + roomId = A_ROOM_ID_3, + roomType = RoomType.Room, + ) + val spaceRoomList = FakeSpaceRoomList( + initialSpaceRoomsValue = listOf(aRoom1, aRoom2), + paginateResult = { Result.success(Unit) }, + ) + val presenter = createSpacePresenter( + spaceRoomList = spaceRoomList, + spaceService = FakeSpaceService( + removeChildFromSpaceResult = removeChildFromSpaceResult, + ), + ) + presenter.test { + awaitItem() // Initial empty state + advanceUntilIdle() + val stateWithChildren = awaitItem() + stateWithChildren.eventSink(SpaceEvents.EnterManageMode) + stateWithChildren.eventSink(SpaceEvents.ToggleRoomSelection(A_ROOM_ID)) + stateWithChildren.eventSink(SpaceEvents.RemoveSelectedRooms) + stateWithChildren.eventSink(SpaceEvents.ConfirmRoomRemoval) + advanceUntilIdle() + val successState = expectMostRecentItem() + assertThat(successState.children.map { it.roomId }).doesNotContain(A_ROOM_ID) + // Emit new flow update with a new room added (simulating server refresh) + spaceRoomList.emitSpaceRooms(listOf(aRoom1, aRoom2, aRoom3)) + advanceUntilIdle() + val afterFlowUpdate = awaitItem() + // A_ROOM_ID should still be filtered out even though it's in the new emission + assertThat(afterFlowUpdate.children.map { it.roomId }).doesNotContain(A_ROOM_ID) + // But the other rooms should be present + assertThat(afterFlowUpdate.children.map { it.roomId }).contains(A_ROOM_ID_2) + assertThat(afterFlowUpdate.children.map { it.roomId }).contains(A_ROOM_ID_3) + } + } + private fun TestScope.createSpacePresenter( client: MatrixClient = FakeMatrixClient(), - spaceRoomList: SpaceRoomList = FakeSpaceRoomList(), + room: BaseRoom = FakeBaseRoom(), + spaceRoomList: SpaceRoomList = FakeSpaceRoomList( + paginateResult = { Result.success(Unit) } + ), seenInvitesStore: SeenInvitesStore = InMemorySeenInvitesStore(), joinRoom: JoinRoom = FakeJoinRoom( lambda = { _, _, _ -> Result.success(Unit) }, ), acceptDeclineInvitePresenter: Presenter = Presenter { anAcceptDeclineInviteState() }, + spaceSettingsEnabled: Boolean = false, + spaceService: FakeSpaceService = FakeSpaceService(), ): SpacePresenter { return SpacePresenter( client = client, + room = room, spaceRoomList = spaceRoomList, seenInvitesStore = seenInvitesStore, joinRoom = joinRoom, acceptDeclineInvitePresenter = acceptDeclineInvitePresenter, sessionCoroutineScope = backgroundScope, + featureFlagService = FakeFeatureFlagService( + initialState = mapOf( + FeatureFlags.SpaceSettings.key to spaceSettingsEnabled, + ) + ), + spaceService = spaceService, ) } } diff --git a/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/root/SpaceStateTest.kt b/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/root/SpaceStateTest.kt index 440ec1b6a5b..65bb7405413 100644 --- a/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/root/SpaceStateTest.kt +++ b/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/root/SpaceStateTest.kt @@ -10,17 +10,19 @@ package io.element.android.features.space.impl.root import com.google.common.truth.Truth.assertThat import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.api.room.RoomType import io.element.android.libraries.matrix.test.AN_EXCEPTION import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_ROOM_ID_2 import io.element.android.libraries.matrix.test.A_ROOM_ID_3 +import io.element.android.libraries.previewutils.room.aSpaceRoom import org.junit.Test class SpaceStateTest { @Test fun `test default state`() { val state = aSpaceState() - assertThat(state.hasAnyFailure).isFalse() + assertThat(state.hasAnyJoinFailures).isFalse() assertThat(state.isJoining(A_ROOM_ID)).isFalse() } @@ -33,7 +35,7 @@ class SpaceStateTest { A_ROOM_ID_3 to AsyncAction.Success(Unit), ) ) - assertThat(state.hasAnyFailure).isTrue() + assertThat(state.hasAnyJoinFailures).isTrue() } @Test @@ -45,4 +47,80 @@ class SpaceStateTest { ) assertThat(state.isJoining(A_ROOM_ID)).isTrue() } + + @Test + fun `test isSelected returns true for selected room`() { + val state = aSpaceState( + selectedRoomIds = setOf(A_ROOM_ID) + ) + assertThat(state.isSelected(A_ROOM_ID)).isTrue() + } + + @Test + fun `test isSelected returns false for non-selected room`() { + val state = aSpaceState( + selectedRoomIds = setOf(A_ROOM_ID) + ) + assertThat(state.isSelected(A_ROOM_ID_2)).isFalse() + } + + @Test + fun `test showManageRoomsAction true when canManageRooms and has room children`() { + val state = aSpaceState( + canManageRooms = true, + children = listOf(aSpaceRoom(roomType = RoomType.Room)) + ) + assertThat(state.showManageRoomsAction).isTrue() + } + + @Test + fun `test showManageRoomsAction false when canManageRooms but children empty`() { + val state = aSpaceState( + canManageRooms = true, + children = emptyList() + ) + assertThat(state.showManageRoomsAction).isFalse() + } + + @Test + fun `test showManageRoomsAction false when canManageRooms but only space children`() { + val state = aSpaceState( + canManageRooms = true, + children = listOf(aSpaceRoom(roomType = RoomType.Space)) + ) + assertThat(state.showManageRoomsAction).isFalse() + } + + @Test + fun `test showManageRoomsAction false when has room children but canManageRooms false`() { + val state = aSpaceState( + canManageRooms = false, + children = listOf(aSpaceRoom(roomType = RoomType.Room)) + ) + assertThat(state.showManageRoomsAction).isFalse() + } + + @Test + fun `test selectedCount returns correct count`() { + val state = aSpaceState( + selectedRoomIds = setOf(A_ROOM_ID, A_ROOM_ID_2, A_ROOM_ID_3) + ) + assertThat(state.selectedCount).isEqualTo(3) + } + + @Test + fun `test isRemoveButtonEnabled true when selectedRoomIds not empty`() { + val state = aSpaceState( + selectedRoomIds = setOf(A_ROOM_ID) + ) + assertThat(state.isRemoveButtonEnabled).isTrue() + } + + @Test + fun `test isRemoveButtonEnabled false when selectedRoomIds empty`() { + val state = aSpaceState( + selectedRoomIds = emptySet() + ) + assertThat(state.isRemoveButtonEnabled).isFalse() + } } diff --git a/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/root/SpaceViewTest.kt b/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/root/SpaceViewTest.kt index 59e323af7c5..27970e93f82 100644 --- a/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/root/SpaceViewTest.kt +++ b/features/space/impl/src/test/kotlin/io/element/android/features/space/impl/root/SpaceViewTest.kt @@ -15,11 +15,13 @@ import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.matrix.api.room.CurrentUserMembership import io.element.android.libraries.matrix.api.spaces.SpaceRoom import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_ROOM_NAME import io.element.android.libraries.matrix.test.A_ROOM_TOPIC +import io.element.android.libraries.matrix.test.room.aRoomInfo import io.element.android.libraries.previewutils.room.aSpaceRoom import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.tests.testutils.EnsureNeverCalled @@ -29,6 +31,7 @@ import io.element.android.tests.testutils.clickOn import io.element.android.tests.testutils.ensureCalledOnce import io.element.android.tests.testutils.ensureCalledOnceWithParam import io.element.android.tests.testutils.pressBack +import io.element.android.tests.testutils.pressBackKey import org.junit.Rule import org.junit.Test import org.junit.rules.TestRule @@ -124,7 +127,7 @@ class SpaceViewTest { val eventsRecorder = EventsRecorder() rule.setSpaceView( aSpaceState( - parentSpace = aSpaceRoom(topic = A_ROOM_TOPIC), + spaceInfo = aRoomInfo(topic = A_ROOM_TOPIC), hasMoreToLoad = false, eventSink = eventsRecorder, ) @@ -132,6 +135,71 @@ class SpaceViewTest { rule.onNodeWithText(A_ROOM_TOPIC).performClick() eventsRecorder.assertSingle(SpaceEvents.ShowTopicViewer(A_ROOM_TOPIC)) } + + @Test + fun `clicking back in manage mode emits ExitManageMode event`() { + val eventsRecorder = EventsRecorder() + rule.setSpaceView( + aSpaceState( + hasMoreToLoad = false, + isManageMode = true, + eventSink = eventsRecorder, + ) + ) + rule.pressBackKey() + eventsRecorder.assertSingle(SpaceEvents.ExitManageMode) + } + + @Test + fun `clicking on room in manage mode emits ToggleRoomSelection event`() { + val aSpaceRoom = aSpaceRoom(roomId = A_ROOM_ID, displayName = A_ROOM_NAME) + val eventsRecorder = EventsRecorder() + rule.setSpaceView( + aSpaceState( + children = listOf(aSpaceRoom), + hasMoreToLoad = false, + isManageMode = true, + eventSink = eventsRecorder, + ) + ) + rule.onNodeWithText(A_ROOM_NAME).performClick() + eventsRecorder.assertSingle(SpaceEvents.ToggleRoomSelection(A_ROOM_ID)) + } + + @Test + fun `clicking remove button emits RemoveSelectedRooms event`() { + val eventsRecorder = EventsRecorder() + rule.setSpaceView( + aSpaceState( + children = listOf(aSpaceRoom(roomId = A_ROOM_ID)), + hasMoreToLoad = false, + isManageMode = true, + selectedRoomIds = setOf(A_ROOM_ID), + eventSink = eventsRecorder, + ) + ) + rule.clickOn(CommonStrings.action_remove) + eventsRecorder.assertSingle(SpaceEvents.RemoveSelectedRooms) + } + + @Config(qualifiers = "h1024dp") + @Test + fun `clicking confirm in removal dialog emits ConfirmRoomRemoval event`() { + val eventsRecorder = EventsRecorder() + rule.setSpaceView( + aSpaceState( + children = listOf(aSpaceRoom(roomId = A_ROOM_ID)), + hasMoreToLoad = false, + isManageMode = true, + selectedRoomIds = setOf(A_ROOM_ID), + removeRoomsAction = AsyncAction.ConfirmingNoParams, + eventSink = eventsRecorder, + ) + ) + // Click on the Remove button in the confirmation dialog + rule.clickOn(CommonStrings.action_remove, inDialog = true) + eventsRecorder.assertSingle(SpaceEvents.ConfirmRoomRemoval) + } } private fun AndroidComposeTestRule.setSpaceView( @@ -140,7 +208,7 @@ private fun AndroidComposeTestRule.setSpace onRoomClick: (SpaceRoom) -> Unit = EnsureNeverCalledWithParam(), onShareSpace: () -> Unit = EnsureNeverCalled(), onLeaveSpaceClick: () -> Unit = EnsureNeverCalled(), - onDetailsClick: () -> Unit = EnsureNeverCalled(), + onSettingsClick: () -> Unit = EnsureNeverCalled(), onViewMembersClick: () -> Unit = EnsureNeverCalled(), acceptDeclineInviteView: @Composable () -> Unit = {}, ) { @@ -151,7 +219,7 @@ private fun AndroidComposeTestRule.setSpace onRoomClick = onRoomClick, onShareSpace = onShareSpace, onLeaveSpaceClick = onLeaveSpaceClick, - onDetailsClick = onDetailsClick, + onSettingsClick = onSettingsClick, onViewMembersClick = onViewMembersClick, acceptDeclineInviteView = acceptDeclineInviteView, ) 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 236d92fd4a9..c7d9aa4fd08 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 @@ -81,6 +81,7 @@ class StartChatFlowNode( } } createRoomEntryPoint.createNode( + isSpace = false, parentNode = this, buildContext = buildContext, callback = callback, diff --git a/features/startchat/impl/src/main/res/values-hr/translations.xml b/features/startchat/impl/src/main/res/values-hr/translations.xml new file mode 100644 index 00000000000..8f0407fcdee --- /dev/null +++ b/features/startchat/impl/src/main/res/values-hr/translations.xml @@ -0,0 +1,12 @@ + + + "Nova soba" + "Direktorij soba" + "Došlo je do pogreške prilikom pokretanja razgovora" + "Pridruži se sobi putem adrese" + "Adresa nije valjana" + "Unos…" + "Pronađena je odgovarajuća soba" + "Soba nije pronađena" + "npr. #naziv-sobe:matrix.org" + diff --git a/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/root/UserProfilePresenter.kt b/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/root/UserProfilePresenter.kt index b4481a11838..51398410bea 100644 --- a/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/root/UserProfilePresenter.kt +++ b/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/root/UserProfilePresenter.kt @@ -38,6 +38,8 @@ import io.element.android.libraries.featureflag.api.FeatureFlags 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.UserId +import io.element.android.libraries.matrix.api.room.powerlevels.canCall +import io.element.android.libraries.matrix.api.room.powerlevels.use import io.element.android.libraries.matrix.api.user.MatrixUser import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.distinctUntilChanged @@ -61,7 +63,7 @@ class UserProfilePresenter( @Composable private fun getDmRoomId(): State { - return produceState(initialValue = null) { + return produceState(initialValue = null) { value = client.findDM(userId).getOrNull() } } @@ -71,7 +73,6 @@ class UserProfilePresenter( val isElementCallAvailable by produceState(initialValue = false, roomId) { value = sessionEnterpriseService.isElementCallAvailable() } - return produceState(initialValue = false, isElementCallAvailable, roomId) { value = when { isElementCallAvailable.not() -> false @@ -80,7 +81,7 @@ class UserProfilePresenter( roomId ?.let { client.getRoom(it) } ?.use { room -> - room.canUserJoinCall(client.sessionId).getOrNull() + room.roomPermissions().use(false) { perms -> perms.canCall() } } .orFalse() } 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 69fbf74f680..b899a067c63 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 @@ -29,6 +29,7 @@ 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.UserId import io.element.android.libraries.matrix.api.encryption.identity.IdentityState +import io.element.android.libraries.matrix.api.room.StateEventType 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_ROOM_ID @@ -38,9 +39,11 @@ import io.element.android.libraries.matrix.test.FakeMatrixClient 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.room.FakeBaseRoom +import io.element.android.libraries.matrix.test.room.powerlevels.FakeRoomPermissions import io.element.android.libraries.matrix.ui.components.aMatrixUser import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.lambda.any +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 @@ -91,15 +94,7 @@ class UserProfilePresenterTest { @Test fun `present - canCall is false when canUserJoinCall returns false`() { testCanCall( - canUserJoinCallResult = Result.success(false), - expectedResult = false, - ) - } - - @Test - fun `present - canCall is false when canUserJoinCall fails`() { - testCanCall( - canUserJoinCallResult = Result.failure(AN_EXCEPTION), + canUserJoinCall = false, expectedResult = false, ) } @@ -130,7 +125,7 @@ class UserProfilePresenterTest { private fun testCanCall( isElementCallAvailable: Boolean = true, - canUserJoinCallResult: Result = Result.success(true), + canUserJoinCall: Boolean = true, dmRoom: RoomId? = A_ROOM_ID, canFindRoom: Boolean = true, expectedResult: Boolean, @@ -138,7 +133,14 @@ class UserProfilePresenterTest { checkThatRoomIsDestroyed: Boolean = false, ) = runTest { val room = FakeBaseRoom( - canUserJoinCallResult = { canUserJoinCallResult }, + roomPermissions = FakeRoomPermissions( + canSendState = { type -> + when (type) { + StateEventType.CallMember -> canUserJoinCall + else -> lambdaError() + } + } + ), ) val client = createFakeMatrixClient().apply { if (canFindRoom) { diff --git a/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileHeaderSection.kt b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileHeaderSection.kt index 9051389d022..5f3754a361f 100644 --- a/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileHeaderSection.kt +++ b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileHeaderSection.kt @@ -67,7 +67,7 @@ fun UserProfileHeaderSection( Avatar( avatarData = AvatarData(userId.value, userName, avatarUrl, AvatarSize.UserHeader), avatarType = AvatarType.User, - contentDescription = avatarUrl?.let { stringResource(CommonStrings.a11y_user_avatar) }, + contentDescription = stringResource(CommonStrings.a11y_user_avatar), modifier = Modifier .clip(CircleShape) .clickable( diff --git a/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/blockuser/BlockUserSection.kt b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/blockuser/BlockUserSection.kt index 7a73c60e4a8..c3caffa7f34 100644 --- a/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/blockuser/BlockUserSection.kt +++ b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/blockuser/BlockUserSection.kt @@ -70,7 +70,7 @@ private fun PreferenceBlockUser( isLoading: Boolean, eventSink: (UserProfileEvents) -> Unit, ) { - val loadingCurrentValue = @Composable { + val loadingCurrentValue = @Composable { _: Boolean -> CircularProgressIndicator( modifier = Modifier .progressSemantics() diff --git a/features/userprofile/shared/src/main/res/values-hr/translations.xml b/features/userprofile/shared/src/main/res/values-hr/translations.xml new file mode 100644 index 00000000000..5bfaf99a612 --- /dev/null +++ b/features/userprofile/shared/src/main/res/values-hr/translations.xml @@ -0,0 +1,19 @@ + + + "Blokiraj" + "Blokirani korisnici neće vam moći slati poruke i sve njihove poruke bit će skrivene. Možete ih odblokirati u bilo kojem trenutku." + "Blokiraj korisnika" + "Odblokiraj" + "Moći ćete ponovno vidjeti sve njihove poruke." + "Odblokiraj korisnika" + "Blokiraj" + "Blokirani korisnici neće vam moći slati poruke i sve njihove poruke bit će skrivene. Možete ih odblokirati u bilo kojem trenutku." + "Blokiraj korisnika" + "Profil" + "Odblokiraj" + "Moći ćete ponovno vidjeti sve njihove poruke." + "Odblokiraj korisnika" + "Pomoću mrežne aplikacije provjerite ovog korisnika." + "Provjeri korisnika %1$s" + "Došlo je do pogreške prilikom pokretanja razgovora" + diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationView.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationView.kt index 286d826a6a8..e86c83825b8 100644 --- a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationView.kt +++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/incoming/IncomingVerificationView.kt @@ -21,10 +21,8 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.ProgressBarRangeInfo import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.focused -import androidx.compose.ui.semantics.progressBarRangeInfo import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.tooling.preview.Preview @@ -45,7 +43,6 @@ import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.components.Button -import io.element.android.libraries.designsystem.theme.components.InvisibleButton 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 @@ -73,11 +70,7 @@ fun IncomingVerificationView( TopAppBar( title = {}, navigationIcon = { - when { - step is Step.Initial && !step.isWaiting -> Unit - step is Step.Completed -> Unit - else -> BackButton(onClick = { state.eventSink(IncomingVerificationViewEvents.GoBack) }) - } + BackButton(onClick = { state.eventSink(IncomingVerificationViewEvents.GoBack) }) }, colors = topAppBarColors(containerColor = Color.Transparent), ) @@ -103,19 +96,11 @@ fun IncomingVerificationView( private fun IncomingVerificationHeader(step: Step, request: VerificationRequest.Incoming) { val iconStyle = when (step) { Step.Canceled -> BigIcon.Style.AlertSolid - is Step.Initial -> if (step.isWaiting) { - BigIcon.Style.Loading - } else { - when (request) { - is VerificationRequest.Incoming.OtherSession -> BigIcon.Style.Default(CompoundIcons.LockSolid()) - is VerificationRequest.Incoming.User -> BigIcon.Style.Default(CompoundIcons.UserProfileSolid()) - } - } - is Step.Verifying -> if (step.isWaiting) { - BigIcon.Style.Loading - } else { - BigIcon.Style.Default(CompoundIcons.ReactionSolid()) + is Step.Initial -> when (request) { + is VerificationRequest.Incoming.OtherSession -> BigIcon.Style.Default(CompoundIcons.Devices()) + is VerificationRequest.Incoming.User -> BigIcon.Style.Default(CompoundIcons.UserProfileSolid()) } + is Step.Verifying -> BigIcon.Style.Default(CompoundIcons.ReactionSolid()) Step.Completed -> BigIcon.Style.SuccessSolid Step.Failure -> BigIcon.Style.AlertSolid } @@ -159,10 +144,6 @@ private fun IncomingVerificationHeader(step: Step, request: VerificationRequest. .semantics(mergeDescendants = true) { contentDescription = timeLimitMessage focused = true - if (iconStyle == BigIcon.Style.Loading) { - // Same code than Modifier.progressSemantics() - progressBarRangeInfo = ProgressBarRangeInfo.Indeterminate - } } .focusable(), iconStyle = iconStyle, @@ -185,7 +166,7 @@ private fun IncomingVerificationContent( @Composable private fun ContentInitial( - initialIncoming: Step.Initial, + stepInitial: Step.Initial, request: VerificationRequest.Incoming, ) { when (request) { @@ -195,9 +176,9 @@ private fun ContentInitial( verticalArrangement = Arrangement.spacedBy(24.dp), ) { SessionDetailsView( - deviceName = initialIncoming.deviceDisplayName, - deviceId = initialIncoming.deviceId, - signInFormattedTimestamp = initialIncoming.formattedSignInTime, + deviceName = stepInitial.deviceDisplayName, + deviceId = stepInitial.deviceId, + signInFormattedTimestamp = stepInitial.formattedSignInTime, ) Text( modifier = Modifier @@ -227,48 +208,42 @@ private fun ContentInitial( private fun IncomingVerificationBottomMenu( state: IncomingVerificationState, ) { - val step = state.step val eventSink = state.eventSink - - when (step) { + when (val step = state.step) { is Step.Initial -> { - if (step.isWaiting) { - // Show nothing - } else { - VerificationBottomMenu { - Button( - modifier = Modifier.fillMaxWidth(), - text = stringResource(CommonStrings.action_start_verification), - onClick = { eventSink(IncomingVerificationViewEvents.StartVerification) }, - ) - TextButton( - modifier = Modifier.fillMaxWidth(), - text = stringResource(CommonStrings.action_ignore), - onClick = { eventSink(IncomingVerificationViewEvents.IgnoreVerification) }, - ) - } + VerificationBottomMenu { + Button( + modifier = Modifier.fillMaxWidth(), + text = stringResource(CommonStrings.action_start_verification), + enabled = !step.isWaiting, + showProgress = step.isWaiting, + onClick = { eventSink(IncomingVerificationViewEvents.StartVerification) }, + ) + TextButton( + modifier = Modifier.fillMaxWidth(), + text = stringResource(CommonStrings.action_ignore), + enabled = !step.isWaiting, + onClick = { eventSink(IncomingVerificationViewEvents.IgnoreVerification) }, + ) } } is Step.Verifying -> { - if (step.isWaiting) { - // Add invisible buttons to keep the same screen layout - VerificationBottomMenu { - InvisibleButton() - InvisibleButton() - } - } else { - VerificationBottomMenu { - Button( - modifier = Modifier.fillMaxWidth(), - text = stringResource(R.string.screen_session_verification_they_match), - onClick = { eventSink(IncomingVerificationViewEvents.ConfirmVerification) }, - ) - TextButton( - modifier = Modifier.fillMaxWidth(), - text = stringResource(R.string.screen_session_verification_they_dont_match), - onClick = { eventSink(IncomingVerificationViewEvents.DeclineVerification) }, - ) - } + VerificationBottomMenu { + Button( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.screen_session_verification_they_match), + enabled = !step.isWaiting, + showProgress = step.isWaiting, + onClick = { + eventSink(IncomingVerificationViewEvents.ConfirmVerification) + }, + ) + TextButton( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.screen_session_verification_they_dont_match), + enabled = !step.isWaiting, + onClick = { eventSink(IncomingVerificationViewEvents.DeclineVerification) }, + ) } } Step.Canceled, @@ -278,7 +253,9 @@ private fun IncomingVerificationBottomMenu( Button( modifier = Modifier.fillMaxWidth(), text = stringResource(CommonStrings.action_done), - onClick = { eventSink(IncomingVerificationViewEvents.GoBack) }, + onClick = { + eventSink(IncomingVerificationViewEvents.GoBack) + }, ) } } 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 f2d3ccfee29..2dd28501741 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 @@ -24,10 +24,8 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.ProgressBarRangeInfo import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.focused -import androidx.compose.ui.semantics.progressBarRangeInfo import androidx.compose.ui.semantics.semantics import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp @@ -37,7 +35,6 @@ import io.element.android.features.verifysession.impl.R import io.element.android.features.verifysession.impl.outgoing.OutgoingVerificationState.Step import io.element.android.features.verifysession.impl.ui.VerificationBottomMenu import io.element.android.features.verifysession.impl.ui.VerificationContentVerifying -import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage import io.element.android.libraries.designsystem.components.BigIcon @@ -96,20 +93,18 @@ fun OutgoingVerificationView( topBar = { TopAppBar( title = {}, - navigationIcon = if (step != Step.Completed) { - { BackButton(onClick = ::cancelOrResetFlow) } - } else { - {} + navigationIcon = { + BackButton(onClick = ::cancelOrResetFlow) }, - colors = topAppBarColors(containerColor = Color.Transparent) + colors = topAppBarColors(containerColor = Color.Transparent), ) }, header = { OutgoingVerificationHeader(step = step, request = state.request) }, footer = { - OutgoingVerificationViewBottomMenu( - screenState = state, + OutgoingVerificationBottomMenu( + state = state, onCancelClick = ::cancelOrResetFlow, onContinueClick = onFinish, ) @@ -117,7 +112,7 @@ fun OutgoingVerificationView( isScrollable = true, ) { OutgoingVerificationContent( - flowState = step, + step = step, request = state.request, onLearnMoreClick = onLearnMoreClick, ) @@ -129,20 +124,16 @@ fun OutgoingVerificationView( private fun OutgoingVerificationHeader(step: Step, request: VerificationRequest.Outgoing) { val iconStyle = when (step) { Step.Loading -> error("Should not happen") + Step.AwaitingOtherDeviceResponse, Step.Initial -> when (request) { is VerificationRequest.Outgoing.CurrentSession -> BigIcon.Style.Default(CompoundIcons.Devices()) - is VerificationRequest.Outgoing.User -> BigIcon.Style.Default(CompoundIcons.LockSolid()) + is VerificationRequest.Outgoing.User -> BigIcon.Style.Default(CompoundIcons.UserProfileSolid()) } - Step.AwaitingOtherDeviceResponse -> BigIcon.Style.Loading Step.Canceled -> BigIcon.Style.AlertSolid Step.Ready -> BigIcon.Style.Default(CompoundIcons.ReactionSolid()) Step.Completed -> BigIcon.Style.SuccessSolid is Step.Verifying -> { - if (step.state is AsyncData.Loading) { - BigIcon.Style.Loading - } else { - BigIcon.Style.Default(CompoundIcons.ReactionSolid()) - } + BigIcon.Style.Default(CompoundIcons.ReactionSolid()) } is Step.Exit -> return } @@ -201,10 +192,6 @@ private fun OutgoingVerificationHeader(step: Step, request: VerificationRequest. .semantics(mergeDescendants = true) { contentDescription = timeLimitMessage focused = true - if (iconStyle == BigIcon.Style.Loading) { - // Same code than Modifier.progressSemantics() - progressBarRangeInfo = ProgressBarRangeInfo.Indeterminate - } } .focusable(), iconStyle = iconStyle, @@ -215,20 +202,16 @@ private fun OutgoingVerificationHeader(step: Step, request: VerificationRequest. @Composable private fun OutgoingVerificationContent( - flowState: Step, + step: Step, request: VerificationRequest.Outgoing, onLearnMoreClick: () -> Unit, ) { - when (flowState) { - is Step.Initial -> { - when (request) { - is VerificationRequest.Outgoing.CurrentSession -> Unit - is VerificationRequest.Outgoing.User -> ContentInitial(onLearnMoreClick) - } - } - is Step.Verifying -> { - VerificationContentVerifying(flowState.data) + when (step) { + is Step.Initial -> when (request) { + is VerificationRequest.Outgoing.CurrentSession -> Unit + is VerificationRequest.Outgoing.User -> ContentInitial(onLearnMoreClick) } + is Step.Verifying -> VerificationContentVerifying(step.data) else -> Unit } } @@ -252,23 +235,23 @@ private fun ContentInitial( } @Composable -private fun OutgoingVerificationViewBottomMenu( - screenState: OutgoingVerificationState, +private fun OutgoingVerificationBottomMenu( + state: OutgoingVerificationState, onCancelClick: () -> Unit, onContinueClick: () -> Unit, ) { - val verificationViewState = screenState.step - val eventSink = screenState.eventSink - - val isVerifying = (verificationViewState as? Step.Verifying)?.state is AsyncData.Loading - - when (verificationViewState) { + val eventSink = state.eventSink + when (val step = state.step) { Step.Loading -> error("Should not happen") + is Step.AwaitingOtherDeviceResponse, is Step.Initial -> { VerificationBottomMenu { + val isWaiting = step is Step.AwaitingOtherDeviceResponse Button( modifier = Modifier.fillMaxWidth(), text = stringResource(CommonStrings.action_start_verification), + enabled = !isWaiting, + showProgress = isWaiting, onClick = { eventSink(OutgoingVerificationViewEvents.RequestVerification) }, ) InvisibleButton() @@ -298,30 +281,26 @@ private fun OutgoingVerificationViewBottomMenu( ) } } - is Step.AwaitingOtherDeviceResponse -> Unit is Step.Verifying -> { - if (isVerifying) { - // Add invisible buttons to keep the same screen layout - VerificationBottomMenu { - InvisibleButton() - InvisibleButton() - } - } else { - VerificationBottomMenu { - Button( - modifier = Modifier.fillMaxWidth(), - text = stringResource(R.string.screen_session_verification_they_match), - onClick = { - eventSink(OutgoingVerificationViewEvents.ConfirmVerification) - }, - ) - - TextButton( - modifier = Modifier.fillMaxWidth(), - text = stringResource(R.string.screen_session_verification_they_dont_match), - onClick = { eventSink(OutgoingVerificationViewEvents.DeclineVerification) }, - ) - } + val isVerifying = step.state.isLoading() + VerificationBottomMenu { + Button( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.screen_session_verification_they_match), + enabled = !isVerifying, + showProgress = isVerifying, + onClick = { + eventSink(OutgoingVerificationViewEvents.ConfirmVerification) + }, + ) + TextButton( + modifier = Modifier.fillMaxWidth(), + text = stringResource(R.string.screen_session_verification_they_dont_match), + enabled = !isVerifying, + onClick = { + eventSink(OutgoingVerificationViewEvents.DeclineVerification) + }, + ) } } is Step.Completed -> { @@ -334,7 +313,7 @@ private fun OutgoingVerificationViewBottomMenu( InvisibleButton() } } - is Step.Exit -> return + is Step.Exit -> Unit } } diff --git a/features/verifysession/impl/src/main/res/values-hr/translations.xml b/features/verifysession/impl/src/main/res/values-hr/translations.xml new file mode 100644 index 00000000000..a2ccb7ce0ac --- /dev/null +++ b/features/verifysession/impl/src/main/res/values-hr/translations.xml @@ -0,0 +1,54 @@ + + + "Ne možete potvrditi?" + "Izradi novi ključ za oporavak" + "Potvrdite ovaj uređaj kako biste postavili sigurnu razmjenu poruka." + "Potvrdite svoj identitet" + "Upotrijebite drugi uređaj" + "Upotrijebi ključ za oporavak" + "Sada možete sigurno čitati ili slati poruke, a svatko s kim razgovarate također može vjerovati ovom uređaju." + "Uređaj je potvrđen" + "Upotrijebite drugi uređaj" + "Čekanje na drugi uređaj…" + "Nešto nije u redu. Zahtjev je istekao ili je odbijen." + "Potvrdite da se emotikoni u nastavku podudaraju s onima prikazanima na vašem drugom uređaju." + "Usporedi emotikone" + "Potvrdite da se emotikoni u nastavku podudaraju s onima prikazanima na uređaju drugog korisnika." + "Potvrdite da se brojevi u nastavku podudaraju s onima prikazanima na vašoj drugoj sesiji." + "Usporedi brojeve" + "Sada možete sigurno čitati ili slati poruke na svom drugom uređaju." + "Sada možete biti sigurni u vjerodostojnost identiteta ovog korisnika prilikom slanja ili primanja poruka." + "Uređaj je potvrđen" + "Unesi ključ za oporavak" + "Zahtjev je istekao, odbijen je ili je došlo do neusklađenosti u provjeri." + "Dokažite da ste to vi kako biste mogli pristupiti povijesti svojih šifriranih poruka." + "Otvori postojeću sesiju" + "Ponovi provjeru" + "Spreman/na sam." + "Čekanje podudaranja…" + "Usporedite jedinstveni skup emotikona." + "Usporedite jedinstvene emotikone, pazeći pritom da se pojavljuju istim redoslijedom." + "Prijavljen/a" + "Zahtjev je istekao, odbijen je ili je došlo do neusklađenosti u provjeri." + "Provjera nije uspjela" + "Nastavite samo ako ste pokrenuli ovu provjeru." + "Potvrdite drugi uređaj kako biste zaštitili povijest poruka." + "Sada možete sigurno čitati ili slati poruke na svom drugom uređaju." + "Uređaj je potvrđen" + "Zatražena je provjera" + "Ne podudaraju se" + "Podudaraju se" + "Prije nego što odavde započnete provjeru, provjerite jeste li otvorili aplikaciju na drugom uređaju." + "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?" + "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" + "Započni provjeru na drugom uređaju" + "Čeka se drugi korisnik" + "Nakon prihvaćanja moći ćete nastaviti s potvrđivanjem." + "Prihvatite zahtjev za pokretanje postupka provjere u drugoj sesiji kako biste nastavili." + "Čekanje na prihvaćanje zahtjeva" + "Odjavljivanje…" + diff --git a/features/verifysession/impl/src/main/res/values-uz/translations.xml b/features/verifysession/impl/src/main/res/values-uz/translations.xml index 04169a71d07..26ba7393df2 100644 --- a/features/verifysession/impl/src/main/res/values-uz/translations.xml +++ b/features/verifysession/impl/src/main/res/values-uz/translations.xml @@ -16,7 +16,7 @@ "Quyidagi emojilar narigi foydalanuvchining qurilmasida ko‘rsatilgan emojilarga mos kelishini tasdiqlang." "Quyidagi raqamlarning boshqa sessiyangizda koʻrsatilgan raqamlarga mos kelishini tasdiqlang." "Sonlarni taqqoslash" - "Yangi seansingiz tasdiqlandi. U sizning shifrlangan xabarlaringizga kirish huquqiga ega va boshqa foydalanuvchilar uni ishonchli deb bilishadi." + "Endi xabarlarni boshqa qurilmangizda xavfsiz o‘qish yoki yuborishingiz mumkin." "Endi xabarlarni yuborish yoki qabul qilishda bu foydalanuvchining shaxsiga ishonishingiz mumkin." "Qurilma tasdiqlandi" "Tiklash kalitini kiriting" @@ -33,7 +33,7 @@ "Tasdiqlanmadi" "Bu tekshiruvni boshlagan bo‘lsangizgina davom eting." "Xabarlaringiz tarixini xavfsiz saqlash uchun narigi qurilmani tasdiqlang." - "Yangi seansingiz tasdiqlandi. U sizning shifrlangan xabarlaringizga kirish huquqiga ega va boshqa foydalanuvchilar uni ishonchli deb bilishadi." + "Endi xabarlarni boshqa qurilmangizda xavfsiz o‘qish yoki yuborishingiz mumkin." "Qurilma tasdiqlandi" "Tasdiqlash talab qilindi" "Ular mos kelmaydi" 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 614e45a7b82..c68d630869c 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 @@ -73,13 +73,14 @@ private fun LineRow( colorationMode: ColorationMode, ) { val context = LocalContext.current + val toastMessage = stringResource(CommonStrings.common_line_copied_to_clipboard) Row( modifier = Modifier .fillMaxWidth() .clickable(onClick = { context.copyToClipboard( text = line, - toastMessage = context.getString(CommonStrings.common_line_copied_to_clipboard), + toastMessage = toastMessage, ) }) ) { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f1fe6985a84..def12d5fd1a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,12 +4,12 @@ [versions] # Project # Tchap TODO : Upgrade gradle plugin (https://github.com/tchapgouv/tchap-x-android/issues/110) -#android_gradle_plugin = "8.13.1" +# android_gradle_plugin = "8.13.2" android_gradle_plugin = "8.11.1" # When updateing this, please also update the version in the file ./idea/kotlinc.xml -kotlin = "2.2.20" +kotlin = "2.3.0" kotlinpoet = "2.2.0" -ksp = "2.2.20-2.0.2" +ksp = "2.3.4" firebaseAppDistribution = "5.2.0" # AndroidX @@ -17,14 +17,14 @@ core = "1.17.0" datastore = "1.2.0" constraintlayout = "2.2.1" constraintlayout_compose = "1.1.1" -lifecycle = "2.9.2" -activity = "1.11.0" -media3 = "1.8.0" -camera = "1.5.1" +lifecycle = "2.10.0" +activity = "1.12.2" +media3 = "1.9.0" +camera = "1.5.2" work = "2.11.0" # Compose -compose_bom = "2025.07.00" +compose_bom = "2026.01.00" # Coroutines coroutines = "1.10.2" @@ -34,7 +34,7 @@ accompanist = "0.37.3" # Test test_core = "1.7.0" -roborazzi = "1.52.0" +roborazzi = "1.56.0" # Jetbrain datetime = "0.7.1" @@ -43,18 +43,18 @@ serialization_json = "1.9.0" #other coil = "3.3.0" # Rollback to 1.0.4, 1.0.5 has this issue: https://github.com/airbnb/Showkase/issues/420 -showkase = "1.0.4" +showkase = "1.0.5" appyx = "1.7.1" sqldelight = "2.2.1" -wysiwyg = "2.40.0" +wysiwyg = "2.41.0" telephoto = "0.18.0" -haze = "1.6.10" +haze = "1.7.1" # Dependency analysis dependencyAnalysis = "3.5.1" # DI -metro = "0.7.7" +metro = "0.9.4" # Auto service autoservice = "1.1.1" @@ -64,7 +64,7 @@ detekt = "1.23.8" # See https://github.com/pinterest/ktlint/releases/ ktlint = "1.8.0" androidx-test-ext-junit = "1.3.0" -kover = "0.9.1" +kover = "0.9.4" [libraries] # Project @@ -80,11 +80,11 @@ 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.6.0" +google_firebase_bom = "com.google.firebase:firebase-bom:34.8.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" } -google_tink = "com.google.crypto.tink:tink-android:1.19.0" +google_tink = "com.google.crypto.tink:tink-android:1.20.0" # AndroidX androidx_core = { module = "androidx.core:core", version.ref = "core" } @@ -92,7 +92,7 @@ androidx_corektx = { module = "androidx.core:core-ktx", version.ref = "core" } androidx_annotationjvm = "androidx.annotation:annotation-jvm:1.9.1" 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.1" +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" } @@ -111,13 +111,13 @@ androidx_media3_ui = { module = "androidx.media3:media3-ui", version.ref = "medi androidx_media3_transformer = { module = "androidx.media3:media3-transformer", version.ref = "media3" } androidx_media3_effect = { module = "androidx.media3:media3-effect", version.ref = "media3" } androidx_media3_common = { module = "androidx.media3:media3-common", version.ref = "media3" } -androidx_biometric = "androidx.biometric:biometric-ktx:1.2.0-alpha05" +androidx_biometric = "androidx.biometric:biometric-ktx:1.4.0-alpha02" androidx_activity_activity = { module = "androidx.activity:activity", version.ref = "activity" } 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.14.0" +androidx_webkit = "androidx.webkit:webkit:1.15.0" androidx_compose_bom = { module = "androidx.compose:compose-bom", version.ref = "compose_bom" } androidx_compose_material3 = { module = "androidx.compose.material3:material3" } @@ -162,14 +162,14 @@ test_corektx = { module = "androidx.test:core-ktx", version.ref = "test_core" } test_arch_core = "androidx.arch.core:core-testing:2.2.0" test_junit = "junit:junit:4.13.2" test_runner = "androidx.test:runner:1.7.0" -test_mockk = "io.mockk:mockk:1.14.6" +test_mockk = "io.mockk:mockk:1.14.7" test_konsist = "com.lemonappdev:konsist:0.17.3" 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.20" -test_robolectric = "org.robolectric:robolectric:4.15.1" +test_robolectric = "org.robolectric:robolectric:4.16" test_appyx_junit = { module = "com.bumble.appyx:testing-junit4", version.ref = "appyx" } -test_composable_preview_scanner = "io.github.sergio-sastre.ComposablePreviewScanner:android:0.7.2" +test_composable_preview_scanner = "io.github.sergio-sastre.ComposablePreviewScanner:android:0.8.1" test_detekt_api = { module = "io.gitlab.arturbosch.detekt:detekt-api", version.ref = "detekt" } test_detekt_test = { module = "io.gitlab.arturbosch.detekt:detekt-test", version.ref = "detekt" } @@ -179,7 +179,7 @@ test_detekt_test = { module = "io.gitlab.arturbosch.detekt:detekt-test", version # https://github.com/matrix-org/matrix-rust-components-kotlin/commits/main/sdk/sdk-android/src/main/kotlin/org/matrix/rustcomponents/sdk/matrix_sdk_ffi.kt # All new features should not be implemented in the pull request that upgrades the version, developers should # only fix API breaks and may add some TODOs. -matrix_sdk = "org.matrix.rustcomponents:sdk-android:25.12.2" +matrix_sdk = "org.matrix.rustcomponents:sdk-android:26.1.12" # Others coil = { module = "io.coil-kt.coil3:coil", version.ref = "coil" } @@ -201,25 +201,27 @@ 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.11.0" +sqlcipher = "net.zetetic:sqlcipher-android:4.12.0" sqlite = "androidx.sqlite:sqlite-ktx:2.6.2" -unifiedpush = "org.unifiedpush.android:connector:3.1.2" +unifiedpush = "org.unifiedpush.android:connector:3.2.0" vanniktech_blurhash = "com.vanniktech:blurhash:0.3.0" telephoto_zoomableimage = { module = "me.saket.telephoto:zoomable-image-coil", version.ref = "telephoto" } telephoto_flick = { module = "me.saket.telephoto:flick-android", version.ref = "telephoto" } statemachine = "com.freeletics.flowredux:compose:1.2.2" -maplibre = "org.maplibre.gl:android-sdk:12.2.0" +maplibre = "org.maplibre.gl:android-sdk:12.3.1" maplibre_ktx = "org.maplibre.gl:android-sdk-ktx-v7:3.0.2" maplibre_annotation = "org.maplibre.gl:android-plugin-annotation-v9:3.0.2" opusencoder = "io.element.android:opusencoder:1.2.0" zxing_cpp = "io.github.zxing-cpp:android:2.3.0" +google_zxing = "com.google.zxing:core:3.5.4" + 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.26.0" -sentry = "io.sentry:sentry-android:8.27.1" +posthog = "com.posthog:posthog-android:3.28.1" +sentry = "io.sentry:sentry-android:8.30.0" # main branch can be tested replacing the version with main-SNAPSHOT matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:0.29.2" @@ -266,7 +268,7 @@ roborazzi = { id = "io.github.takahirom.roborazzi", version.ref = "roborazzi" } sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" } firebaseAppDistribution = { id = "com.google.firebase.appdistribution", version.ref = "firebaseAppDistribution" } knit = { id = "org.jetbrains.kotlinx.knit", version = "0.5.0" } -sonarqube = "org.sonarqube:7.1.0.6387" +sonarqube = "org.sonarqube:7.2.2.6593" licensee = "app.cash.licensee:1.14.1" compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } gms_google_services = { id = "com.google.gms.google-services", version = "4.4.4" } diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/browser/ChromeCustomTab.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/browser/ChromeCustomTab.kt index 198518466b2..6650f70ecbd 100644 --- a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/browser/ChromeCustomTab.kt +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/browser/ChromeCustomTab.kt @@ -60,7 +60,7 @@ fun Activity.openUrlInChromeCustomTab( }) } .launchUrl(this, url.toUri()) - } catch (activityNotFoundException: ActivityNotFoundException) { + } catch (_: ActivityNotFoundException) { openUrlInExternalApp(url) } } diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/diff/DiffCacheUpdater.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/diff/DiffCacheUpdater.kt index fce510f69c7..fb3c610a018 100644 --- a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/diff/DiffCacheUpdater.kt +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/diff/DiffCacheUpdater.kt @@ -28,7 +28,7 @@ class DiffCacheUpdater( private val cacheInvalidator: DiffCacheInvalidator = DefaultDiffCacheInvalidator(), private val areItemsTheSame: (oldItem: ListItem?, newItem: ListItem?) -> Boolean, ) { - private val lock = Object() + private val lock = Any() private var prevOriginalList: List = emptyList() private val listUpdateCallback = object : ListUpdateCallback { diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/filesize/AndroidFileSizeFormatter.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/filesize/AndroidFileSizeFormatter.kt index 100fdcdfdc0..6854d099fab 100644 --- a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/filesize/AndroidFileSizeFormatter.kt +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/filesize/AndroidFileSizeFormatter.kt @@ -24,12 +24,15 @@ class AndroidFileSizeFormatter( override fun format(fileSize: Long, useShortFormat: Boolean): String { // Since Android O, the system considers that 1kB = 1000 bytes instead of 1024 bytes. // We want to avoid that. + // Sadly we do not have access to the flags values Formatter.FLAG_IEC_UNITS and Formatter.FLAG_SHORTER + // nor the method Formatter.formatFileSize with the flags parameter. + // So for Android 0 and more, first convert the fileSize to MB/GB/TB ourselves val normalizedSize = if (sdkIntProvider.get() <= Build.VERSION_CODES.N) { fileSize } else { // First convert the size when { - fileSize < 1024 -> fileSize + fileSize <= 1 -> fileSize fileSize < 1024 * 1024 -> fileSize * 1000 / 1024 fileSize < 1024 * 1024 * 1024 -> fileSize * 1000 / 1024 * 1000 / 1024 else -> fileSize * 1000 / 1024 * 1000 / 1024 * 1000 / 1024 @@ -40,6 +43,6 @@ class AndroidFileSizeFormatter( Formatter.formatShortFileSize(context, normalizedSize) } else { Formatter.formatFileSize(context, normalizedSize) - } + }.replace("kB", "KB") } } diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/hash/Hash.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/hash/Hash.kt index 211b60a6026..b699054ff04 100644 --- a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/hash/Hash.kt +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/hash/Hash.kt @@ -20,7 +20,7 @@ fun String.hash() = try { digest.digest() .joinToString("") { String.format(Locale.ROOT, "%02X", it) } .lowercase(Locale.ROOT) -} catch (exc: Exception) { +} catch (_: Exception) { // Should not happen, but just in case hashCode().toString() } diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/json/JsonProvider.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/json/JsonProvider.kt index 1876bbd3f51..1e255999627 100644 --- a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/json/JsonProvider.kt +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/json/JsonProvider.kt @@ -10,14 +10,15 @@ package io.element.android.libraries.androidutils.json import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding -import dev.zacsweers.metro.Provider import dev.zacsweers.metro.SingleIn import kotlinx.serialization.json.Json /** * Provides a Json instance configured to ignore unknown keys. */ -fun interface JsonProvider : Provider +fun interface JsonProvider { + operator fun invoke(): Json +} @ContributesBinding(AppScope::class) @SingleIn(AppScope::class) diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/Brightness.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/Brightness.kt new file mode 100644 index 00000000000..8cf1cc68279 --- /dev/null +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/Brightness.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2025 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.system + +import android.app.Activity +import android.view.WindowManager + +/** + * Set the screen brightness for the given activity. + * + * @receiver current Activity. + * @param full If true, override brightness to full; otherwise, set to none (default). + */ +fun Activity.setFullBrightness(full: Boolean) { + window.attributes = window.attributes.apply { + screenBrightness = if (full) { + WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_FULL + } else { + WindowManager.LayoutParams.BRIGHTNESS_OVERRIDE_NONE + } + } +} 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 354b68a32be..861aca032db 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 @@ -32,7 +32,7 @@ fun Context.getApplicationLabel(packageName: String): String { return try { val ai = packageManager.getApplicationInfoCompat(packageName, 0) packageManager.getApplicationLabel(ai).toString() - } catch (e: PackageManager.NameNotFoundException) { + } catch (_: PackageManager.NameNotFoundException) { packageName } } @@ -96,7 +96,7 @@ fun Context.startNotificationSettingsIntent( } else { startActivity(intent) } - } catch (activityNotFoundException: ActivityNotFoundException) { + } catch (_: ActivityNotFoundException) { toast(noActivityFoundMessage) } } @@ -112,7 +112,7 @@ fun Context.openAppSettingsPage( data = Uri.fromParts("package", packageName, null) } ) - } catch (activityNotFoundException: ActivityNotFoundException) { + } catch (_: ActivityNotFoundException) { toast(noActivityFoundMessage) } } @@ -126,7 +126,7 @@ fun Context.startInstallFromSourceIntent( .setData("package:$packageName".toUri()) try { activityResultLauncher.launch(intent) - } catch (activityNotFoundException: ActivityNotFoundException) { + } catch (_: ActivityNotFoundException) { toast(noActivityFoundMessage) } } @@ -157,7 +157,7 @@ fun Context.startSharePlainTextIntent( } else { startActivity(intent) } - } catch (activityNotFoundException: ActivityNotFoundException) { + } catch (_: ActivityNotFoundException) { toast(noActivityFoundMessage) } } diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/text/LinkifyHelper.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/text/LinkifyHelper.kt index 916f365b282..b95dacc5a8d 100644 --- a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/text/LinkifyHelper.kt +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/text/LinkifyHelper.kt @@ -30,6 +30,7 @@ object LinkifyHelper { @LinkifyCompat.LinkifyMask linkifyMask: Int = Linkify.WEB_URLS or Linkify.PHONE_NUMBERS or Linkify.EMAIL_ADDRESSES, ): CharSequence { // Convert the text to a Spannable to be able to add URL spans, return the original text if it's not possible (in tests, i.e.) + @Suppress("USELESS_ELVIS") val spannable = text.toSpannable() ?: return text // Get all URL spans, as they will be removed by LinkifyCompat.addLinks diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/text/TextUtils.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/text/TextUtils.kt new file mode 100644 index 00000000000..dde63e79a6b --- /dev/null +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/text/TextUtils.kt @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2025 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.text + +import java.net.URLDecoder +import java.net.URLEncoder +import java.nio.charset.Charset + +fun String.urlEncoded(charset: Charset = Charsets.UTF_8): String = URLEncoder.encode(this, charset.name()) +fun String.urlDecoded(charset: Charset = Charsets.UTF_8): String = URLDecoder.decode(this, charset.name()) diff --git a/libraries/androidutils/src/main/res/values-hr/translations.xml b/libraries/androidutils/src/main/res/values-hr/translations.xml new file mode 100644 index 00000000000..1e19b4cb895 --- /dev/null +++ b/libraries/androidutils/src/main/res/values-hr/translations.xml @@ -0,0 +1,4 @@ + + + "Nije pronađena kompatibilna aplikacija za izvršavanje ove radnje." + diff --git a/libraries/androidutils/src/test/kotlin/io/element/android/libraries/androidutils/filesize/AndroidFileSizeFormatterTest.kt b/libraries/androidutils/src/test/kotlin/io/element/android/libraries/androidutils/filesize/AndroidFileSizeFormatterTest.kt index 37260006b83..a83aa7d69ff 100644 --- a/libraries/androidutils/src/test/kotlin/io/element/android/libraries/androidutils/filesize/AndroidFileSizeFormatterTest.kt +++ b/libraries/androidutils/src/test/kotlin/io/element/android/libraries/androidutils/filesize/AndroidFileSizeFormatterTest.kt @@ -15,45 +15,59 @@ import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.RuntimeEnvironment +import org.robolectric.annotation.Config @RunWith(RobolectricTestRunner::class) class AndroidFileSizeFormatterTest { + @Config(sdk = [Build.VERSION_CODES.N]) @Test fun `test api 24 long format`() { val sut = createAndroidFileSizeFormatter(sdkLevel = Build.VERSION_CODES.N) - assertThat(sut.format(1, useShortFormat = false)).isEqualTo("1.00B") - assertThat(sut.format(1000, useShortFormat = false)).isEqualTo("0.98KB") - assertThat(sut.format(1024, useShortFormat = false)).isEqualTo("1.00KB") - assertThat(sut.format(1024 * 1024, useShortFormat = false)).isEqualTo("1.00MB") - assertThat(sut.format(1024 * 1024 * 1024, useShortFormat = false)).isEqualTo("1.00GB") + assertThat(sut.format(1, useShortFormat = false)).isEqualTo("1 B") + assertThat(sut.format(1000, useShortFormat = false)).isEqualTo("0.98 KB") + assertThat(sut.format(1024, useShortFormat = false)).isEqualTo("1.00 KB") + assertThat(sut.format(1024 * 500, useShortFormat = false)).isEqualTo("500 KB") + assertThat(sut.format(1024 * 1024, useShortFormat = false)).isEqualTo("1.00 MB") + assertThat(sut.format(1024 * 1024 * 500, useShortFormat = false)).isEqualTo("500 MB") + assertThat(sut.format(1024 * 1024 * 1024, useShortFormat = false)).isEqualTo("1.00 GB") } + @Config(sdk = [Build.VERSION_CODES.O]) @Test fun `test api 26 long format`() { val sut = createAndroidFileSizeFormatter(sdkLevel = Build.VERSION_CODES.O) - assertThat(sut.format(1, useShortFormat = false)).isEqualTo("1.00B") - assertThat(sut.format(1000, useShortFormat = false)).isEqualTo("0.98KB") - assertThat(sut.format(1024 * 1024, useShortFormat = false)).isEqualTo("0.95MB") - assertThat(sut.format(1024 * 1024 * 1024, useShortFormat = false)).isEqualTo("0.93GB") + assertThat(sut.format(1, useShortFormat = false)).isEqualTo("1 B") + assertThat(sut.format(1000, useShortFormat = false)).isEqualTo("0.98 KB") + assertThat(sut.format(1024, useShortFormat = false)).isEqualTo("1.00 KB") + assertThat(sut.format(1024 * 500, useShortFormat = false)).isEqualTo("500 KB") + assertThat(sut.format(1024 * 1024, useShortFormat = false)).isEqualTo("1.00 MB") + assertThat(sut.format(1024 * 1024 * 500, useShortFormat = false)).isEqualTo("500 MB") + assertThat(sut.format(1024 * 1024 * 1024, useShortFormat = false)).isEqualTo("1.00 GB") } + @Config(sdk = [Build.VERSION_CODES.N]) @Test fun `test api 24 short format`() { val sut = createAndroidFileSizeFormatter(sdkLevel = Build.VERSION_CODES.N) - assertThat(sut.format(1, useShortFormat = true)).isEqualTo("1.0B") - assertThat(sut.format(1000, useShortFormat = true)).isEqualTo("0.98KB") - assertThat(sut.format(1024, useShortFormat = true)).isEqualTo("1.0KB") - assertThat(sut.format(1024 * 1024, useShortFormat = true)).isEqualTo("1.0MB") - assertThat(sut.format(1024 * 1024 * 1024, useShortFormat = true)).isEqualTo("1.0GB") + assertThat(sut.format(1, useShortFormat = true)).isEqualTo("1 B") + assertThat(sut.format(1000, useShortFormat = true)).isEqualTo("0.98 KB") + assertThat(sut.format(1024, useShortFormat = true)).isEqualTo("1.0 KB") + assertThat(sut.format(1024 * 500, useShortFormat = true)).isEqualTo("500 KB") + assertThat(sut.format(1024 * 1024, useShortFormat = true)).isEqualTo("1.0 MB") + assertThat(sut.format(1024 * 1024 * 500, useShortFormat = true)).isEqualTo("500 MB") + assertThat(sut.format(1024 * 1024 * 1024, useShortFormat = true)).isEqualTo("1.0 GB") } + @Config(sdk = [Build.VERSION_CODES.O]) @Test fun `test api 26 short format`() { val sut = createAndroidFileSizeFormatter(sdkLevel = Build.VERSION_CODES.O) - assertThat(sut.format(1, useShortFormat = true)).isEqualTo("1.0B") - assertThat(sut.format(1000, useShortFormat = true)).isEqualTo("0.98KB") - assertThat(sut.format(1024 * 1024, useShortFormat = true)).isEqualTo("0.95MB") - assertThat(sut.format(1024 * 1024 * 1024, useShortFormat = true)).isEqualTo("0.93GB") + assertThat(sut.format(1, useShortFormat = true)).isEqualTo("1 B") + assertThat(sut.format(1000, useShortFormat = true)).isEqualTo("0.98 KB") + assertThat(sut.format(1024, useShortFormat = true)).isEqualTo("1.0 KB") + assertThat(sut.format(1024 * 500, useShortFormat = true)).isEqualTo("500 KB") + assertThat(sut.format(1024 * 1024, useShortFormat = true)).isEqualTo("1.0 MB") + assertThat(sut.format(1024 * 1024 * 1024, useShortFormat = true)).isEqualTo("1.0 GB") } private fun createAndroidFileSizeFormatter(sdkLevel: Int) = AndroidFileSizeFormatter( diff --git a/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/NodeCallback.kt b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/NodeCallback.kt index 4a35f99db4e..e949dcaf637 100644 --- a/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/NodeCallback.kt +++ b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/NodeCallback.kt @@ -10,8 +10,11 @@ package io.element.android.libraries.architecture import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.plugin.Plugin -import com.bumble.appyx.core.plugin.plugins inline fun Node.callback(): I { - return requireNotNull(plugins().singleOrNull()) { "Make sure to actually pass a Callback plugin to your node" } + return plugins.callback() +} + +inline fun List.callback(): I { + return requireNotNull(filterIsInstance().singleOrNull()) { "Make sure to actually pass a Callback plugin to your node" } } diff --git a/libraries/compound/screenshots/Compound Icons - Dark.png b/libraries/compound/screenshots/Compound Icons - Dark.png index 82bddf6cde0..57095b784dd 100644 --- a/libraries/compound/screenshots/Compound Icons - Dark.png +++ b/libraries/compound/screenshots/Compound Icons - Dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a1e34c02db3531c546405cdda2004fd4a820992c9b567504380ae1956a8a1dae -size 230805 +oid sha256:f1988cdf7bf66c6c94f18d81cfb9bf44159d2e2b1e4ab8eb631020e08c59fb7f +size 247741 diff --git a/libraries/compound/screenshots/Compound Icons - Light.png b/libraries/compound/screenshots/Compound Icons - Light.png index 1ca15f00236..aff3555aa11 100644 --- a/libraries/compound/screenshots/Compound Icons - Light.png +++ b/libraries/compound/screenshots/Compound Icons - Light.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9d58e23d6ab0b97e22bb9b3f53d0828ddf71d47e1c9d813088523cd1636aa58a -size 227641 +oid sha256:bfc08e8d9c935272003333f639eff67311b401bc0330a76168bfe3f2f16c3769 +size 244302 diff --git a/libraries/compound/screenshots/Compound Icons - Rtl.png b/libraries/compound/screenshots/Compound Icons - Rtl.png index 4753d4c49e2..3b8d63d58ad 100644 --- a/libraries/compound/screenshots/Compound Icons - Rtl.png +++ b/libraries/compound/screenshots/Compound Icons - Rtl.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:50ff305f6e876e87e19829fe686136eb76f014a6775e19571382790e9c621595 -size 229070 +oid sha256:86f542d6421c4d4d47b3969714cd7f19ee2fe5dd10143f78e906206816dbc6f3 +size 245549 diff --git a/libraries/compound/screenshots/Compound Typography.png b/libraries/compound/screenshots/Compound Typography.png index 0d52b062170..fbfbd8a9a76 100644 --- a/libraries/compound/screenshots/Compound Typography.png +++ b/libraries/compound/screenshots/Compound Typography.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3a7b44ca91f056eb4f5c90d5f585a2b976ae58914c58ea956a707999a7ded333 -size 128573 +oid sha256:8456f26f3e3a9117e5b9c7f14b48435671768ed3191d75b9480967f3ab7c1d59 +size 140013 diff --git a/libraries/compound/screenshots/Compound Vector Icons - Dark.png b/libraries/compound/screenshots/Compound Vector Icons - Dark.png index 3ed35cfeac0..23227b953c0 100644 --- a/libraries/compound/screenshots/Compound Vector Icons - Dark.png +++ b/libraries/compound/screenshots/Compound Vector Icons - Dark.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:acd0850ea06a5338433e9d5df55201c0d825dd1c63260e949d15018f192377a8 -size 91527 +oid sha256:39f59b7df00585d80e1d70f24605944667a0170701f2c569ce77da78fc1a4f55 +size 99407 diff --git a/libraries/compound/screenshots/Compound Vector Icons - Light.png b/libraries/compound/screenshots/Compound Vector Icons - Light.png index 82f88c136b9..9dce20b3cb6 100644 --- a/libraries/compound/screenshots/Compound Vector Icons - Light.png +++ b/libraries/compound/screenshots/Compound Vector Icons - Light.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c33dc23d8b232bd473dc90573defd57114cddd29d0fa85f60ec2d7d41acc9989 -size 91113 +oid sha256:1e5003714506131fdf022976bbb33a10af861145449cdf73e4f5a52f53fa3c55 +size 98475 diff --git a/libraries/compound/src/main/kotlin/io/element/android/compound/previews/Typography.kt b/libraries/compound/src/main/kotlin/io/element/android/compound/previews/Typography.kt index 761bf12cdf6..2f2ef942a7e 100644 --- a/libraries/compound/src/main/kotlin/io/element/android/compound/previews/Typography.kt +++ b/libraries/compound/src/main/kotlin/io/element/android/compound/previews/Typography.kt @@ -17,13 +17,14 @@ import androidx.compose.ui.text.TextStyle import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.compoundTypography @Preview @Composable internal fun TypographyPreview() = ElementTheme { Surface { Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - with(ElementTheme.materialTypography) { + with(compoundTypography) { TypographyTokenPreview(displayLarge, "Display large") TypographyTokenPreview(displayMedium, "Display medium") TypographyTokenPreview(displaySmall, "Display small") @@ -44,6 +45,33 @@ internal fun TypographyPreview() = ElementTheme { } } +@Preview +@Composable +internal fun CompoundTypographyPreview() = ElementTheme { + Surface { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + with(ElementTheme.typography) { + TypographyTokenPreview(fontHeadingXlBold, "fontHeadingXlBold") + TypographyTokenPreview(fontHeadingXlRegular, "fontHeadingXlRegular") + TypographyTokenPreview(fontHeadingLgBold, "fontHeadingLgBold") + TypographyTokenPreview(fontHeadingLgRegular, "fontHeadingLgRegular") + TypographyTokenPreview(fontHeadingMdBold, "fontHeadingMdBold") + TypographyTokenPreview(fontHeadingMdRegular, "fontHeadingMdRegular") + TypographyTokenPreview(fontHeadingSmMedium, "fontHeadingSmMedium") + TypographyTokenPreview(fontHeadingSmRegular, "fontHeadingSmRegular") + TypographyTokenPreview(fontBodyLgMedium, "fontBodyLgMedium") + TypographyTokenPreview(fontBodyLgRegular, "fontBodyLgRegular") + TypographyTokenPreview(fontBodyMdMedium, "fontBodyMdMedium") + TypographyTokenPreview(fontBodyMdRegular, "fontBodyMdRegular") + TypographyTokenPreview(fontBodySmMedium, "fontBodySmMedium") + TypographyTokenPreview(fontBodySmRegular, "fontBodySmRegular") + TypographyTokenPreview(fontBodyXsMedium, "fontBodyXsMedium") + TypographyTokenPreview(fontBodyXsRegular, "fontBodyXsRegular") + } + } + } +} + @Composable private fun TypographyTokenPreview(style: TextStyle, text: String) { Text(text = text, style = style) diff --git a/libraries/compound/src/main/kotlin/io/element/android/compound/theme/ElementTheme.kt b/libraries/compound/src/main/kotlin/io/element/android/compound/theme/ElementTheme.kt index 18657b44106..e2ca03aaacb 100644 --- a/libraries/compound/src/main/kotlin/io/element/android/compound/theme/ElementTheme.kt +++ b/libraries/compound/src/main/kotlin/io/element/android/compound/theme/ElementTheme.kt @@ -62,14 +62,6 @@ object ElementTheme { */ val typography: TypographyTokens = TypographyTokens - /** - * Material 3 [Typography] tokens. In Figma, these have the `M3 Typography/` prefix. - */ - val materialTypography: Typography - @Composable - @ReadOnlyComposable - get() = MaterialTheme.typography - /** * Returns whether the theme version used is the light or the dark one. */ diff --git a/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/CompoundIcons.kt b/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/CompoundIcons.kt index 2f634b529e7..8c540de854f 100644 --- a/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/CompoundIcons.kt +++ b/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/CompoundIcons.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025 Element Creations Ltd. + * 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. @@ -25,6 +25,9 @@ object CompoundIcons { @Composable fun Admin(): ImageVector { return ImageVector.vectorResource(R.drawable.ic_compound_admin) } + @Composable fun AdvancedSettings(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_advanced_settings) + } @Composable fun ArrowDown(): ImageVector { return ImageVector.vectorResource(R.drawable.ic_compound_arrow_down) } @@ -52,12 +55,21 @@ object CompoundIcons { @Composable fun Audio(): ImageVector { return ImageVector.vectorResource(R.drawable.ic_compound_audio) } + @Composable fun Backspace(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_backspace) + } + @Composable fun BackspaceSolid(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_backspace_solid) + } @Composable fun Block(): ImageVector { return ImageVector.vectorResource(R.drawable.ic_compound_block) } @Composable fun Bold(): ImageVector { return ImageVector.vectorResource(R.drawable.ic_compound_bold) } + @Composable fun Bug(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_bug) + } @Composable fun Calendar(): ImageVector { return ImageVector.vectorResource(R.drawable.ic_compound_calendar) } @@ -301,6 +313,9 @@ object CompoundIcons { @Composable fun LeftPanelClose(): ImageVector { return ImageVector.vectorResource(R.drawable.ic_compound_left_panel_close) } + @Composable fun LeftPanelOpen(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_left_panel_open) + } @Composable fun Link(): ImageVector { return ImageVector.vectorResource(R.drawable.ic_compound_link) } @@ -469,6 +484,12 @@ object CompoundIcons { @Composable fun Room(): ImageVector { return ImageVector.vectorResource(R.drawable.ic_compound_room) } + @Composable fun RotateLeft(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_rotate_left) + } + @Composable fun RotateRight(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_rotate_right) + } @Composable fun Search(): ImageVector { return ImageVector.vectorResource(R.drawable.ic_compound_search) } @@ -523,6 +544,9 @@ object CompoundIcons { @Composable fun SpotlightView(): ImageVector { return ImageVector.vectorResource(R.drawable.ic_compound_spotlight_view) } + @Composable fun Sticker(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_sticker) + } @Composable fun Strikethrough(): ImageVector { return ImageVector.vectorResource(R.drawable.ic_compound_strikethrough) } @@ -547,6 +571,12 @@ object CompoundIcons { @Composable fun Time(): ImageVector { return ImageVector.vectorResource(R.drawable.ic_compound_time) } + @Composable fun Translate(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_translate) + } + @Composable fun Tree(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_tree) + } @Composable fun Underline(): ImageVector { return ImageVector.vectorResource(R.drawable.ic_compound_underline) } @@ -595,6 +625,9 @@ object CompoundIcons { @Composable fun VideoCallOffSolid(): ImageVector { return ImageVector.vectorResource(R.drawable.ic_compound_video_call_off_solid) } + @Composable fun VideoCallOutgoingSolid(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_video_call_outgoing_solid) + } @Composable fun VideoCallSolid(): ImageVector { return ImageVector.vectorResource(R.drawable.ic_compound_video_call_solid) } @@ -607,6 +640,15 @@ object CompoundIcons { @Composable fun VoiceCall(): ImageVector { return ImageVector.vectorResource(R.drawable.ic_compound_voice_call) } + @Composable fun VoiceCallDeclinedSolid(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_voice_call_declined_solid) + } + @Composable fun VoiceCallMissedSolid(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_voice_call_missed_solid) + } + @Composable fun VoiceCallOutgoingSolid(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_voice_call_outgoing_solid) + } @Composable fun VoiceCallSolid(): ImageVector { return ImageVector.vectorResource(R.drawable.ic_compound_voice_call_solid) } @@ -631,9 +673,16 @@ object CompoundIcons { @Composable fun Windows(): ImageVector { return ImageVector.vectorResource(R.drawable.ic_compound_windows) } + @Composable fun ZoomIn(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_zoom_in) + } + @Composable fun ZoomOut(): ImageVector { + return ImageVector.vectorResource(R.drawable.ic_compound_zoom_out) + } val all @Composable get() = persistentListOf( Admin(), + AdvancedSettings(), ArrowDown(), ArrowLeft(), ArrowRight(), @@ -643,8 +692,11 @@ object CompoundIcons { AskToJoinSolid(), Attachment(), Audio(), + Backspace(), + BackspaceSolid(), Block(), Bold(), + Bug(), Calendar(), Chart(), Chat(), @@ -726,6 +778,7 @@ object CompoundIcons { Labs(), Leave(), LeftPanelClose(), + LeftPanelOpen(), Link(), Linux(), ListBulleted(), @@ -782,6 +835,8 @@ object CompoundIcons { Reply(), Restart(), Room(), + RotateLeft(), + RotateRight(), Search(), Send(), SendSolid(), @@ -800,6 +855,7 @@ object CompoundIcons { Spinner(), Spotlight(), SpotlightView(), + Sticker(), Strikethrough(), SwitchCameraSolid(), TakePhoto(), @@ -808,6 +864,8 @@ object CompoundIcons { Threads(), ThreadsSolid(), Time(), + Translate(), + Tree(), Underline(), Unknown(), UnknownSolid(), @@ -824,10 +882,14 @@ object CompoundIcons { VideoCallMissedSolid(), VideoCallOff(), VideoCallOffSolid(), + VideoCallOutgoingSolid(), VideoCallSolid(), VisibilityOff(), VisibilityOn(), VoiceCall(), + VoiceCallDeclinedSolid(), + VoiceCallMissedSolid(), + VoiceCallOutgoingSolid(), VoiceCallSolid(), VolumeOff(), VolumeOffSolid(), @@ -836,10 +898,13 @@ object CompoundIcons { Warning(), WebBrowser(), Windows(), + ZoomIn(), + ZoomOut(), ) val allResIds get() = persistentListOf( R.drawable.ic_compound_admin, + R.drawable.ic_compound_advanced_settings, R.drawable.ic_compound_arrow_down, R.drawable.ic_compound_arrow_left, R.drawable.ic_compound_arrow_right, @@ -849,8 +914,11 @@ object CompoundIcons { R.drawable.ic_compound_ask_to_join_solid, R.drawable.ic_compound_attachment, R.drawable.ic_compound_audio, + R.drawable.ic_compound_backspace, + R.drawable.ic_compound_backspace_solid, R.drawable.ic_compound_block, R.drawable.ic_compound_bold, + R.drawable.ic_compound_bug, R.drawable.ic_compound_calendar, R.drawable.ic_compound_chart, R.drawable.ic_compound_chat, @@ -932,6 +1000,7 @@ object CompoundIcons { R.drawable.ic_compound_labs, R.drawable.ic_compound_leave, R.drawable.ic_compound_left_panel_close, + R.drawable.ic_compound_left_panel_open, R.drawable.ic_compound_link, R.drawable.ic_compound_linux, R.drawable.ic_compound_list_bulleted, @@ -988,6 +1057,8 @@ object CompoundIcons { R.drawable.ic_compound_reply, R.drawable.ic_compound_restart, R.drawable.ic_compound_room, + R.drawable.ic_compound_rotate_left, + R.drawable.ic_compound_rotate_right, R.drawable.ic_compound_search, R.drawable.ic_compound_send, R.drawable.ic_compound_send_solid, @@ -1006,6 +1077,7 @@ object CompoundIcons { R.drawable.ic_compound_spinner, R.drawable.ic_compound_spotlight, R.drawable.ic_compound_spotlight_view, + R.drawable.ic_compound_sticker, R.drawable.ic_compound_strikethrough, R.drawable.ic_compound_switch_camera_solid, R.drawable.ic_compound_take_photo, @@ -1014,6 +1086,8 @@ object CompoundIcons { R.drawable.ic_compound_threads, R.drawable.ic_compound_threads_solid, R.drawable.ic_compound_time, + R.drawable.ic_compound_translate, + R.drawable.ic_compound_tree, R.drawable.ic_compound_underline, R.drawable.ic_compound_unknown, R.drawable.ic_compound_unknown_solid, @@ -1030,10 +1104,14 @@ object CompoundIcons { R.drawable.ic_compound_video_call_missed_solid, R.drawable.ic_compound_video_call_off, R.drawable.ic_compound_video_call_off_solid, + R.drawable.ic_compound_video_call_outgoing_solid, R.drawable.ic_compound_video_call_solid, R.drawable.ic_compound_visibility_off, R.drawable.ic_compound_visibility_on, R.drawable.ic_compound_voice_call, + R.drawable.ic_compound_voice_call_declined_solid, + R.drawable.ic_compound_voice_call_missed_solid, + R.drawable.ic_compound_voice_call_outgoing_solid, R.drawable.ic_compound_voice_call_solid, R.drawable.ic_compound_volume_off, R.drawable.ic_compound_volume_off_solid, @@ -1042,5 +1120,7 @@ object CompoundIcons { R.drawable.ic_compound_warning, R.drawable.ic_compound_web_browser, R.drawable.ic_compound_windows, + R.drawable.ic_compound_zoom_in, + R.drawable.ic_compound_zoom_out, ) } diff --git a/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/SemanticColors.kt b/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/SemanticColors.kt index b2e975e23c4..028cdd4e322 100644 --- a/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/SemanticColors.kt +++ b/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/SemanticColors.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025 Element Creations Ltd. + * 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. @@ -53,7 +53,7 @@ data class SemanticColors( val bgBadgeAccent: Color, /** Badge default background colour */ val bgBadgeDefault: Color, - /** Badge external background colour */ + /** Badge default background colour */ val bgBadgeExternal: Color, /** Badge info background colour */ val bgBadgeInfo: Color, @@ -151,6 +151,14 @@ data class SemanticColors( val iconAccentPrimary: Color, /** Lowest contrast accessible accent icons. */ val iconAccentTertiary: Color, + /** Badge accent text colour */ + val iconBadgeAccent: Color, + /** Badge info text colour */ + val iconBadgeDefault: Color, + /** Badge info text colour */ + val iconBadgeExternal: Color, + /** Badge info text colour */ + val iconBadgeInfo: Color, /** High-contrast icon for critical state. State: Rest. */ val iconCriticalPrimary: Color, /** Use for icons in disabled elements. There's no minimum contrast requirement. */ @@ -183,9 +191,9 @@ data class SemanticColors( val textActionPrimary: Color, /** Badge accent text colour */ val textBadgeAccent: Color, - /** Badge default text colour */ + /** Badge info text colour */ val textBadgeDefault: Color, - /** Badge external text colour */ + /** Badge info text colour */ val textBadgeExternal: Color, /** Badge info text colour */ val textBadgeInfo: Color, diff --git a/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/SemanticColorsDark.kt b/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/SemanticColorsDark.kt index 6cc099f7e44..b393fe238cc 100644 --- a/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/SemanticColorsDark.kt +++ b/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/SemanticColorsDark.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025 Element Creations Ltd. + * 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. @@ -88,6 +88,10 @@ val compoundColorsDark = SemanticColors( gradientSubtleStop6 = DarkColorTokens.colorTransparent, iconAccentPrimary = DarkColorTokens.colorBlue1400, iconAccentTertiary = DarkColorTokens.colorBlue600, + iconBadgeAccent = DarkColorTokens.colorGreen1200, + iconBadgeDefault = DarkColorTokens.colorGray1200, + iconBadgeExternal = DarkColorTokens.colorOrange1200, + iconBadgeInfo = DarkColorTokens.colorBlue1200, iconCriticalPrimary = DarkColorTokens.colorRed800, iconDisabled = DarkColorTokens.colorGray700, iconInfoPrimary = DarkColorTokens.colorBlue900, diff --git a/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/SemanticColorsDarkHc.kt b/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/SemanticColorsDarkHc.kt index 4983fec9a4f..a75e4f34249 100644 --- a/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/SemanticColorsDarkHc.kt +++ b/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/SemanticColorsDarkHc.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025 Element Creations Ltd. + * 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. @@ -38,8 +38,8 @@ val compoundColorsHcDark = SemanticColors( bgActionTertiaryRest = DarkHcColorTokens.colorThemeBg, bgActionTertiarySelected = DarkHcColorTokens.colorGray400, bgBadgeAccent = DarkHcColorTokens.colorAlphaGreen500, - bgBadgeExternal = DarkHcColorTokens.colorOrange400, bgBadgeDefault = DarkHcColorTokens.colorAlphaGray500, + bgBadgeExternal = DarkHcColorTokens.colorOrange500, bgBadgeInfo = DarkHcColorTokens.colorAlphaBlue500, bgCanvasDefault = DarkHcColorTokens.colorThemeBg, bgCanvasDefaultLevel1 = DarkHcColorTokens.colorGray300, @@ -88,6 +88,10 @@ val compoundColorsHcDark = SemanticColors( gradientSubtleStop6 = DarkHcColorTokens.colorTransparent, iconAccentPrimary = DarkHcColorTokens.colorBlue1400, iconAccentTertiary = DarkHcColorTokens.colorBlue600, + iconBadgeAccent = DarkHcColorTokens.colorGreen1100, + iconBadgeDefault = DarkHcColorTokens.colorGray1100, + iconBadgeExternal = DarkHcColorTokens.colorOrange1100, + iconBadgeInfo = DarkHcColorTokens.colorBlue1100, iconCriticalPrimary = DarkHcColorTokens.colorRed800, iconDisabled = DarkHcColorTokens.colorGray700, iconInfoPrimary = DarkHcColorTokens.colorBlue900, @@ -104,8 +108,8 @@ val compoundColorsHcDark = SemanticColors( textActionAccent = DarkHcColorTokens.colorBlue600, textActionPrimary = DarkHcColorTokens.colorGray1400, textBadgeAccent = DarkHcColorTokens.colorGreen1100, - textBadgeDefault = DarkHcColorTokens.colorGray1200, - textBadgeExternal = DarkHcColorTokens.colorOrange1200, + textBadgeDefault = DarkHcColorTokens.colorGray1100, + textBadgeExternal = DarkHcColorTokens.colorOrange1100, textBadgeInfo = DarkHcColorTokens.colorBlue1100, textCriticalPrimary = DarkHcColorTokens.colorRed800, textDecorative1 = DarkHcColorTokens.colorGreen1000, diff --git a/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/SemanticColorsLight.kt b/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/SemanticColorsLight.kt index 20a54d36ce8..65858e4be39 100644 --- a/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/SemanticColorsLight.kt +++ b/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/SemanticColorsLight.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025 Element Creations Ltd. + * 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. @@ -88,6 +88,10 @@ val compoundColorsLight = SemanticColors( gradientSubtleStop6 = LightColorTokens.colorTransparent, iconAccentPrimary = LightColorTokens.colorBlue1400, iconAccentTertiary = LightColorTokens.colorBlue900, + iconBadgeAccent = LightColorTokens.colorGreen1200, + iconBadgeDefault = LightColorTokens.colorGray1200, + iconBadgeExternal = LightColorTokens.colorOrange1200, + iconBadgeInfo = LightColorTokens.colorBlue1200, iconCriticalPrimary = LightColorTokens.colorRed900, iconDisabled = LightColorTokens.colorGray700, iconInfoPrimary = LightColorTokens.colorBlue900, diff --git a/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/SemanticColorsLightHc.kt b/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/SemanticColorsLightHc.kt index c6dd621350b..88dbfe55913 100644 --- a/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/SemanticColorsLightHc.kt +++ b/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/SemanticColorsLightHc.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025 Element Creations Ltd. + * 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. @@ -88,6 +88,10 @@ val compoundColorsHcLight = SemanticColors( gradientSubtleStop6 = LightHcColorTokens.colorTransparent, iconAccentPrimary = LightHcColorTokens.colorBlue1400, iconAccentTertiary = LightHcColorTokens.colorBlue900, + iconBadgeAccent = LightHcColorTokens.colorGreen1100, + iconBadgeDefault = LightHcColorTokens.colorGray1100, + iconBadgeExternal = LightHcColorTokens.colorOrange1100, + iconBadgeInfo = LightHcColorTokens.colorBlue1100, iconCriticalPrimary = LightHcColorTokens.colorRed900, iconDisabled = LightHcColorTokens.colorGray700, iconInfoPrimary = LightHcColorTokens.colorBlue900, @@ -104,8 +108,8 @@ val compoundColorsHcLight = SemanticColors( textActionAccent = LightHcColorTokens.colorBlue900, textActionPrimary = LightHcColorTokens.colorGray1400, textBadgeAccent = LightHcColorTokens.colorGreen1100, - textBadgeDefault = LightHcColorTokens.colorGray1200, - textBadgeExternal = LightHcColorTokens.colorOrange1200, + textBadgeDefault = LightHcColorTokens.colorGray1100, + textBadgeExternal = LightHcColorTokens.colorOrange1100, textBadgeInfo = LightHcColorTokens.colorBlue1100, textCriticalPrimary = LightHcColorTokens.colorRed900, textDecorative1 = LightHcColorTokens.colorGreen1000, diff --git a/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/TypographyTokens.kt b/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/TypographyTokens.kt index 8f3a7d5e950..2e55181510b 100644 --- a/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/TypographyTokens.kt +++ b/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/TypographyTokens.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025 Element Creations Ltd. + * 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. diff --git a/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/internal/DarkColorTokens.kt b/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/internal/DarkColorTokens.kt index ccc6bd5436c..32390c4a6d7 100644 --- a/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/internal/DarkColorTokens.kt +++ b/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/internal/DarkColorTokens.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025 Element Creations Ltd. + * 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. diff --git a/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/internal/DarkHcColorTokens.kt b/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/internal/DarkHcColorTokens.kt index 8b2c04a93a4..06e5e3450ee 100644 --- a/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/internal/DarkHcColorTokens.kt +++ b/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/internal/DarkHcColorTokens.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025 Element Creations Ltd. + * 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. diff --git a/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/internal/LightColorTokens.kt b/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/internal/LightColorTokens.kt index 75c11c0264e..e2a6271bdc2 100644 --- a/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/internal/LightColorTokens.kt +++ b/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/internal/LightColorTokens.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025 Element Creations Ltd. + * 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. diff --git a/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/internal/LightHcColorTokens.kt b/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/internal/LightHcColorTokens.kt index 3468e661f6a..a2508af2e4f 100644 --- a/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/internal/LightHcColorTokens.kt +++ b/libraries/compound/src/main/kotlin/io/element/android/compound/tokens/generated/internal/LightHcColorTokens.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2025 Element Creations Ltd. + * 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. diff --git a/libraries/compound/src/main/res/drawable/ic_compound_advanced_settings.xml b/libraries/compound/src/main/res/drawable/ic_compound_advanced_settings.xml new file mode 100644 index 00000000000..a1c61bf419b --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_advanced_settings.xml @@ -0,0 +1,14 @@ + + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_backspace.xml b/libraries/compound/src/main/res/drawable/ic_compound_backspace.xml new file mode 100644 index 00000000000..044da0d4b3f --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_backspace.xml @@ -0,0 +1,14 @@ + + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_backspace_solid.xml b/libraries/compound/src/main/res/drawable/ic_compound_backspace_solid.xml new file mode 100644 index 00000000000..b7f454e3303 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_backspace_solid.xml @@ -0,0 +1,11 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_bug.xml b/libraries/compound/src/main/res/drawable/ic_compound_bug.xml new file mode 100644 index 00000000000..7ee32ec13c2 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_bug.xml @@ -0,0 +1,9 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_left_panel_open.xml b/libraries/compound/src/main/res/drawable/ic_compound_left_panel_open.xml new file mode 100644 index 00000000000..78c21c0625c --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_left_panel_open.xml @@ -0,0 +1,9 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_mac.xml b/libraries/compound/src/main/res/drawable/ic_compound_mac.xml index 47a4d690ef5..f802f5fa4ec 100644 --- a/libraries/compound/src/main/res/drawable/ic_compound_mac.xml +++ b/libraries/compound/src/main/res/drawable/ic_compound_mac.xml @@ -4,7 +4,7 @@ android:viewportWidth="24" android:viewportHeight="24"> diff --git a/libraries/compound/src/main/res/drawable/ic_compound_rotate_left.xml b/libraries/compound/src/main/res/drawable/ic_compound_rotate_left.xml new file mode 100644 index 00000000000..5d372e143ce --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_rotate_left.xml @@ -0,0 +1,9 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_rotate_right.xml b/libraries/compound/src/main/res/drawable/ic_compound_rotate_right.xml new file mode 100644 index 00000000000..0026ea4ab82 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_rotate_right.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_sticker.xml b/libraries/compound/src/main/res/drawable/ic_compound_sticker.xml new file mode 100644 index 00000000000..c68fd65d7d9 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_sticker.xml @@ -0,0 +1,10 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_translate.xml b/libraries/compound/src/main/res/drawable/ic_compound_translate.xml new file mode 100644 index 00000000000..13c7a37d282 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_translate.xml @@ -0,0 +1,10 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_tree.xml b/libraries/compound/src/main/res/drawable/ic_compound_tree.xml new file mode 100644 index 00000000000..bc7283dca96 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_tree.xml @@ -0,0 +1,9 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_video_call_outgoing_solid.xml b/libraries/compound/src/main/res/drawable/ic_compound_video_call_outgoing_solid.xml new file mode 100644 index 00000000000..98464c94504 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_video_call_outgoing_solid.xml @@ -0,0 +1,10 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_voice_call_declined_solid.xml b/libraries/compound/src/main/res/drawable/ic_compound_voice_call_declined_solid.xml new file mode 100644 index 00000000000..46504f3f14d --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_voice_call_declined_solid.xml @@ -0,0 +1,9 @@ + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_voice_call_missed_solid.xml b/libraries/compound/src/main/res/drawable/ic_compound_voice_call_missed_solid.xml new file mode 100644 index 00000000000..1cddb633dba --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_voice_call_missed_solid.xml @@ -0,0 +1,12 @@ + + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_voice_call_outgoing_solid.xml b/libraries/compound/src/main/res/drawable/ic_compound_voice_call_outgoing_solid.xml new file mode 100644 index 00000000000..c197241d236 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_voice_call_outgoing_solid.xml @@ -0,0 +1,12 @@ + + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_zoom_in.xml b/libraries/compound/src/main/res/drawable/ic_compound_zoom_in.xml new file mode 100644 index 00000000000..62f7b9cd247 --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_zoom_in.xml @@ -0,0 +1,23 @@ + + + + + + + + + diff --git a/libraries/compound/src/main/res/drawable/ic_compound_zoom_out.xml b/libraries/compound/src/main/res/drawable/ic_compound_zoom_out.xml new file mode 100644 index 00000000000..db334baf5eb --- /dev/null +++ b/libraries/compound/src/main/res/drawable/ic_compound_zoom_out.xml @@ -0,0 +1,13 @@ + + + + diff --git a/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/CompoundTypographyTest.kt b/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/CompoundTypographyTest.kt index 902bab68721..c4822f680ee 100644 --- a/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/CompoundTypographyTest.kt +++ b/libraries/compound/src/test/kotlin/io/element/android/compound/screenshot/CompoundTypographyTest.kt @@ -8,18 +8,10 @@ package io.element.android.compound.screenshot -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.material3.Surface -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.text.TextStyle -import androidx.compose.ui.unit.dp import androidx.test.ext.junit.runners.AndroidJUnit4 import com.github.takahirom.roborazzi.captureRoboImage +import io.element.android.compound.previews.CompoundTypographyPreview import io.element.android.compound.screenshot.utils.screenshotFile -import io.element.android.compound.theme.ElementTheme -import io.element.android.compound.tokens.generated.TypographyTokens import org.junit.Test import org.junit.runner.RunWith import org.robolectric.annotation.Config @@ -32,35 +24,7 @@ class CompoundTypographyTest { @Config(sdk = [35], qualifiers = "h2048dp-xxhdpi") fun screenshots() { captureRoboImage(file = screenshotFile("Compound Typography.png")) { - ElementTheme { - Surface { - Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { - with(TypographyTokens) { - TypographyTokenPreview(fontHeadingXlBold, "Heading XL Bold") - TypographyTokenPreview(fontHeadingXlRegular, "Heading XL Regular") - TypographyTokenPreview(fontHeadingLgBold, "Heading LG Bold") - TypographyTokenPreview(fontHeadingLgRegular, "Heading LG Regular") - TypographyTokenPreview(fontHeadingMdBold, "Heading MD Bold") - TypographyTokenPreview(fontHeadingMdRegular, "Heading MD Regular") - TypographyTokenPreview(fontHeadingSmMedium, "Heading SM Medium") - TypographyTokenPreview(fontHeadingSmRegular, "Heading SM Regular") - TypographyTokenPreview(fontBodyLgMedium, "Body LG Medium") - TypographyTokenPreview(fontBodyLgRegular, "Body LG Regular") - TypographyTokenPreview(fontBodyMdMedium, "Body MD Medium") - TypographyTokenPreview(fontBodyMdRegular, "Body MD Regular") - TypographyTokenPreview(fontBodySmMedium, "Body SM Medium") - TypographyTokenPreview(fontBodySmRegular, "Body SM Regular") - TypographyTokenPreview(fontBodyXsMedium, "Body XS Medium") - TypographyTokenPreview(fontBodyXsRegular, "Body XS Regular") - } - } - } - } + CompoundTypographyPreview() } } - - @Composable - private fun TypographyTokenPreview(style: TextStyle, text: String) { - Text(text = text, style = style) - } } diff --git a/libraries/core/build.gradle.kts b/libraries/core/build.gradle.kts index e25ae62c956..d04efaa40ea 100644 --- a/libraries/core/build.gradle.kts +++ b/libraries/core/build.gradle.kts @@ -24,6 +24,7 @@ kotlin { dependencies { implementation(libs.coroutines.core) + implementation(libs.coroutines.test) testImplementation(libs.test.junit) testImplementation(libs.test.truth) } diff --git a/libraries/core/src/main/kotlin/io/element/android/libraries/core/coroutine/ChildScopeOf.kt b/libraries/core/src/main/kotlin/io/element/android/libraries/core/coroutine/ChildScopeOf.kt index 1fba021530e..46831a26980 100644 --- a/libraries/core/src/main/kotlin/io/element/android/libraries/core/coroutine/ChildScopeOf.kt +++ b/libraries/core/src/main/kotlin/io/element/android/libraries/core/coroutine/ChildScopeOf.kt @@ -14,6 +14,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.job import kotlinx.coroutines.plus +import kotlinx.coroutines.test.TestScope /** * Create a child scope of the current scope. @@ -28,6 +29,11 @@ fun CoroutineScope.childScope( dispatcher: CoroutineDispatcher, name: String, ): CoroutineScope = run { - val supervisorJob = SupervisorJob(parent = coroutineContext.job) - this + dispatcher + supervisorJob + CoroutineName(name) + if (this is TestScope) { + // Special case for tests: we can't start a coroutine with a different SupervisorJob + this + } else { + val supervisorJob = SupervisorJob(parent = coroutineContext.job) + this + dispatcher + supervisorJob + CoroutineName(name) + } } diff --git a/libraries/core/src/main/kotlin/io/element/android/libraries/core/data/ByteSize.kt b/libraries/core/src/main/kotlin/io/element/android/libraries/core/data/ByteSize.kt index fe72866d7ec..f4daf85011d 100644 --- a/libraries/core/src/main/kotlin/io/element/android/libraries/core/data/ByteSize.kt +++ b/libraries/core/src/main/kotlin/io/element/android/libraries/core/data/ByteSize.kt @@ -15,13 +15,36 @@ enum class ByteUnit(val bitShift: Int) { } class ByteSize internal constructor(val value: Long, val unit: ByteUnit) { - fun to(dest: ByteUnit): Long { + fun into(dest: ByteUnit): Long { if (unit == dest) return value return value shl unit.bitShift shr dest.bitShift } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is ByteSize) return false + + return value == other.value && unit == other.unit + } + + override fun hashCode(): Int { + var result = value.hashCode() + result = 31 * result + unit.hashCode() + return result + } + + override fun toString(): String { + return "$value $unit" + } } val Number.gigaBytes get() = ByteSize(toLong(), ByteUnit.GB) val Number.megaBytes get() = ByteSize(toLong(), ByteUnit.MB) val Number.kiloBytes get() = ByteSize(toLong(), ByteUnit.KB) val Number.bytes get() = ByteSize(toLong(), ByteUnit.BYTES) + +// For the SDK values +val ULong.gigaBytes get() = ByteSize(toLong(), ByteUnit.GB) +val ULong.megaBytes get() = ByteSize(toLong(), ByteUnit.MB) +val ULong.kiloBytes get() = ByteSize(toLong(), ByteUnit.KB) +val ULong.bytes get() = ByteSize(toLong(), ByteUnit.BYTES) diff --git a/libraries/core/src/main/kotlin/io/element/android/libraries/core/extensions/BasicExtensions.kt b/libraries/core/src/main/kotlin/io/element/android/libraries/core/extensions/BasicExtensions.kt index d3a2805df1a..d13777842db 100644 --- a/libraries/core/src/main/kotlin/io/element/android/libraries/core/extensions/BasicExtensions.kt +++ b/libraries/core/src/main/kotlin/io/element/android/libraries/core/extensions/BasicExtensions.kt @@ -100,6 +100,8 @@ fun String.containsRtLOverride() = contains(RTL_OVERRIDE_CHAR) fun String.filterDirectionOverrides() = filterNot { it == RTL_OVERRIDE_CHAR || it == LTR_OVERRIDE_CHAR } +const val DEFAULT_SAFE_LENGTH = 500 + /** * This works around https://github.com/element-hq/element-x-android/issues/2105. * @param maxLength Max characters to retrieve. Defaults to `500`. @@ -107,7 +109,7 @@ fun String.filterDirectionOverrides() = filterNot { it == RTL_OVERRIDE_CHAR || i * @return The string truncated to [maxLength] characters, with an optional ellipsis if larger. */ fun String.toSafeLength( - maxLength: Int = 500, + maxLength: Int = DEFAULT_SAFE_LENGTH, ellipsize: Boolean = false, ): String { return if (ellipsize) { diff --git a/libraries/core/src/main/kotlin/io/element/android/libraries/core/hash/Hash.kt b/libraries/core/src/main/kotlin/io/element/android/libraries/core/hash/Hash.kt index 944889bdcbc..4a9af95bd1a 100644 --- a/libraries/core/src/main/kotlin/io/element/android/libraries/core/hash/Hash.kt +++ b/libraries/core/src/main/kotlin/io/element/android/libraries/core/hash/Hash.kt @@ -21,7 +21,7 @@ fun String.md5() = try { digest.digest() .joinToString("") { String.format(locale, "%02X", it) } .lowercase(locale) -} catch (exc: Exception) { +} catch (_: Exception) { // Should not happen, but just in case hashCode().toString() } diff --git a/libraries/core/src/main/kotlin/io/element/android/libraries/core/uri/UrlUtils.kt b/libraries/core/src/main/kotlin/io/element/android/libraries/core/uri/UrlUtils.kt index a086a6fba50..666ee1d7ab8 100644 --- a/libraries/core/src/main/kotlin/io/element/android/libraries/core/uri/UrlUtils.kt +++ b/libraries/core/src/main/kotlin/io/element/android/libraries/core/uri/UrlUtils.kt @@ -14,7 +14,7 @@ fun String.isValidUrl(): Boolean { return try { URI(this).toURL() true - } catch (t: Throwable) { + } catch (_: Throwable) { false } } diff --git a/libraries/core/src/test/kotlin/io/element/android/libraries/core/data/ByteSizeTest.kt b/libraries/core/src/test/kotlin/io/element/android/libraries/core/data/ByteSizeTest.kt index 0d1ec87b63f..ad0191da0c6 100644 --- a/libraries/core/src/test/kotlin/io/element/android/libraries/core/data/ByteSizeTest.kt +++ b/libraries/core/src/test/kotlin/io/element/android/libraries/core/data/ByteSizeTest.kt @@ -15,35 +15,35 @@ class ByteSizeTest { fun testSizeConversions() { // Check bytes to other units val bytes = 10_000_000.bytes - assertThat(bytes.to(ByteUnit.BYTES)).isEqualTo(bytes.value) - assertThat(bytes.to(ByteUnit.KB)).isEqualTo(bytes.value / 1024L) - assertThat(bytes.to(ByteUnit.MB)).isEqualTo(bytes.value / 1024L / 1024L) - assertThat(bytes.to(ByteUnit.GB)).isEqualTo(bytes.value / 1024L / 1024L / 1024L) + assertThat(bytes.into(ByteUnit.BYTES)).isEqualTo(bytes.value) + assertThat(bytes.into(ByteUnit.KB)).isEqualTo(bytes.value / 1024L) + assertThat(bytes.into(ByteUnit.MB)).isEqualTo(bytes.value / 1024L / 1024L) + assertThat(bytes.into(ByteUnit.GB)).isEqualTo(bytes.value / 1024L / 1024L / 1024L) // Now check for values too small to be converted - assertThat(100.bytes.to(ByteUnit.KB)).isEqualTo(0) - assertThat(100.bytes.to(ByteUnit.MB)).isEqualTo(0) - assertThat(100.bytes.to(ByteUnit.GB)).isEqualTo(0) + assertThat(100.bytes.into(ByteUnit.KB)).isEqualTo(0) + assertThat(100.bytes.into(ByteUnit.MB)).isEqualTo(0) + assertThat(100.bytes.into(ByteUnit.GB)).isEqualTo(0) // Check for KBs val kiloBytes = 10_000.kiloBytes - assertThat(kiloBytes.to(ByteUnit.BYTES)).isEqualTo(kiloBytes.value * 1024L) - assertThat(kiloBytes.to(ByteUnit.KB)).isEqualTo(kiloBytes.value) - assertThat(kiloBytes.to(ByteUnit.MB)).isEqualTo(kiloBytes.value / 1024L) - assertThat(kiloBytes.to(ByteUnit.GB)).isEqualTo(kiloBytes.value / 1024L / 1024L) + assertThat(kiloBytes.into(ByteUnit.BYTES)).isEqualTo(kiloBytes.value * 1024L) + assertThat(kiloBytes.into(ByteUnit.KB)).isEqualTo(kiloBytes.value) + assertThat(kiloBytes.into(ByteUnit.MB)).isEqualTo(kiloBytes.value / 1024L) + assertThat(kiloBytes.into(ByteUnit.GB)).isEqualTo(kiloBytes.value / 1024L / 1024L) // Check for MBs val megaBytes = 10_000.megaBytes - assertThat(megaBytes.to(ByteUnit.BYTES)).isEqualTo(megaBytes.value * 1024L * 1024L) - assertThat(megaBytes.to(ByteUnit.KB)).isEqualTo(megaBytes.value * 1024L) - assertThat(megaBytes.to(ByteUnit.MB)).isEqualTo(megaBytes.value) - assertThat(megaBytes.to(ByteUnit.GB)).isEqualTo(megaBytes.value / 1024L) + assertThat(megaBytes.into(ByteUnit.BYTES)).isEqualTo(megaBytes.value * 1024L * 1024L) + assertThat(megaBytes.into(ByteUnit.KB)).isEqualTo(megaBytes.value * 1024L) + assertThat(megaBytes.into(ByteUnit.MB)).isEqualTo(megaBytes.value) + assertThat(megaBytes.into(ByteUnit.GB)).isEqualTo(megaBytes.value / 1024L) // Check for GBs val gigaBytes = 10.gigaBytes - assertThat(gigaBytes.to(ByteUnit.BYTES)).isEqualTo(gigaBytes.value * 1024L * 1024L * 1024L) - assertThat(gigaBytes.to(ByteUnit.KB)).isEqualTo(gigaBytes.value * 1024L * 1024L) - assertThat(gigaBytes.to(ByteUnit.MB)).isEqualTo(gigaBytes.value * 1024L) - assertThat(gigaBytes.to(ByteUnit.GB)).isEqualTo(gigaBytes.value) + assertThat(gigaBytes.into(ByteUnit.BYTES)).isEqualTo(gigaBytes.value * 1024L * 1024L * 1024L) + assertThat(gigaBytes.into(ByteUnit.KB)).isEqualTo(gigaBytes.value * 1024L * 1024L) + assertThat(gigaBytes.into(ByteUnit.MB)).isEqualTo(gigaBytes.value * 1024L) + assertThat(gigaBytes.into(ByteUnit.GB)).isEqualTo(gigaBytes.value) } } diff --git a/libraries/dateformatter/impl/src/main/res/values-hr/translations.xml b/libraries/dateformatter/impl/src/main/res/values-hr/translations.xml new file mode 100644 index 00000000000..f70800fcd01 --- /dev/null +++ b/libraries/dateformatter/impl/src/main/res/values-hr/translations.xml @@ -0,0 +1,5 @@ + + + "%1$s u %2$s" + "Ovaj mjesec" + diff --git a/libraries/deeplink/impl/src/main/kotlin/io/element/android/libraries/deeplink/impl/DefaultDeepLinkCreator.kt b/libraries/deeplink/impl/src/main/kotlin/io/element/android/libraries/deeplink/impl/DefaultDeepLinkCreator.kt index 97c6eeda3fe..95b7ccf116e 100644 --- a/libraries/deeplink/impl/src/main/kotlin/io/element/android/libraries/deeplink/impl/DefaultDeepLinkCreator.kt +++ b/libraries/deeplink/impl/src/main/kotlin/io/element/android/libraries/deeplink/impl/DefaultDeepLinkCreator.kt @@ -10,6 +10,7 @@ package io.element.android.libraries.deeplink.impl import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.androidutils.text.urlEncoded import io.element.android.libraries.deeplink.api.DeepLinkCreator import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId @@ -21,13 +22,13 @@ class DefaultDeepLinkCreator : DeepLinkCreator { override fun create(sessionId: SessionId, roomId: RoomId?, threadId: ThreadId?, eventId: EventId?): String { return buildString { append("$SCHEME://$HOST/") - append(sessionId.value) + append(sessionId.value.urlEncoded()) append("/") - append(roomId?.value.orEmpty()) + append(roomId?.value?.urlEncoded().orEmpty()) append("/") - append(threadId?.value.orEmpty()) + append(threadId?.value?.urlEncoded().orEmpty()) append("/") - append(eventId?.value.orEmpty()) + append(eventId?.value?.urlEncoded().orEmpty()) } // Remove all possible trailing '/' characters: // No event id diff --git a/libraries/deeplink/impl/src/main/kotlin/io/element/android/libraries/deeplink/impl/DefaultDeeplinkParser.kt b/libraries/deeplink/impl/src/main/kotlin/io/element/android/libraries/deeplink/impl/DefaultDeeplinkParser.kt index ca1a39d5d0b..8c865b6557e 100644 --- a/libraries/deeplink/impl/src/main/kotlin/io/element/android/libraries/deeplink/impl/DefaultDeeplinkParser.kt +++ b/libraries/deeplink/impl/src/main/kotlin/io/element/android/libraries/deeplink/impl/DefaultDeeplinkParser.kt @@ -12,6 +12,7 @@ import android.content.Intent import android.net.Uri import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.androidutils.text.urlDecoded import io.element.android.libraries.deeplink.api.DeeplinkData import io.element.android.libraries.deeplink.api.DeeplinkParser import io.element.android.libraries.matrix.api.core.EventId @@ -31,7 +32,7 @@ class DefaultDeeplinkParser : DeeplinkParser { private fun Uri.toDeeplinkData(): DeeplinkData? { if (scheme != SCHEME) return null if (host != HOST) return null - val pathBits = path.orEmpty().split("/").drop(1) + val pathBits = encodedPath.orEmpty().split("/").drop(1).map { it.urlDecoded() } val sessionId = pathBits.elementAtOrNull(0)?.let(::SessionId) ?: return null return when (val screenPathComponent = pathBits.elementAtOrNull(1)) { diff --git a/libraries/deeplink/impl/src/test/kotlin/io/element/android/libraries/deeplink/impl/DefaultDeepLinkCreatorTest.kt b/libraries/deeplink/impl/src/test/kotlin/io/element/android/libraries/deeplink/impl/DefaultDeepLinkCreatorTest.kt index 4e3a10e8612..b27a6458029 100644 --- a/libraries/deeplink/impl/src/test/kotlin/io/element/android/libraries/deeplink/impl/DefaultDeepLinkCreatorTest.kt +++ b/libraries/deeplink/impl/src/test/kotlin/io/element/android/libraries/deeplink/impl/DefaultDeepLinkCreatorTest.kt @@ -9,6 +9,10 @@ package io.element.android.libraries.deeplink.impl import com.google.common.truth.Truth.assertThat +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.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_SESSION_ID @@ -19,15 +23,36 @@ class DefaultDeepLinkCreatorTest { @Test fun create() { val sut = DefaultDeepLinkCreator() - assertThat(sut.create(A_SESSION_ID, null, null, null)) - .isEqualTo("elementx://open/@alice:server.org") - assertThat(sut.create(A_SESSION_ID, A_ROOM_ID, null, null)) - .isEqualTo("elementx://open/@alice:server.org/!aRoomId:domain") - assertThat(sut.create(A_SESSION_ID, A_ROOM_ID, A_THREAD_ID, null)) - .isEqualTo("elementx://open/@alice:server.org/!aRoomId:domain/\$aThreadId") - assertThat(sut.create(A_SESSION_ID, A_ROOM_ID, A_THREAD_ID, AN_EVENT_ID)) - .isEqualTo("elementx://open/@alice:server.org/!aRoomId:domain/\$aThreadId/\$anEventId") - assertThat(sut.create(A_SESSION_ID, A_ROOM_ID, null, AN_EVENT_ID)) - .isEqualTo("elementx://open/@alice:server.org/!aRoomId:domain//\$anEventId") + val sessionId = A_SESSION_ID + val roomId = A_ROOM_ID + val threadId = A_THREAD_ID + val eventId = AN_EVENT_ID + assertThat(sut.create(sessionId, null, null, null)) + .isEqualTo("elementx://open/%40alice%3Aserver.org") + assertThat(sut.create(sessionId, roomId, null, null)) + .isEqualTo("elementx://open/%40alice%3Aserver.org/%21aRoomId%3Adomain") + assertThat(sut.create(sessionId, roomId, threadId, null)) + .isEqualTo("elementx://open/%40alice%3Aserver.org/%21aRoomId%3Adomain/%24aThreadId") + assertThat(sut.create(sessionId, roomId, threadId, eventId)) + .isEqualTo("elementx://open/%40alice%3Aserver.org/%21aRoomId%3Adomain/%24aThreadId/%24anEventId") + assertThat(sut.create(sessionId, roomId, null, eventId)) + .isEqualTo("elementx://open/%40alice%3Aserver.org/%21aRoomId%3Adomain//%24anEventId") + } + + @Test + fun `create - with escaped invalid characters`() { + val sut = DefaultDeepLinkCreator() + val sessionId = SessionId("@a/:domain") + val roomId = RoomId("!a/RoomId:domain") + val threadId = ThreadId("\$a/ThreadId") + val eventId = EventId("\$an/EventId") + assertThat(sut.create(sessionId, roomId, null, null)) + .isEqualTo("elementx://open/%40a%2F%3Adomain/%21a%2FRoomId%3Adomain") + assertThat(sut.create(sessionId, roomId, threadId, null)) + .isEqualTo("elementx://open/%40a%2F%3Adomain/%21a%2FRoomId%3Adomain/%24a%2FThreadId") + assertThat(sut.create(sessionId, roomId, threadId, eventId)) + .isEqualTo("elementx://open/%40a%2F%3Adomain/%21a%2FRoomId%3Adomain/%24a%2FThreadId/%24an%2FEventId") + assertThat(sut.create(sessionId, roomId, null, eventId)) + .isEqualTo("elementx://open/%40a%2F%3Adomain/%21a%2FRoomId%3Adomain//%24an%2FEventId") } } diff --git a/libraries/deeplink/impl/src/test/kotlin/io/element/android/libraries/deeplink/impl/DefaultDeeplinkParserTest.kt b/libraries/deeplink/impl/src/test/kotlin/io/element/android/libraries/deeplink/impl/DefaultDeeplinkParserTest.kt index 4b79f2b08cc..7f563ddd7f6 100644 --- a/libraries/deeplink/impl/src/test/kotlin/io/element/android/libraries/deeplink/impl/DefaultDeeplinkParserTest.kt +++ b/libraries/deeplink/impl/src/test/kotlin/io/element/android/libraries/deeplink/impl/DefaultDeeplinkParserTest.kt @@ -12,6 +12,10 @@ import android.content.Intent import androidx.core.net.toUri import com.google.common.truth.Truth.assertThat import io.element.android.libraries.deeplink.api.DeeplinkData +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.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_SESSION_ID @@ -34,6 +38,8 @@ class DefaultDeeplinkParserTest { "elementx://open/@alice:server.org/!aRoomId:domain/\$aThreadId/\$anEventId" const val A_URI_WITH_ROOM_WITH_EVENT_AND_NO_THREAD = "elementx://open/@alice:server.org/!aRoomId:domain//\$anEventId" + const val A_URI_WITH_ROOM_WITH_THREAD_AND_EVENT_AND_INVALID_CHARACTERS = + "elementx://open/@a%2Flice:server.org/!a%2FRoomId:domain/\$a%2FThreadId/\$an%2FEventId" } @Test @@ -49,6 +55,15 @@ class DefaultDeeplinkParserTest { .isEqualTo(DeeplinkData.Room(A_SESSION_ID, A_ROOM_ID, A_THREAD_ID, AN_EVENT_ID)) assertThat(sut.getFromIntent(createIntent(A_URI_WITH_ROOM_WITH_EVENT_AND_NO_THREAD))) .isEqualTo(DeeplinkData.Room(A_SESSION_ID, A_ROOM_ID, null, AN_EVENT_ID)) + assertThat(sut.getFromIntent(createIntent(A_URI_WITH_ROOM_WITH_THREAD_AND_EVENT_AND_INVALID_CHARACTERS))) + .isEqualTo( + DeeplinkData.Room( + sessionId = SessionId("@a/lice:server.org"), + roomId = RoomId("!a/RoomId:domain"), + threadId = ThreadId("\$a/ThreadId"), + eventId = EventId("\$an/EventId"), + ) + ) } @Test diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/LoadingButtonAtom.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/LoadingButtonAtom.kt new file mode 100644 index 00000000000..666d48d4577 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/LoadingButtonAtom.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2025 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.atomic.atoms + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import io.element.android.libraries.designsystem.theme.components.Button +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun LoadingButtonAtom( + modifier: Modifier = Modifier, +) = Button( + modifier = modifier.fillMaxWidth(), + enabled = false, + showProgress = true, + text = stringResource(CommonStrings.common_loading), + onClick = {}, +) diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/PlaybackSpeedButton.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/PlaybackSpeedButton.kt new file mode 100644 index 00000000000..dfd98dd5da6 --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/PlaybackSpeedButton.kt @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2025 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.atomic.atoms + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +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.draw.clip +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.components.Text +import io.element.android.libraries.designsystem.theme.messageFromMeBackground + +@Composable +fun PlaybackSpeedButton( + speed: Float, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + val speedText = when (speed) { + 0.5f -> "0.5×" + 1.0f -> "1×" + 1.5f -> "1.5×" + 2.0f -> "2×" + else -> "$speed×" + } + Box( + modifier = modifier + .clip(RoundedCornerShape(12.dp)) + .background( + color = ElementTheme.colors.bgCanvasDefault, + ) + .clickable(onClick = onClick) + .padding(horizontal = 8.dp, vertical = 4.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = speedText, + color = ElementTheme.colors.iconSecondary, + style = ElementTheme.typography.fontBodyXsMedium, + ) + } +} + +@PreviewsDayNight +@Composable +internal fun PlaybackSpeedButtonPreview() = ElementPreview { + Row( + modifier = Modifier + .background(ElementTheme.colors.messageFromMeBackground) + .padding(4.dp), + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + listOf(0.5f, 1.0f, 1.5f, 2.0f, 3.0f).forEach { speed -> + PlaybackSpeedButton( + speed = speed, + onClick = {}, + ) + } + } +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/SelectedIndicatorAtom.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/SelectedIndicatorAtom.kt index b2e25af0fb9..1a2e60cad56 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/SelectedIndicatorAtom.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/atoms/SelectedIndicatorAtom.kt @@ -34,7 +34,7 @@ fun SelectedIndicatorAtom( Icon( modifier = modifier.toggleable( value = true, - role = Role.Companion.Checkbox, + role = Role.Checkbox, enabled = enabled, onValueChange = {}, ), 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 40e84b34c3f..639d365625d 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 @@ -34,7 +34,6 @@ import io.element.android.compound.theme.ElementTheme import io.element.android.compound.tokens.generated.CompoundIcons 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.designsystem.theme.components.Icon import io.element.android.libraries.ui.strings.CommonStrings @@ -82,11 +81,6 @@ object BigIcon { * A success style with a tinted background. */ data object SuccessSolid : Style - - /** - * A loading style with the default background color. - */ - data object Loading : Style } /** @@ -110,7 +104,6 @@ object BigIcon { Style.Success -> Color.Transparent Style.AlertSolid -> ElementTheme.colors.bgCriticalSubtle Style.SuccessSolid -> ElementTheme.colors.bgSuccessSubtle - Style.Loading -> ElementTheme.colors.bgSubtleSecondary } Box( modifier = modifier @@ -119,52 +112,39 @@ object BigIcon { .background(backgroundColor), contentAlignment = Alignment.Center, ) { - if (style is Style.Loading) { - CircularProgressIndicator( - modifier = Modifier.size(27.dp), - color = ElementTheme.colors.iconSecondary, - trackColor = Color.Transparent, - strokeWidth = 3.dp, - ) - } else { - val icon = when (style) { - is Style.Default -> style.vectorIcon - Style.Alert, - Style.AlertSolid -> CompoundIcons.ErrorSolid() - Style.Success, - Style.SuccessSolid -> CompoundIcons.CheckCircleSolid() - Style.Loading -> error("This should never be reached") - } - val contentDescription = when (style) { - is Style.Default -> style.contentDescription - Style.Alert, - Style.AlertSolid -> stringResource(CommonStrings.common_error) - Style.Success, - Style.SuccessSolid -> stringResource(CommonStrings.common_success) - Style.Loading -> error("This should never be reached") - } - val iconTint = when (style) { - is Style.Default -> if (style.useCriticalTint) { - ElementTheme.colors.iconCriticalPrimary - } else if (style.usePrimaryTint) { - ElementTheme.colors.iconPrimary - } else { - ElementTheme.colors.iconSecondary - } - Style.Alert, - Style.AlertSolid -> ElementTheme.colors.iconCriticalPrimary - Style.Success, - Style.SuccessSolid -> ElementTheme.colors.iconSuccessPrimary - Style.Loading -> error("This should never be reached") + val icon = when (style) { + is Style.Default -> style.vectorIcon + Style.Alert, + Style.AlertSolid -> CompoundIcons.ErrorSolid() + Style.Success, + Style.SuccessSolid -> CompoundIcons.CheckCircleSolid() + } + val contentDescription = when (style) { + is Style.Default -> style.contentDescription + Style.Alert, + Style.AlertSolid -> stringResource(CommonStrings.common_error) + Style.Success, + Style.SuccessSolid -> stringResource(CommonStrings.common_success) + } + val iconTint = when (style) { + is Style.Default -> if (style.useCriticalTint) { + ElementTheme.colors.iconCriticalPrimary + } else if (style.usePrimaryTint) { + ElementTheme.colors.iconPrimary + } else { + ElementTheme.colors.iconSecondary } - - Icon( - modifier = Modifier.size(32.dp), - tint = iconTint, - imageVector = icon, - contentDescription = contentDescription - ) + Style.Alert, + Style.AlertSolid -> ElementTheme.colors.iconCriticalPrimary + Style.Success, + Style.SuccessSolid -> ElementTheme.colors.iconSuccessPrimary } + Icon( + modifier = Modifier.size(32.dp), + tint = iconTint, + imageVector = icon, + contentDescription = contentDescription + ) } } } @@ -199,6 +179,5 @@ internal class BigIconStyleProvider : PreviewParameterProvider { BigIcon.Style.Default(Icons.Filled.CatchingPokemon, useCriticalTint = true), BigIcon.Style.Success, BigIcon.Style.SuccessSolid, - BigIcon.Style.Loading, ) } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/ExpandableBottomSheetLayout.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/ExpandableBottomSheetLayout.kt index 433c38102d2..85307823f6c 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/ExpandableBottomSheetLayout.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/ExpandableBottomSheetLayout.kt @@ -77,28 +77,9 @@ fun ExpandableBottomSheetLayout( var calculatedMaxBottomContentHeightPx by remember(maxBottomContentHeightPx) { mutableIntStateOf(maxBottomContentHeightPx) } val animatable = remember { Animatable(0f) } - fun calculatePercentage(currentPos: Int, minPos: Int, maxPos: Int): Float { - val currentProgress = currentPos - minPos - if (currentProgress < 0) { - Timber.e("Invalid current progress: $currentProgress, minPos: $minPos, maxPos: $maxPos") - return 0f - } - val total = (maxPos - minPos).toFloat() - if (total <= 0) { - Timber.e("Invalid total space: $total, minPos: $minPos, maxPos: $maxPos") - return 0f - } - return currentProgress / total - } - LaunchedEffect(animatable.value) { if (animatable.isRunning && animatable.value != animatable.targetValue) { currentBottomContentHeightPx = animatable.value.roundToInt() - state.internalDraggingPercentage = calculatePercentage( - currentPos = currentBottomContentHeightPx, - minPos = minBottomContentHeightPx, - maxPos = calculatedMaxBottomContentHeightPx, - ) } } @@ -122,11 +103,6 @@ fun ExpandableBottomSheetLayout( minBottomContentHeightPx -> ExpandableBottomSheetLayoutState.Position.COLLAPSED else -> ExpandableBottomSheetLayoutState.Position.DRAGGING } - state.internalDraggingPercentage = calculatePercentage( - currentPos = newHeight, - minPos = minBottomContentHeightPx, - maxPos = calculatedMaxBottomContentHeightPx, - ) currentBottomContentHeightPx = newHeight }, onDragEnd = { diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/ExpandableBottomSheetLayoutState.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/ExpandableBottomSheetLayoutState.kt index 1b17ec544b0..ae9f9e4cf2b 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/ExpandableBottomSheetLayoutState.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/ExpandableBottomSheetLayoutState.kt @@ -11,7 +11,6 @@ package io.element.android.libraries.designsystem.components import androidx.compose.runtime.Composable import androidx.compose.runtime.Stable import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.setValue @@ -32,21 +31,12 @@ fun rememberExpandableBottomSheetLayoutState(): ExpandableBottomSheetLayoutState @Stable class ExpandableBottomSheetLayoutState { internal var internalPosition: Position by mutableStateOf(Position.COLLAPSED) - internal var internalDraggingPercentage: Float by mutableFloatStateOf( - if (internalPosition == Position.EXPANDED) 1f else 0f - ) /** * The current position of the bottom sheet layout. */ val position get() = internalPosition - /** - * The percentage of the bottom sheet layout that is currently being dragged. - * This value ranges from `0f` for [Position.COLLAPSED] to `1f` for [Position.EXPANDED]. - */ - val draggingPercentage = internalDraggingPercentage - /** * The position of the bottom sheet layout. */ 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 6fd48fe1941..b044192e6f5 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 @@ -46,7 +46,7 @@ enum class AvatarSize(val dp: Dp) { RoomInviteItem(52.dp), InviteSender(16.dp), - EditRoomDetails(70.dp), + EditRoomDetails(68.dp), RoomListManageUser(96.dp), NotificationsOptIn(32.dp), diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/DmAvatars.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/DmAvatars.kt index 1cd024542c9..c5b870507b9 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/DmAvatars.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/avatar/DmAvatars.kt @@ -59,7 +59,7 @@ fun DmAvatars( Avatar( avatarData = userAvatarData, avatarType = AvatarType.User, - contentDescription = userAvatarData.url?.let { stringResource(CommonStrings.a11y_your_avatar) }, + contentDescription = stringResource(CommonStrings.a11y_your_avatar), modifier = Modifier .align(Alignment.BottomStart) .graphicsLayer { @@ -94,7 +94,7 @@ fun DmAvatars( Avatar( avatarData = otherUserAvatarData, avatarType = AvatarType.User, - contentDescription = otherUserAvatarData.url?.let { stringResource(CommonStrings.a11y_other_user_avatar) }, + contentDescription = stringResource(CommonStrings.a11y_other_user_avatar), modifier = Modifier .align(Alignment.TopEnd) .clip(CircleShape) 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 ebd81f61b10..da57fbcbe15 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 @@ -36,7 +36,7 @@ internal fun ImageAvatar( SubcomposeAsyncImage( model = avatarData, contentDescription = contentDescription, - contentScale = ContentScale.Companion.Crop, + contentScale = ContentScale.Crop, modifier = modifier .size(size) .clip(avatarShape) 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 37c9dae87ea..e85725cfc04 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 @@ -57,14 +57,14 @@ fun ErrorDialogWithDoNotShowAgain( Column { Text( text = content, - style = ElementTheme.materialTypography.bodyMedium, + style = ElementTheme.typography.fontBodyMdRegular, ) Spacer(modifier = Modifier.height(8.dp)) Row(verticalAlignment = Alignment.CenterVertically) { Checkbox(checked = doNotShowAgain, onCheckedChange = { doNotShowAgain = it }) Text( text = stringResource(id = CommonStrings.common_do_not_show_this_again), - style = ElementTheme.materialTypography.bodyMedium, + style = ElementTheme.typography.fontBodyMdRegular, ) } } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/SaveChangesDialog.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/SaveChangesDialog.kt index b722480bb19..dc30b191a05 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/SaveChangesDialog.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/SaveChangesDialog.kt @@ -17,16 +17,22 @@ import io.element.android.libraries.ui.strings.CommonStrings @Composable fun SaveChangesDialog( - onSubmitClick: () -> Unit, + onSaveClick: () -> Unit, + onDiscardClick: () -> Unit, onDismiss: () -> Unit, modifier: Modifier = Modifier, title: String = stringResource(CommonStrings.dialog_unsaved_changes_title), - content: String = stringResource(CommonStrings.dialog_unsaved_changes_description_android), + content: String = stringResource(CommonStrings.dialog_unsaved_changes_description), + submitText: String = stringResource(CommonStrings.action_save), + cancelText: String = stringResource(CommonStrings.action_discard), ) = ConfirmationDialog( modifier = modifier, title = title, content = content, - onSubmitClick = onSubmitClick, + submitText = submitText, + cancelText = cancelText, + onSubmitClick = onSaveClick, + onCancelClick = onDiscardClick, onDismiss = onDismiss, ) @@ -34,7 +40,8 @@ fun SaveChangesDialog( @Composable internal fun SaveChangesDialogPreview() = ElementPreview { SaveChangesDialog( - onSubmitClick = {}, + onSaveClick = {}, + onDiscardClick = {}, onDismiss = {} ) } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/TextFieldDialog.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/TextFieldDialog.kt index aeaaa9f040f..1ce241d5d95 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/TextFieldDialog.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/TextFieldDialog.kt @@ -76,7 +76,7 @@ fun TextFieldDialog( item { Text( text = content, - style = ElementTheme.materialTypography.bodyMedium, + style = ElementTheme.typography.fontBodyMdRegular, ) } } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/ListItemContent.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/ListItemContent.kt index 022100abe1b..e8add0369f0 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/ListItemContent.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/ListItemContent.kt @@ -85,7 +85,7 @@ sealed interface ListItemContent { data class Text(val text: String) : ListItemContent /** Displays any custom content. */ - data class Custom(val content: @Composable () -> Unit) : ListItemContent + data class Custom(val content: @Composable (enabled: Boolean) -> Unit) : ListItemContent /** Displays a badge. */ data object Badge : ListItemContent @@ -131,7 +131,7 @@ sealed interface ListItemContent { is Counter -> { CounterAtom(count = count) } - is Custom -> content() + is Custom -> content(isItemEnabled) } } } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceCheckbox.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceCheckbox.kt index 5277aca1c7b..6b4a8e1e0cd 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceCheckbox.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceCheckbox.kt @@ -43,7 +43,6 @@ fun PreferenceCheckbox( leadingContent = preferenceIcon( icon = icon, iconResourceId = iconResourceId, - enabled = enabled, showIconAreaIfNoIcon = showIconAreaIfNoIcon, ), headlineContent = { 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 23fdec99424..5fcbcab4751 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 @@ -40,7 +40,7 @@ import io.element.android.libraries.designsystem.theme.components.DropdownMenuIt 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.Text -import io.element.android.libraries.designsystem.toEnabledColor +import io.element.android.libraries.designsystem.toIconSecondaryEnabledColor import io.element.android.libraries.designsystem.toSecondaryEnabledColor import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList @@ -64,7 +64,6 @@ fun PreferenceDropdown( leadingContent = preferenceIcon( icon = icon, iconResourceId = iconResourceId, - enabled = enabled, showIconAreaIfNoIcon = showIconAreaIfNoIcon, ), headlineContent = { @@ -72,7 +71,6 @@ fun PreferenceDropdown( style = ElementTheme.typography.fontBodyLgRegular, modifier = Modifier.fillMaxWidth(), text = title, - color = enabled.toEnabledColor(), ) }, supportingContent = supportingText?.let { @@ -80,22 +78,23 @@ fun PreferenceDropdown( Text( style = ElementTheme.typography.fontBodyMdRegular, text = it, - color = enabled.toSecondaryEnabledColor(), ) } }, trailingContent = ListItemContent.Custom( - content = { + content = { enabled -> DropdownTrailingContent( selectedOption = selectedOption, options = options, onSelectOption = onSelectOption, expanded = isDropdownExpanded, onExpandedChange = { isDropdownExpanded = it }, + enabled = enabled, modifier = Modifier.fillMaxSize(0.3f) ) } ), + enabled = enabled, onClick = { isDropdownExpanded = true }.takeIf { !isDropdownExpanded }, ) } @@ -118,6 +117,7 @@ private fun DropdownTrailingContent( expanded: Boolean, onExpandedChange: (Boolean) -> Unit, onSelectOption: (T) -> Unit, + enabled: Boolean, modifier: Modifier = Modifier, ) { Row( @@ -129,7 +129,7 @@ private fun DropdownTrailingContent( text = selectedOption?.getText().orEmpty(), maxLines = 1, style = ElementTheme.typography.fontBodyMdRegular, - color = ElementTheme.colors.textSecondary, + color = enabled.toSecondaryEnabledColor(), overflow = TextOverflow.Ellipsis, textAlign = TextAlign.End, modifier = Modifier.weight(1f), @@ -137,7 +137,7 @@ private fun DropdownTrailingContent( Icon( imageVector = CompoundIcons.ChevronDown(), contentDescription = null, - tint = ElementTheme.colors.iconSecondary, + tint = enabled.toIconSecondaryEnabledColor(), ) DropdownMenu( expanded = expanded, @@ -146,6 +146,7 @@ private fun DropdownTrailingContent( ) { options.forEach { option -> DropdownMenuItem( + enabled = enabled, text = { Text( text = option.getText(), @@ -206,5 +207,14 @@ internal fun PreferenceDropdownPreview() = ElementThemedPreview { options = options, onSelectOption = {}, ) + PreferenceDropdown( + title = "Dropdown", + supportingText = "Options for dropdown", + icon = CompoundIcons.Threads(), + selectedOption = options.first(), + options = options, + onSelectOption = {}, + enabled = false + ) } } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceSlide.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceSlide.kt index a5092609e9a..671eb5bf3f3 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceSlide.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceSlide.kt @@ -44,7 +44,6 @@ fun PreferenceSlide( leadingContent = preferenceIcon( icon = icon, iconResourceId = iconResourceId, - enabled = enabled, showIconAreaIfNoIcon = showIconAreaIfNoIcon, ), headlineContent = { diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceSwitch.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceSwitch.kt index 404a26737f0..4545dbdf3ea 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceSwitch.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceSwitch.kt @@ -42,7 +42,6 @@ fun PreferenceSwitch( leadingContent = preferenceIcon( icon = icon, iconResourceId = iconResourceId, - enabled = enabled, showIconAreaIfNoIcon = showIconAreaIfNoIcon, ), headlineContent = { diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/components/PreferenceIcon.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/components/PreferenceIcon.kt index b3e12275257..59e818c18ae 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/components/PreferenceIcon.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/components/PreferenceIcon.kt @@ -34,11 +34,10 @@ fun preferenceIcon( @DrawableRes iconResourceId: Int? = null, showIconBadge: Boolean = false, tintColor: Color? = null, - enabled: Boolean = true, showIconAreaIfNoIcon: Boolean = false, ): ListItemContent.Custom? { return if (icon != null || iconResourceId != null || showIconAreaIfNoIcon) { - ListItemContent.Custom { + ListItemContent.Custom { enabled -> PreferenceIcon( icon = icon, iconResourceId = iconResourceId, diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/tooltip/ElementTooltipDefaults.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/tooltip/ElementTooltipDefaults.kt index b21c5550cfc..c6c244b2d6f 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/tooltip/ElementTooltipDefaults.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/tooltip/ElementTooltipDefaults.kt @@ -9,6 +9,7 @@ package io.element.android.libraries.designsystem.components.tooltip import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.TooltipAnchorPosition import androidx.compose.material3.TooltipDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.remember @@ -41,8 +42,9 @@ object ElementTooltipDefaults { windowPadding: Dp = 12.dp, ): PopupPositionProvider { val windowPaddingPx = with(LocalDensity.current) { windowPadding.roundToPx() } - val plainTooltipPositionProvider = TooltipDefaults.rememberPlainTooltipPositionProvider( - spacingBetweenTooltipAndAnchor = spacingBetweenTooltipAndAnchor, + val plainTooltipPositionProvider = TooltipDefaults.rememberTooltipPositionProvider( + positioning = TooltipAnchorPosition.Above, + spacingBetweenTooltipAndAnchor = spacingBetweenTooltipAndAnchor ) return remember(windowPaddingPx, plainTooltipPositionProvider) { object : PopupPositionProvider { diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/preview/SheetState.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/preview/SheetState.kt index d14d6143068..7e687e9b89f 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/preview/SheetState.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/preview/SheetState.kt @@ -12,12 +12,12 @@ import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.SheetState import androidx.compose.material3.SheetValue import androidx.compose.runtime.Composable -import androidx.compose.ui.platform.LocalDensity @OptIn(ExperimentalMaterial3Api::class) @Composable fun sheetStateForPreview() = SheetState( skipPartiallyExpanded = true, + positionalThreshold = { 0.5f }, + velocityThreshold = { 400f }, initialValue = SheetValue.Expanded, - density = LocalDensity.current, ) diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/text/StringWithLink.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/text/StringWithLink.kt new file mode 100644 index 00000000000..d82ea9e817f --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/text/StringWithLink.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2025 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.text + +import androidx.annotation.StringRes +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.LinkAnnotation +import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextDecoration +import io.element.android.compound.theme.ElementTheme +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +fun stringWithLink( + @StringRes textRes: Int, + url: String, + onLinkClick: (String) -> Unit, + @StringRes linkTextRes: Int = CommonStrings.action_learn_more, +) = buildAnnotatedString { + val learnMoreStr = stringResource(linkTextRes) + val fullText = stringResource(textRes, learnMoreStr) + append(fullText) + val learnMoreStartIndex = fullText.lastIndexOf(learnMoreStr) + addStyle( + style = SpanStyle( + textDecoration = TextDecoration.Underline, + fontWeight = FontWeight.Bold, + color = ElementTheme.colors.textPrimary + ), + start = learnMoreStartIndex, + end = learnMoreStartIndex + learnMoreStr.length, + ) + addLink( + url = LinkAnnotation.Url( + url = url, + linkInteractionListener = { + onLinkClick(url) + } + ), + start = learnMoreStartIndex, + end = learnMoreStartIndex + learnMoreStr.length, + ) +} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/AlertDialogContent.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/AlertDialogContent.kt index ddc235bc9ec..c9c553b7b92 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/AlertDialogContent.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/AlertDialogContent.kt @@ -65,7 +65,7 @@ internal fun SimpleAlertDialogContent( content = { Text( text = content, - style = ElementTheme.materialTypography.bodyMedium, + style = ElementTheme.typography.fontBodyMdRegular, ) }, submitText = submitText, diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/FilledTextField.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/FilledTextField.kt index cbd25c1aeb3..d9f42566878 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/FilledTextField.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/FilledTextField.kt @@ -15,9 +15,15 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.text.input.InputTransformation +import androidx.compose.foundation.text.input.KeyboardActionHandler +import androidx.compose.foundation.text.input.OutputTransformation +import androidx.compose.foundation.text.input.TextFieldLineLimits +import androidx.compose.foundation.text.input.TextFieldState import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.TextFieldColors import androidx.compose.material3.TextFieldDefaults +import androidx.compose.material3.TextFieldLabelScope import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.Modifier @@ -135,6 +141,51 @@ fun FilledTextField( ) } +@Composable +fun FilledTextField( + state: TextFieldState, + modifier: Modifier = Modifier, + enabled: Boolean = true, + readOnly: Boolean = false, + textStyle: TextStyle = LocalTextStyle.current, + label: @Composable (TextFieldLabelScope.() -> Unit)? = null, + placeholder: @Composable (() -> Unit)? = null, + leadingIcon: @Composable (() -> Unit)? = null, + trailingIcon: @Composable (() -> Unit)? = null, + supportingText: @Composable (() -> Unit)? = null, + isError: Boolean = false, + inputTransformation: InputTransformation? = null, + outputTransformation: OutputTransformation? = null, + keyboardOptions: KeyboardOptions = KeyboardOptions.Default, + keyboardActions: KeyboardActionHandler? = null, + lineLimits: TextFieldLineLimits = TextFieldLineLimits.Default, + interactionSource: MutableInteractionSource? = null, + shape: Shape = TextFieldDefaults.shape, + colors: TextFieldColors = TextFieldDefaults.colors() +) { + androidx.compose.material3.TextField( + state = state, + modifier = modifier, + enabled = enabled, + readOnly = readOnly, + textStyle = textStyle, + label = label, + placeholder = placeholder, + leadingIcon = leadingIcon, + trailingIcon = trailingIcon, + supportingText = supportingText, + isError = isError, + inputTransformation = inputTransformation, + outputTransformation = outputTransformation, + keyboardOptions = keyboardOptions, + onKeyboardAction = keyboardActions, + lineLimits = lineLimits, + interactionSource = interactionSource, + shape = shape, + colors = colors, + ) +} + @Preview(group = PreviewGroup.TextFields) @Composable internal fun FilledTextFieldLightPreview() = diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/FloatingActionButton.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/FloatingActionButton.kt index 60522d5f36b..9b633621017 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/FloatingActionButton.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/FloatingActionButton.kt @@ -33,6 +33,8 @@ fun FloatingActionButton( modifier: Modifier = Modifier, shape: Shape = FloatingActionButtonDefaults.shape, containerColor: Color = ElementTheme.colors.textActionAccent, + // TCHAP theme : color used when background is blue Tchap +// contentColor: Color = ElementTheme.colors.iconOnSolidPrimary, contentColor: Color = ElementTheme.iconOnSolidBlueTchap, elevation: FloatingActionButtonElevation = FloatingActionButtonDefaults.elevation(), interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/ListItem.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/ListItem.kt index 8bc09ceaa48..0da086725d8 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/ListItem.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/ListItem.kt @@ -114,7 +114,7 @@ fun ListItem( val decoratedHeadlineContent: @Composable () -> Unit = { CompositionLocalProvider( - LocalTextStyle provides ElementTheme.materialTypography.bodyLarge, + LocalTextStyle provides ElementTheme.typography.fontBodyLgRegular, LocalContentColor provides headlineColor, ) { headlineContent() @@ -123,7 +123,7 @@ fun ListItem( val decoratedSupportingContent: (@Composable () -> Unit)? = supportingContent?.let { content -> { CompositionLocalProvider( - LocalTextStyle provides ElementTheme.materialTypography.bodyMedium, + LocalTextStyle provides ElementTheme.typography.fontBodyMdRegular, LocalContentColor provides supportingColor, ) { content() 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 e4c8ca74db3..a49a3595f7c 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 @@ -33,7 +33,7 @@ fun MediumTopAppBar( navigationIcon: @Composable () -> Unit = {}, actions: @Composable RowScope.() -> Unit = {}, windowInsets: WindowInsets = TopAppBarDefaults.windowInsets, - colors: TopAppBarColors = TopAppBarDefaults.mediumTopAppBarColors(), + colors: TopAppBarColors = TopAppBarDefaults.topAppBarColors(), scrollBehavior: TopAppBarScrollBehavior? = null ) { androidx.compose.material3.MediumTopAppBar( diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/NavigationBarItem.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/NavigationBarItem.kt index 40407602a83..7f74944fce8 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/NavigationBarItem.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/NavigationBarItem.kt @@ -50,6 +50,6 @@ object ElementNavigationBarItemDefaults { selectedTextColor = ElementTheme.colors.textPrimary, unselectedIconColor = ElementTheme.colors.iconTertiary, unselectedTextColor = ElementTheme.colors.textDisabled, - selectedIndicatorColor = Color.Companion.Transparent, + selectedIndicatorColor = Color.Transparent, ) } 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 924b77882b4..c1fd7bc6f06 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 @@ -14,13 +14,13 @@ import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.SearchBar import androidx.compose.material3.SearchBarColors import androidx.compose.material3.SearchBarDefaults -import androidx.compose.material3.TextFieldColors import androidx.compose.material3.TextFieldDefaults import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable @@ -45,6 +45,9 @@ import io.element.android.libraries.designsystem.preview.ElementThemedPreview import io.element.android.libraries.designsystem.preview.PreviewGroup import io.element.android.libraries.ui.strings.CommonStrings +/** + * Ref: https://www.figma.com/design/G1xy0HDZKJf5TCRFmKb5d5/Compound-Android-Components?node-id=1992-8350 + */ @OptIn(ExperimentalMaterial3Api::class) @Composable fun SearchBar( @@ -63,14 +66,12 @@ fun SearchBar( interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, inactiveBarColors: SearchBarColors = ElementSearchBarDefaults.inactiveColors(), activeBarColors: SearchBarColors = ElementSearchBarDefaults.activeColors(), - inactiveTextInputColors: TextFieldColors = ElementSearchBarDefaults.inactiveInputFieldColors(), - activeTextInputColors: TextFieldColors = ElementSearchBarDefaults.activeInputFieldColors(), contentPrefix: @Composable ColumnScope.() -> Unit = {}, contentSuffix: @Composable ColumnScope.() -> Unit = {}, resultHandler: @Composable ColumnScope.(T) -> Unit = {}, ) { val focusManager = LocalFocusManager.current - + val colors = if (active) activeBarColors else inactiveBarColors val updatedOnQueryChange by rememberUpdatedState(onQueryChange) LaunchedEffect(active) { if (!active) { @@ -107,28 +108,25 @@ fun SearchBar( } } } - !active -> { { Icon( imageVector = CompoundIcons.Search(), contentDescription = stringResource(CommonStrings.action_search), - tint = ElementTheme.colors.iconTertiary, ) } } - else -> null }, interactionSource = interactionSource, - colors = if (active) activeTextInputColors else inactiveTextInputColors, + colors = colors.inputFieldColors, ) }, expanded = active, onExpandedChange = onActiveChange, modifier = modifier.padding(horizontal = if (!active) 16.dp else 0.dp), shape = shape, - colors = if (active) activeBarColors else inactiveBarColors, + colors = colors, tonalElevation = tonalElevation, windowInsets = windowInsets, content = { @@ -163,35 +161,43 @@ object ElementSearchBarDefaults { @OptIn(ExperimentalMaterial3Api::class) @Composable fun inactiveColors() = SearchBarDefaults.colors( - containerColor = ElementTheme.materialColors.surfaceVariant, - dividerColor = ElementTheme.materialColors.outline, + containerColor = Color.Transparent, + dividerColor = ElementTheme.colors.borderInteractivePrimary, + inputFieldColors = inactiveInputFieldColors(), ) @Composable fun inactiveInputFieldColors() = TextFieldDefaults.colors( unfocusedPlaceholderColor = ElementTheme.colors.textDisabled, focusedPlaceholderColor = ElementTheme.colors.textDisabled, - unfocusedLeadingIconColor = ElementTheme.materialColors.primary, - focusedLeadingIconColor = ElementTheme.materialColors.primary, - unfocusedTrailingIconColor = ElementTheme.materialColors.primary, - focusedTrailingIconColor = ElementTheme.materialColors.primary, + unfocusedTrailingIconColor = ElementTheme.colors.iconDisabled, + focusedTrailingIconColor = ElementTheme.colors.iconDisabled, + focusedContainerColor = ElementTheme.colors.bgSubtleSecondary, + unfocusedContainerColor = ElementTheme.colors.bgSubtleSecondary, + disabledContainerColor = ElementTheme.colors.bgSubtleSecondary, + errorContainerColor = ElementTheme.colors.bgSubtleSecondary, ) @OptIn(ExperimentalMaterial3Api::class) @Composable fun activeColors() = SearchBarDefaults.colors( containerColor = Color.Transparent, - dividerColor = ElementTheme.materialColors.outline, + dividerColor = ElementTheme.colors.borderInteractivePrimary, + inputFieldColors = activeInputFieldColors(), ) @Composable fun activeInputFieldColors() = TextFieldDefaults.colors( - unfocusedPlaceholderColor = ElementTheme.colors.textDisabled, - focusedPlaceholderColor = ElementTheme.colors.textDisabled, - unfocusedLeadingIconColor = ElementTheme.materialColors.primary, - focusedLeadingIconColor = ElementTheme.materialColors.primary, - unfocusedTrailingIconColor = ElementTheme.materialColors.primary, - focusedTrailingIconColor = ElementTheme.materialColors.primary, + unfocusedPlaceholderColor = ElementTheme.colors.textSecondary, + focusedPlaceholderColor = ElementTheme.colors.textSecondary, + unfocusedLeadingIconColor = ElementTheme.colors.iconPrimary, + focusedLeadingIconColor = ElementTheme.colors.iconPrimary, + unfocusedTrailingIconColor = ElementTheme.colors.iconTertiary, + focusedTrailingIconColor = ElementTheme.colors.iconTertiary, + focusedContainerColor = Color.Transparent, + unfocusedContainerColor = Color.Transparent, + disabledContainerColor = Color.Transparent, + errorContainerColor = Color.Transparent, ) } @@ -295,6 +301,7 @@ private fun ContentToPreview( resultHandler: @Composable ColumnScope.(String) -> Unit = {}, ) { SearchBar( + modifier = Modifier.heightIn(max = 200.dp), query = query, active = active, resultState = resultState, diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/ForceMaxBrightness.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/ForceMaxBrightness.kt new file mode 100644 index 00000000000..b0c9e8cea7d --- /dev/null +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/ForceMaxBrightness.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2025 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.activity.compose.LocalActivity +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import io.element.android.libraries.androidutils.system.setFullBrightness + +@Composable +fun ForceMaxBrightness() { + val activity = LocalActivity.current ?: return + DisposableEffect(Unit) { + activity.setFullBrightness(true) + onDispose { + activity.setFullBrightness(false) + } + } +} diff --git a/libraries/di/src/main/kotlin/io/element/android/libraries/di/identifiers/SentryDsn.kt b/libraries/di/src/main/kotlin/io/element/android/libraries/di/identifiers/SentryDsn.kt new file mode 100644 index 00000000000..264af2e6e19 --- /dev/null +++ b/libraries/di/src/main/kotlin/io/element/android/libraries/di/identifiers/SentryDsn.kt @@ -0,0 +1,11 @@ +/* + * Copyright (c) 2025 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.di.identifiers + +@JvmInline +value class SentryDsn(val value: String) diff --git a/libraries/di/src/main/kotlin/io/element/android/libraries/di/identifiers/SentrySdkDsn.kt b/libraries/di/src/main/kotlin/io/element/android/libraries/di/identifiers/SentrySdkDsn.kt new file mode 100644 index 00000000000..f1ebe2e5c2d --- /dev/null +++ b/libraries/di/src/main/kotlin/io/element/android/libraries/di/identifiers/SentrySdkDsn.kt @@ -0,0 +1,11 @@ +/* + * Copyright (c) 2025 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.di.identifiers + +@JvmInline +value class SentrySdkDsn(val value: String) diff --git a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLatestEventFormatter.kt b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLatestEventFormatter.kt index 7e1ffc3ee63..009547f9eb2 100644 --- a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLatestEventFormatter.kt +++ b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLatestEventFormatter.kt @@ -9,6 +9,7 @@ package io.element.android.libraries.eventformatter.impl import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.core.extensions.DEFAULT_SAFE_LENGTH import io.element.android.libraries.di.SessionScope import io.element.android.libraries.eventformatter.api.RoomLatestEventFormatter import io.element.android.libraries.eventformatter.impl.mode.RenderingMode @@ -54,11 +55,6 @@ class DefaultRoomLatestEventFormatter( private val stateContentFormatter: StateContentFormatter, private val permalinkParser: PermalinkParser, ) : RoomLatestEventFormatter { - companion object { - // Max characters to display in the last message. This works around https://github.com/element-hq/element-x-android/issues/2105 - private const val MAX_SAFE_LENGTH = 500 - } - override fun format( latestEvent: LatestEventValue.Local, isDmRoom: Boolean, @@ -121,7 +117,7 @@ class DefaultRoomLatestEventFormatter( } is LegacyCallInviteContent -> sp.getString(CommonStrings.common_unsupported_call) is CallNotifyContent -> sp.getString(CommonStrings.common_call_started) - }?.take(MAX_SAFE_LENGTH) + }?.take(DEFAULT_SAFE_LENGTH) } private fun MessageContent.process( diff --git a/libraries/eventformatter/impl/src/main/res/values-hr/translations.xml b/libraries/eventformatter/impl/src/main/res/values-hr/translations.xml new file mode 100644 index 00000000000..50ca735d50a --- /dev/null +++ b/libraries/eventformatter/impl/src/main/res/values-hr/translations.xml @@ -0,0 +1,73 @@ + + + "(promijenjen je i avatar)" + "%1$s promijenio/la je svoj avatar" + "Promijenili ste svoj avatar" + "%1$s degradiran/a je u člana" + "%1$s degradiran/a je u moderatora" + "%1$s promijenio/la je svoje ime za prikaz s %2$s na %3$s" + "Promijenili ste svoje ime za prikaz s %1$s na %2$s" + "%1$s uklonio/la je svoje ime za prikaz (bilo je %2$s)" + "Uklonili ste svoje ime za prikaz (bilo je %1$s)" + "%1$s postavio/la je svoje ime za prikaz na %2$s" + "Postavili ste svoje ime za prikaz na %1$s" + "%1$s promaknut/a je u administratora" + "%1$s promaknut/a je u moderatora" + "%1$s promijenio/la je avatar sobe" + "Promijenili ste avatar sobe" + "%1$s uklonio/la je avatar sobe" + "Uklonili ste avatar sobe" + "%1$s zabranio/la je pristup za %2$s" + "Zabranili ste pristup korisniku %1$s" + "Zabranili ste pristup korisniku %1$s: %2$s" + "%1$s zabranio/la je pristup korisniku %2$s: %3$s" + "%1$s stvorio/la je sobu" + "Stvorili ste sobu" + "%1$s je pozvao/la %2$s" + "%1$s prihvatio/la je poziv" + "Prihvatili ste poziv" + "Pozvali ste %1$s" + "%1$s vas je pozvao/la" + "%1$s pridružio/la se sobi" + "Pridružili ste se sobi" + "%1$s zatražio/la je pridruživanje sobi" + "Korisniku %1$s odobren je pristup sobi %2$s" + "Dopustili ste korisniku %1$s da se pridruži" + "Zatražili ste pridruživanje sobi" + "%1$s odbio/la je zahtjev za pridruživanje korisnika %2$s" + "Odbili ste zahtjev za pridruživanje korisnika %1$s" + "%1$s odbio/la je vaš zahtjev za pridruživanje" + "Korisnik %1$s više nije zainteresiran za pridruživanje" + "Otkazali ste zahtjev za pridruživanje" + "%1$s napustio/la je sobu" + "Napustili ste sobu" + "%1$s promijenio/la je naziv sobe u: %2$s" + "Promijenili ste naziv sobe u: %1$s" + "%1$s uklonio/la je naziv sobe" + "Uklonili ste naziv sobe" + "%1$s nije izvršio/la nikakve promjene" + "Niste izvršili nikakve promjene" + "%1$s promijenio/la je prikvačene poruke" + "Promijenili ste prikvačene poruke" + "%1$s prikvačio/la je poruku" + "Prikvačili ste poruku" + "%1$s otkvačio/la je poruku" + "Otkvačili ste poruku" + "%1$s odbio/la je poziv" + "Odbili ste poziv" + "%1$s uklonio/la je %2$s" + "Uklonili ste %1$s" + "Uklonili ste korisnika %1$s: %2$s" + "%1$s uklonio/la je korisnika %2$s: %3$s" + "%1$s poslao/la je pozivnicu za pridruživanje sobi korisniku %2$s" + "Poslali ste pozivnicu za pridruživanje sobi korisniku %1$s" + "%1$s povukao/la je poziv za pridruživanje sobi korisniku %2$s" + "Povukli ste poziv za pridruživanje sobi korisniku %1$s" + "%1$s promijenio/la je temu na: %2$s" + "Promijenili ste temu na: %1$s" + "%1$s uklonio/la je temu sobe" + "Uklonili ste temu sobe" + "%1$s uklonio/la je zabranu pristupa za %2$sban" + "Uklonili ste zabranu pristupa za %1$s" + "%1$s napravio/la je nepoznatu promjenu u svom članstvu" + diff --git a/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultPinnedMessagesBannerFormatterTest.kt b/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultPinnedMessagesBannerFormatterTest.kt index 10c09ad41dd..b58bbb4b253 100644 --- a/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultPinnedMessagesBannerFormatterTest.kt +++ b/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultPinnedMessagesBannerFormatterTest.kt @@ -103,7 +103,11 @@ class DefaultPinnedMessagesBannerFormatterTest { fun `Unable to decrypt content`() { val expected = "Waiting for this message" val senderName = "Someone" - val message = createRoomEvent(false, senderName, UnableToDecryptContent(UnableToDecryptContent.Data.Unknown)) + val message = createRoomEvent( + sentByYou = false, + senderDisplayName = senderName, + content = UnableToDecryptContent(data = UnableToDecryptContent.Data.Unknown, threadInfo = null) + ) val result = formatter.format(message) assertThat(result).isEqualTo(expected) } diff --git a/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLatestEventFormatterTest.kt b/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLatestEventFormatterTest.kt index c06b79ead9b..0da3134098d 100644 --- a/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLatestEventFormatterTest.kt +++ b/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLatestEventFormatterTest.kt @@ -112,7 +112,11 @@ class DefaultRoomLatestEventFormatterTest { val expected = "Waiting for this message" val senderName = "Someone" sequenceOf(false, true).forEach { isDm -> - val message = createLatestEvent(false, senderName, UnableToDecryptContent(UnableToDecryptContent.Data.Unknown)) + val message = createLatestEvent( + sentByYou = false, + senderDisplayName = senderName, + content = UnableToDecryptContent(data = UnableToDecryptContent.Data.Unknown, threadInfo = null), + ) val result = formatter.format(message, isDm) if (isDm) { assertThat(result).isEqualTo(expected) 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 ec8cd716073..4ac8a4e7ead 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 @@ -71,10 +71,18 @@ enum class FeatureFlags( defaultValue = { false }, isFinished = false, ), - Space( - key = "feature.space", - title = "Spaces", - defaultValue = { true }, + CreateSpaces( + key = "feature.createSpaces", + title = "Create spaces", + description = "Allow creating spaces.", + defaultValue = { false }, + isFinished = false, + ), + SpaceSettings( + key = "feature.spaceSettings", + title = "Space settings", + description = "Allow managing space settings such as details, permissions and privacy.", + defaultValue = { false }, isFinished = false, ), PrintLogsToLogcat( @@ -128,4 +136,18 @@ enum class FeatureFlags( defaultValue = { true }, isFinished = false, ), + QrCodeLogin( + key = "feature.qr_code_login", + title = "QR Code Login", + description = "Allow logging in on other devices using a QR code.", + defaultValue = { false }, + isFinished = false, + ), + SignInWithClassic( + key = "feature.signin_with_classic", + title = "Sign in with Element Classic", + description = "Allow the application to sign in to the current Element Classic account.", + defaultValue = { false }, + isFinished = false, + ), } diff --git a/libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/DefaultFeatureFlagService.kt b/libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/DefaultFeatureFlagService.kt index f7361b69b10..74967a574d5 100644 --- a/libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/DefaultFeatureFlagService.kt +++ b/libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/DefaultFeatureFlagService.kt @@ -25,10 +25,14 @@ class DefaultFeatureFlagService( private val featuresProvider: FeaturesProvider, ) : FeatureFlagService { override fun isFeatureEnabledFlow(feature: Feature): Flow { - return providers.filter { it.hasFeature(feature) } - .maxByOrNull(FeatureFlagProvider::priority) - ?.isFeatureEnabledFlow(feature) - ?: flowOf(feature.defaultValue(buildMeta)) + return if (feature.isFinished) { + flowOf(feature.defaultValue(buildMeta)) + } else { + providers.filter { it.hasFeature(feature) } + .maxByOrNull(FeatureFlagProvider::priority) + ?.isFeatureEnabledFlow(feature) + ?: flowOf(feature.defaultValue(buildMeta)) + } } override suspend fun setFeatureEnabled(feature: Feature, enabled: Boolean): Boolean { 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 ed406e75ffd..d4a3e75d1a1 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 @@ -88,7 +88,7 @@ class FullScreenIntentPermissionsPresenter( "package:${buildMeta.applicationId}".toUri() ) externalIntentLauncher.launch(intent) - } catch (e: ActivityNotFoundException) { + } catch (_: ActivityNotFoundException) { val intent = Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS) .putExtra(Settings.EXTRA_APP_PACKAGE, buildMeta.applicationId) externalIntentLauncher.launch(intent) 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 1718f810df5..773dbaaa07b 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 @@ -9,6 +9,7 @@ package io.element.android.libraries.matrix.api import io.element.android.libraries.core.data.tryOrNull +import io.element.android.libraries.matrix.api.analytics.SdkStoreSizes import io.element.android.libraries.matrix.api.core.DeviceId import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.MatrixPatterns @@ -19,6 +20,8 @@ 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.createroom.CreateRoomParameters import io.element.android.libraries.matrix.api.encryption.EncryptionService +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.MediaPreviewService import io.element.android.libraries.matrix.api.notification.NotificationService @@ -81,6 +84,7 @@ interface MatrixClient { suspend fun joinRoomByIdOrAlias(roomIdOrAlias: RoomIdOrAlias, serverNames: List): Result suspend fun knockRoom(roomIdOrAlias: RoomIdOrAlias, message: String, serverNames: List): Result suspend fun getCacheSize(): Long + suspend fun getDatabaseSizes(): Result /** * Will close the client and delete the cache data. @@ -194,6 +198,31 @@ interface MatrixClient { * Use [Timeline.markAsRead] instead when possible. */ suspend fun markRoomAsFullyRead(roomId: RoomId, eventId: EventId): Result + + /** + * Check if linking a new device using QrCode is supported by the server. + */ + suspend fun canLinkNewDevice(): Result + + /** + * Create a handler to link a new mobile device, i.e. a device capable of scanning QrCodes. + */ + fun createLinkMobileHandler(): Result + + /** + * Create a handler to link a new desktop device, i.e. a device not capable of scanning QrCodes. + */ + fun createLinkDesktopHandler(): Result + + /** + * Performs a database optimization that should flush cached data and improve performance. + */ + suspend fun performDatabaseVacuum(): Result + + /** + * Resets the cached client `well-known` config by the SDK. + */ + suspend fun resetWellKnownConfig(): Result } /** diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/analytics/GetDatabaseSizesUseCase.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/analytics/GetDatabaseSizesUseCase.kt new file mode 100644 index 00000000000..ab113741166 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/analytics/GetDatabaseSizesUseCase.kt @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2025 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.analytics + +import io.element.android.libraries.matrix.api.core.SessionId + +fun interface GetDatabaseSizesUseCase { + suspend operator fun invoke(sessionId: SessionId): Result +} 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 new file mode 100644 index 00000000000..62b68b8fdb9 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/analytics/SdkStoreSizes.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 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.analytics + +import io.element.android.libraries.core.data.ByteSize + +/** + * The sizes of the different stores (DBs) in the SDK. + */ +data class SdkStoreSizes( + val stateStore: ByteSize?, + val eventCacheStore: ByteSize?, + val mediaStore: ByteSize?, + val cryptoStore: ByteSize?, +) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/SessionRestorationException.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/SessionRestorationException.kt new file mode 100644 index 00000000000..bf6f50cab5f --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/auth/SessionRestorationException.kt @@ -0,0 +1,15 @@ +/* + * Copyright (c) 2025 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.auth + +import io.element.android.libraries.matrix.api.core.SessionId + +sealed class SessionRestorationException(message: String, cause: Throwable? = null) : Exception(message, cause) { + data class MissingSession(val sessionId: SessionId) : SessionRestorationException("Session with id $sessionId not found") + class InvalidToken : SessionRestorationException("Access token is invalid or expired") +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/createroom/CreateRoomParameters.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/createroom/CreateRoomParameters.kt index f89d3131743..2fd51d60f9c 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/createroom/CreateRoomParameters.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/createroom/CreateRoomParameters.kt @@ -27,4 +27,5 @@ data class CreateRoomParameters( val joinRuleOverride: JoinRule? = null, val historyVisibilityOverride: RoomHistoryVisibility? = null, val roomAliasName: Optional = Optional.empty(), + val isSpace: Boolean = false, ) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/exception/ClientException.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/exception/ClientException.kt index 35acec43cae..52b1577bf0f 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/exception/ClientException.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/exception/ClientException.kt @@ -8,10 +8,14 @@ package io.element.android.libraries.matrix.api.exception -sealed class ClientException(message: String, val details: String?) : Exception(message) { - class Generic(message: String, details: String?) : ClientException(message, details) - class MatrixApi(val kind: ErrorKind, val code: String, message: String, details: String?) : ClientException(message, details) - class Other(message: String) : ClientException(message, null) +sealed class ClientException(message: String, val details: String?, cause: Throwable? = null) : Exception(message, cause) { + class Generic(message: String, details: String?, cause: Throwable? = null) : ClientException(message, details, cause) + class MatrixApi(val kind: ErrorKind, val code: String, message: String, details: String?, cause: Throwable? = null) : ClientException( + message = message, + details = details, + cause = cause + ) + class Other(message: String, cause: Throwable? = null) : ClientException(message, null, cause) } fun ClientException.isNetworkError(): Boolean { diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/linknewdevice/CheckCodeSender.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/linknewdevice/CheckCodeSender.kt new file mode 100644 index 00000000000..ecb440de833 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/linknewdevice/CheckCodeSender.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.linknewdevice + +interface CheckCodeSender { + /** + * Validates the given [code]. Returns true if the code is valid, false otherwise. + * This method can be called multiple times to validate different codes. + */ + suspend fun validate(code: UByte): Boolean + + /** + * Sends the given [code]. + * This method can be called only once. + */ + suspend fun send(code: UByte): Result +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/linknewdevice/ErrorType.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/linknewdevice/ErrorType.kt new file mode 100644 index 00000000000..0f61007d47b --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/linknewdevice/ErrorType.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.linknewdevice + +sealed class ErrorType(message: String) : Exception(message) { + /** + * The requested device ID is already in use. + */ + class DeviceIdAlreadyInUse(message: String) : ErrorType(message) + + /** + * The check code was incorrect. + */ + class InvalidCheckCode(message: String) : ErrorType(message) + + /** + * The other client proposed an unsupported protocol. + */ + class UnsupportedProtocol(message: String) : ErrorType(message) + + /** + * Secrets backup not set up properly. + */ + class MissingSecretsBackup(message: String) : ErrorType(message) + + /** + * The rendezvous session was not found and might have expired. + */ + class NotFound(message: String) : ErrorType(message) + + /** + * The device could not be created. + */ + class UnableToCreateDevice(message: String) : ErrorType(message) + + /** + * An unknown error has happened. + */ + class Unknown(message: String) : ErrorType(message) +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/linknewdevice/LinkDesktopHandler.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/linknewdevice/LinkDesktopHandler.kt new file mode 100644 index 00000000000..d600c1a8bf4 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/linknewdevice/LinkDesktopHandler.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.linknewdevice + +import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeDecodeException +import kotlinx.coroutines.flow.StateFlow + +interface LinkDesktopHandler { + val linkDesktopStep: StateFlow + suspend fun handleScannedQrCode(data: ByteArray) +} + +sealed interface LinkDesktopStep { + data object Uninitialized : LinkDesktopStep + data object Starting : LinkDesktopStep + data class WaitingForAuth( + val verificationUri: String, + ) : LinkDesktopStep + + data class EstablishingSecureChannel( + val checkCode: UByte, + val checkCodeString: String, + ) : LinkDesktopStep + + data class InvalidQrCode( + val error: QrCodeDecodeException, + ) : LinkDesktopStep + + data class Error( + val errorType: ErrorType, + ) : LinkDesktopStep + + data object SyncingSecrets : LinkDesktopStep + + data object Done : LinkDesktopStep +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/linknewdevice/LinkMobileHandler.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/linknewdevice/LinkMobileHandler.kt new file mode 100644 index 00000000000..0c261cdd1aa --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/linknewdevice/LinkMobileHandler.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.api.linknewdevice + +import kotlinx.coroutines.flow.Flow + +interface LinkMobileHandler { + val linkMobileStep: Flow + suspend fun start() +} + +sealed interface LinkMobileStep { + data object Uninitialized : LinkMobileStep + data object Starting : LinkMobileStep + data class QrReady(val data: String) : LinkMobileStep + data class WaitingForAuth(val verificationUri: String) : LinkMobileStep + data class QrScanned(val checkCodeSender: CheckCodeSender) : LinkMobileStep + data class Error(val errorType: ErrorType) : LinkMobileStep + data object SyncingSecrets : LinkMobileStep + data object Done : LinkMobileStep +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/logs/LoggerTags.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/logs/LoggerTags.kt new file mode 100644 index 00000000000..7f19b2d3db3 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/logs/LoggerTags.kt @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2025 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.logs + +import io.element.android.libraries.core.log.logger.LoggerTag + +object LoggerTags { + val linkNewDevice = LoggerTag("LinkNewDevice") +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notification/NotificationData.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notification/NotificationData.kt index e02e601f3b1..6f562f1b1a8 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notification/NotificationData.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/notification/NotificationData.kt @@ -30,6 +30,7 @@ data class NotificationData( val roomDisplayName: String?, val isDirect: Boolean, val isDm: Boolean, + val isSpace: Boolean, val isEncrypted: Boolean, val isNoisy: Boolean, val timestamp: Long, diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/BaseRoom.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/BaseRoom.kt index 41e6ce0e623..481171ace0a 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/BaseRoom.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/BaseRoom.kt @@ -14,6 +14,7 @@ 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.UserId import io.element.android.libraries.matrix.api.room.draft.ComposerDraft +import io.element.android.libraries.matrix.api.room.powerlevels.RoomPermissions import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevelsValues import io.element.android.libraries.matrix.api.room.tombstone.PredecessorRoom import io.element.android.libraries.matrix.api.roomdirectory.RoomVisibility @@ -99,6 +100,11 @@ interface BaseRoom : Closeable { */ suspend fun userRole(userId: UserId): Result + /** + * Gets the permissions of the room. + */ + suspend fun roomPermissions(): Result + /** * Gets the display name of the user with the provided [userId] in the room. */ @@ -124,57 +130,6 @@ interface BaseRoom : Closeable { */ suspend fun forget(): Result - /** - * Returns `true` if the user with the provided [userId] can invite other users to the room. - */ - suspend fun canUserInvite(userId: UserId): Result - - /** - * Returns `true` if the user with the provided [userId] can kick other users from the room. - */ - suspend fun canUserKick(userId: UserId): Result - - /** - * Returns `true` if the user with the provided [userId] can ban other users from the room. - */ - suspend fun canUserBan(userId: UserId): Result - - /** - * Returns `true` if the user with the provided [userId] can redact their own messages. - */ - suspend fun canUserRedactOwn(userId: UserId): Result - - /** - * Returns `true` if the user with the provided [userId] can redact messages from other users. - */ - suspend fun canUserRedactOther(userId: UserId): Result - - /** - * Returns `true` if the user with the provided [userId] can send state events. - */ - suspend fun canUserSendState(userId: UserId, type: StateEventType): Result - - /** - * Returns `true` if the user with the provided [userId] can send messages. - */ - suspend fun canUserSendMessage(userId: UserId, type: MessageEventType): Result - - /** - * Returns `true` if the user with the provided [userId] can trigger an `@room` notification. - */ - suspend fun canUserTriggerRoomNotification(userId: UserId): Result - - /** - * Returns `true` if the user with the provided [userId] can pin or unpin messages. - */ - suspend fun canUserPinUnpin(userId: UserId): Result - - /** - * Returns `true` if the user with the provided [userId] can join or starts calls. - */ - suspend fun canUserJoinCall(userId: UserId): Result = - canUserSendState(userId, StateEventType.CALL_MEMBER) - /** * Sets the room as favorite or not, based on the [isFavorite] parameter. */ diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/JoinedRoom.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/JoinedRoom.kt index 08149a64f20..418c40c8522 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/JoinedRoom.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/JoinedRoom.kt @@ -68,6 +68,7 @@ interface JoinedRoom : BaseRoom { suspend fun updateRoomNotificationSettings(): Result + // TCHAP access rule suspend fun setAccessRule(rule: RoomAccessRules): Result /** @@ -180,4 +181,9 @@ interface JoinedRoom : BaseRoom { * */ suspend fun withdrawVerificationAndResend(userIds: List, sendHandle: SendHandle): Result + + /** + * Subscribe to a [Flow] of [SendQueueUpdate] related to this room. + */ + fun subscribeToSendQueueUpdates(): Flow } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MessageEventType.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MessageEventType.kt index adf8ebfed11..1341378b907 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MessageEventType.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MessageEventType.kt @@ -12,10 +12,21 @@ import androidx.compose.runtime.Immutable @Immutable sealed interface MessageEventType { + data object Audio : MessageEventType + data object Beacon : MessageEventType data object CallAnswer : MessageEventType + data object CallCandidates : MessageEventType data object CallInvite : MessageEventType data object CallHangup : MessageEventType - data object CallCandidates : MessageEventType + data object CallNegotiate : MessageEventType + data object CallNotify : MessageEventType + data object CallReject : MessageEventType + data object CallSdpStreamMetadataChanged : MessageEventType + data object CallSelectAnswer : MessageEventType + data object Emote : MessageEventType + data object Encrypted : MessageEventType + data object File : MessageEventType + data object Image : MessageEventType data object RtcNotification : MessageEventType data object KeyVerificationReady : MessageEventType data object KeyVerificationStart : MessageEventType @@ -24,10 +35,13 @@ sealed interface MessageEventType { data object KeyVerificationKey : MessageEventType data object KeyVerificationMac : MessageEventType data object KeyVerificationDone : MessageEventType + data object Location : MessageEventType + data object Message : MessageEventType data object Reaction : MessageEventType data object RoomEncrypted : MessageEventType data object RoomMessage : MessageEventType data object RoomRedaction : MessageEventType + data object RtcDecline : MessageEventType data object Sticker : MessageEventType data object PollEnd : MessageEventType data object PollResponse : MessageEventType @@ -35,5 +49,7 @@ sealed interface MessageEventType { data object UnstablePollEnd : MessageEventType data object UnstablePollResponse : MessageEventType data object UnstablePollStart : MessageEventType + data object Video : MessageEventType + data object Voice : MessageEventType data class Other(val type: String) : MessageEventType } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomInfo.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomInfo.kt index 3e5fe3c6a51..0b86759d97c 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomInfo.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomInfo.kt @@ -81,15 +81,4 @@ data class RoomInfo( ) { val aliases: List get() = listOfNotNull(canonicalAlias) + alternativeAliases - - /** - * Returns the list of users with the given [role] in this room. - */ - fun usersWithRole(role: RoomMember.Role): List { - return if (role is RoomMember.Role.Owner && role.isCreator) { - this.creators - } else { - this.roomPowerLevels?.usersWithRole(role).orEmpty().toList() - } - } } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomMember.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomMember.kt index b76b028bd45..1dfeee48578 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomMember.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/RoomMember.kt @@ -99,6 +99,6 @@ fun RoomMember.getBestName(): String { fun RoomMember.toMatrixUser() = MatrixUser( userId = userId, - displayName = displayName, - avatarUrl = avatarUrl, + displayName = displayName.takeUnless { membership == RoomMembershipState.BAN }, + avatarUrl = avatarUrl.takeUnless { membership == RoomMembershipState.BAN }, ) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/SendQueueUpdate.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/SendQueueUpdate.kt new file mode 100644 index 00000000000..41e1e5643b3 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/SendQueueUpdate.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2025 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.room + +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.TransactionId +import io.element.android.libraries.matrix.api.media.MediaSource + +sealed interface SendQueueUpdate { + data class NewLocalEvent(val transactionId: TransactionId) : SendQueueUpdate + data class CancelledLocalEvent(val transactionId: TransactionId) : SendQueueUpdate + data class ReplacedLocalEvent(val transactionId: TransactionId) : SendQueueUpdate + data class SendError(val transactionId: TransactionId) : SendQueueUpdate + data class RetrySendingEvent(val transactionId: TransactionId) : SendQueueUpdate + data class SentEvent(val transactionId: TransactionId, val eventId: EventId) : SendQueueUpdate + data class MediaUpload(val relatedTo: EventId, val file: MediaSource?, val index: Long, val progress: Float) : SendQueueUpdate +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/StateEventType.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/StateEventType.kt index 452d934212a..41d64afff15 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/StateEventType.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/StateEventType.kt @@ -8,27 +8,33 @@ package io.element.android.libraries.matrix.api.room -enum class StateEventType { - POLICY_RULE_ROOM, - POLICY_RULE_SERVER, - POLICY_RULE_USER, - CALL_MEMBER, - ROOM_ALIASES, - ROOM_AVATAR, - ROOM_CANONICAL_ALIAS, - ROOM_CREATE, - ROOM_ENCRYPTION, - ROOM_GUEST_ACCESS, - ROOM_HISTORY_VISIBILITY, - ROOM_JOIN_RULES, - ROOM_MEMBER_EVENT, - ROOM_NAME, - ROOM_PINNED_EVENTS, - ROOM_POWER_LEVELS, - ROOM_SERVER_ACL, - ROOM_THIRD_PARTY_INVITE, - ROOM_TOMBSTONE, - ROOM_TOPIC, - SPACE_CHILD, - SPACE_PARENT +sealed interface StateEventType { + data object PolicyRuleRoom : StateEventType + data object PolicyRuleServer : StateEventType + data object PolicyRuleUser : StateEventType + data object CallMember : StateEventType + data object RoomAliases : StateEventType + data object RoomAvatar : StateEventType + data object RoomCanonicalAlias : StateEventType + data object RoomCreate : StateEventType + data object RoomEncryption : StateEventType + data object RoomGuestAccess : StateEventType + data object RoomHistoryVisibility : StateEventType + data object RoomJoinRules : StateEventType + data object RoomMemberEvent : StateEventType + data object RoomName : StateEventType + data object RoomPinnedEvents : StateEventType + data object RoomPowerLevels : StateEventType + data object RoomServerAcl : StateEventType + data object RoomThirdPartyInvite : StateEventType + data object RoomTombstone : StateEventType + data object RoomTopic : StateEventType + data object SpaceChild : StateEventType + data object SpaceParent : StateEventType + data object BeaconInfo : StateEventType + data object MemberHints : StateEventType + data object RoomImagePack : StateEventType + data object RoomLanguage : StateEventType + + data class Custom(val type: String) : StateEventType } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/powerlevels/MatrixRoomMembersWithRole.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/powerlevels/RoomMembersWithRole.kt similarity index 62% rename from libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/powerlevels/MatrixRoomMembersWithRole.kt rename to libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/powerlevels/RoomMembersWithRole.kt index 39e5dafae96..4981de3b1a4 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/powerlevels/MatrixRoomMembersWithRole.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/powerlevels/RoomMembersWithRole.kt @@ -15,17 +15,16 @@ import io.element.android.libraries.matrix.api.room.activeRoomMembers import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onStart /** - * Return a flow of the list of active room members who have the given role. + * Return a flow of the list of active room members who match the predicate. */ -fun BaseRoom.usersWithRole(role: RoomMember.Role): Flow> { - // Ensure the room members flow is ready +fun BaseRoom.usersWithRole(predicate: (RoomMember.Role) -> Boolean): Flow> { + // Wait until members are ready to avoid returning empty lists initially val readyMembersFlow = membersStateFlow .onStart { if (membersStateFlow.value is RoomMembersState.Unknown) { @@ -34,12 +33,17 @@ fun BaseRoom.usersWithRole(role: RoomMember.Role): Flow roomInfo.usersWithRole(role) } - .combine(readyMembersFlow) { powerLevels, membersState -> - membersState.activeRoomMembers() - .filter { powerLevels.contains(it.userId) } - .toImmutableList() - } - .distinctUntilChanged() + return readyMembersFlow.map { membersState -> + membersState + .activeRoomMembers() + .filter { predicate(it.role) } + .toImmutableList() + }.distinctUntilChanged() +} + +/** + * Return the number of active room members who match the predicate. + */ +fun BaseRoom.userCountWithRole(predicate: (RoomMember.Role) -> Boolean): Flow { + return usersWithRole(predicate).map { it.size } } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/powerlevels/RoomPermissions.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/powerlevels/RoomPermissions.kt new file mode 100644 index 00000000000..a12f7d96063 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/powerlevels/RoomPermissions.kt @@ -0,0 +1,183 @@ +/* + * Copyright (c) 2025 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.room.powerlevels + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.State +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.remember +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.room.BaseRoom +import io.element.android.libraries.matrix.api.room.MessageEventType +import io.element.android.libraries.matrix.api.room.StateEventType +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map + +/** + * Provides information about the permissions of users in a room. + */ +interface RoomPermissions : AutoCloseable { + /** + * Returns true if the current user is able to ban from the room. + */ + fun canOwnUserBan(): Boolean + + /** + * Returns true if the current user is able to invite in the room. + */ + fun canOwnUserInvite(): Boolean + + /** + * Returns true if the current user is able to kick from the room. + */ + fun canOwnUserKick(): Boolean + + /** + * Returns true if the current user is able to pin or unpin events in the + * room. + */ + fun canOwnUserPinUnpin(): Boolean + + /** + * Returns true if the current user user is able to redact messages of + * other users in the room. + */ + fun canOwnUserRedactOther(): Boolean + + /** + * Returns true if the current user is able to redact their own messages in + * the room. + */ + fun canOwnUserRedactOwn(): Boolean + + /** + * Returns true if the current user is able to send a specific message type + * in the room. + */ + fun canOwnUserSendMessage(message: MessageEventType): Boolean + + /** + * Returns true if the current user is able to send a specific state event + * type in the room. + */ + fun canOwnUserSendState(stateEvent: StateEventType): Boolean + + /** + * Returns true if the current user is able to trigger a notification in + * the room. + */ + fun canOwnUserTriggerRoomNotification(): Boolean + + /** + * Returns true if the user with the given userId is able to ban in the + * room. + */ + fun canUserBan(userId: UserId): Boolean + + /** + * Returns true if the user with the given userId is able to invite in the + * room. + */ + fun canUserInvite(userId: UserId): Boolean + + /** + * Returns true if the user with the given userId is able to kick in the + * room. + */ + fun canUserKick(userId: UserId): Boolean + + /** + * Returns true if the user with the given userId is able to pin or unpin + * events in the room. + */ + fun canUserPinUnpin(userId: UserId): Boolean + + /** + * Returns true if the user with the given userId is able to redact + * messages of other users in the room. + */ + fun canUserRedactOther(userId: UserId): Boolean + + /** + * Returns true if the user with the given userId is able to redact + * their own messages in the room. + */ + fun canUserRedactOwn(userId: UserId): Boolean + + /** + * Returns true if the user with the given userId is able to send a + * specific message type in the room. + */ + fun canUserSendMessage(userId: UserId, message: MessageEventType): Boolean + + /** + * Returns true if the user with the given userId is able to send a + * specific state event type in the room. + */ + fun canUserSendState(userId: UserId, stateEvent: StateEventType): Boolean + + /** + * Returns true if the user with the given userId is able to trigger a + * notification in the room. + * + * The call may fail if there is an error in getting the power levels. + */ + fun canUserTriggerRoomNotification(userId: UserId): Boolean +} + +/** + * Returns true if the current user can edit roles and permissions in the room ie. can send + * a power levels state event. + */ +fun RoomPermissions.canEditRolesAndPermissions(): Boolean { + return canOwnUserSendState(StateEventType.RoomPowerLevels) +} + +/** + * Returns true if the current user can start a call in the room ie. can send + * a call member state event. + */ +fun RoomPermissions.canCall(): Boolean { + return canOwnUserSendState(StateEventType.CallMember) +} + +fun Result.use(default: T, block: (RoomPermissions) -> T): T { + return fold( + onSuccess = { perms -> + perms.use(block) + }, + onFailure = { + default + } + ) +} + +fun BaseRoom.permissionsFlow(default: T, block: (RoomPermissions) -> T): Flow { + return roomInfoFlow + .map { info -> + // If the user is a privileged creator, we return a constant hashcode to avoid recomputing permissions + // each time the power levels change (as they have all permissions). + if (info.privilegedCreatorRole && info.creators.contains(sessionId)) { + Long.MAX_VALUE + } else { + info.roomPowerLevels?.hashCode() ?: 0L + } + } + .distinctUntilChanged() + .map { + roomPermissions().use(default, block) + } +} + +@Composable +fun BaseRoom.permissionsAsState(default: T, block: (RoomPermissions) -> T): State { + return remember(this, default, block) { + permissionsFlow(default, block) + }.collectAsState(default) +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/powerlevels/RoomPowerLevels.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/powerlevels/RoomPowerLevels.kt index 19db749e675..31019878d97 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/powerlevels/RoomPowerLevels.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/powerlevels/RoomPowerLevels.kt @@ -35,19 +35,6 @@ data class RoomPowerLevels( return users[userId] ?: 0L } - /** - * Returns the set of [UserId]s that have the given role in the room. - * - * **WARNING**: This method must not be used with a creator role. It'll result in a runtime error. - */ - fun usersWithRole(role: RoomMember.Role): Set { - return if (role is RoomMember.Role.Owner && role.isCreator) { - error("RoomPowerLevels.usersWithRole should not be used with a creator role, use roomInfo.creators instead") - } else { - users.filterValues { RoomMember.Role.forPowerLevel(it) == role }.keys - } - } - /** * Returns the role of the user in the room based on their power level. * If the user is not found, returns null. diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/powerlevels/RoomPowerLevelsValues.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/powerlevels/RoomPowerLevelsValues.kt index d20b6141eb9..e8f88ed86de 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/powerlevels/RoomPowerLevelsValues.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/powerlevels/RoomPowerLevelsValues.kt @@ -8,66 +8,15 @@ package io.element.android.libraries.matrix.api.room.powerlevels -import io.element.android.libraries.core.extensions.runCatchingExceptions -import io.element.android.libraries.matrix.api.room.BaseRoom -import io.element.android.libraries.matrix.api.room.MessageEventType -import io.element.android.libraries.matrix.api.room.StateEventType - data class RoomPowerLevelsValues( val ban: Long, val invite: Long, val kick: Long, - val sendEvents: Long, + val eventsDefault: Long, + val stateDefault: Long, val redactEvents: Long, val roomName: Long, val roomAvatar: Long, val roomTopic: Long, val spaceChild: Long, ) - -/** - * Shortcut for calling [BaseRoom.canUserInvite] with our own user. - */ -suspend fun BaseRoom.canInvite(): Result = canUserInvite(sessionId) - -/** - * Shortcut for calling [BaseRoom.canUserKick] with our own user. - */ -suspend fun BaseRoom.canKick(): Result = canUserKick(sessionId) - -/** - * Shortcut for calling [BaseRoom.canUserBan] with our own user. - */ -suspend fun BaseRoom.canBan(): Result = canUserBan(sessionId) - -/** - * Shortcut for calling [BaseRoom.canUserSendState] with our own user. - */ -suspend fun BaseRoom.canSendState(type: StateEventType): Result = canUserSendState(sessionId, type) - -/** - * Shortcut for calling [BaseRoom.canUserSendMessage] with our own user. - */ -suspend fun BaseRoom.canSendMessage(type: MessageEventType): Result = canUserSendMessage(sessionId, type) - -/** - * Shortcut for calling [BaseRoom.canUserRedactOwn] with our own user. - */ -suspend fun BaseRoom.canRedactOwn(): Result = canUserRedactOwn(sessionId) - -/** - * Shortcut for calling [BaseRoom.canRedactOther] with our own user. - */ -suspend fun BaseRoom.canRedactOther(): Result = canUserRedactOther(sessionId) - -/** - * Shortcut for checking if current user can handle knock requests. - */ -suspend fun BaseRoom.canHandleKnockRequests(): Result = runCatchingExceptions { - canInvite().getOrThrow() || canBan().getOrThrow() || canKick().getOrThrow() -} - -/** - * Shortcut for calling [BaseRoom.canUserPinUnpin] with our own user. - */ -suspend fun BaseRoom.canPinUnpin(): Result = canUserPinUnpin(sessionId) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/RoomList.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/RoomList.kt index d03a9677e39..a0d092596a8 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/RoomList.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/roomlist/RoomList.kt @@ -65,7 +65,7 @@ suspend fun RoomList.awaitLoaded(timeout: Duration = Duration.INFINITE) { it is RoomList.LoadingState.Loaded } } - } catch (timeoutException: TimeoutCancellationException) { + } catch (_: TimeoutCancellationException) { Timber.d("awaitAllRoomsAreLoaded: no response after $timeout") } } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceService.kt index 6f5ba674ece..927bec13e17 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceService.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/spaces/SpaceService.kt @@ -15,7 +15,19 @@ interface SpaceService { val spaceRoomsFlow: SharedFlow> suspend fun joinedSpaces(): Result> + suspend fun joinedParents(spaceId: RoomId): Result> + + suspend fun getSpaceRoom(spaceId: RoomId): SpaceRoom? + fun spaceRoomList(id: RoomId): SpaceRoomList fun getLeaveSpaceHandle(spaceId: RoomId): LeaveSpaceHandle + + /** + * Remove a child room from a space. + * @param spaceId The space ID from which to remove the child. + * @param childId The room ID of the child to remove. + * @return A result indicating success or failure. + */ + suspend fun removeChildFromSpace(spaceId: RoomId, childId: RoomId): Result } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventContent.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventContent.kt index c6272e8f529..b6ed7dc6023 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventContent.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventContent.kt @@ -26,7 +26,7 @@ data class MessageContent( val inReplyTo: InReplyTo?, val isEdited: Boolean, val threadInfo: EventThreadInfo?, - val type: MessageType + val type: MessageType, ) : EventContent data object RedactedContent : EventContent @@ -36,6 +36,7 @@ data class StickerContent( val body: String?, val info: ImageInfo, val source: MediaSource, + val threadInfo: EventThreadInfo?, ) : EventContent { val bestDescription: String get() = body ?: filename @@ -49,10 +50,12 @@ data class PollContent( val votes: ImmutableMap>, val endTime: ULong?, val isEdited: Boolean, + val threadInfo: EventThreadInfo?, ) : EventContent data class UnableToDecryptContent( - val data: Data + val data: Data, + val threadInfo: EventThreadInfo?, ) : EventContent { @Immutable sealed interface Data { 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 8294b78c249..401240f9279 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 @@ -39,7 +39,13 @@ data class EventTimelineItem( return (content as? MessageContent)?.inReplyTo } - fun threadInfo(): EventThreadInfo? = (content as? MessageContent)?.threadInfo + fun threadInfo(): EventThreadInfo? = when (content) { + is MessageContent -> content.threadInfo + is PollContent -> content.threadInfo + is StickerContent -> content.threadInfo + is UnableToDecryptContent -> content.threadInfo + else -> null + } fun hasNotLoadedInReplyTo(): Boolean { val details = inReplyTo() diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/tracing/TraceLogPack.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/tracing/TraceLogPack.kt index 4f71f7309c3..a0e5050a238 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/tracing/TraceLogPack.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/tracing/TraceLogPack.kt @@ -20,6 +20,12 @@ enum class TraceLogPack(val key: String) { }, NOTIFICATION_CLIENT("notification_client") { override val title: String = "Notification Client" + }, + SYNC_PROFILING("sync_profiling") { + override val title: String = "Sync Profiling" + }, + LATEST_EVENTS("latest_events") { + override val title = "Latest Events" }; abstract val title: String diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/tracing/TracingConfiguration.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/tracing/TracingConfiguration.kt index 45d6e7e46e0..569683d3f73 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/tracing/TracingConfiguration.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/tracing/TracingConfiguration.kt @@ -14,4 +14,5 @@ data class TracingConfiguration( val traceLogPacks: Set, val writesToLogcat: Boolean, val writesToFilesConfiguration: WriteToFilesConfiguration, + val sdkSentryDsn: String?, ) diff --git a/libraries/matrix/impl/build.gradle.kts b/libraries/matrix/impl/build.gradle.kts index 88552bd1ada..3d3278519e0 100644 --- a/libraries/matrix/impl/build.gradle.kts +++ b/libraries/matrix/impl/build.gradle.kts @@ -47,6 +47,7 @@ dependencies { implementation(projects.libraries.featureflag.api) implementation(projects.libraries.network) implementation(projects.libraries.preferences.api) + implementation(projects.libraries.workmanager.api) implementation(projects.services.analytics.api) implementation(projects.services.toolbox.api) api(projects.libraries.matrix.api) @@ -62,6 +63,7 @@ dependencies { testImplementation(projects.libraries.preferences.test) testImplementation(projects.libraries.previewutils) testImplementation(projects.libraries.sessionStorage.test) + testImplementation(projects.libraries.workmanager.test) testImplementation(projects.services.analytics.test) testImplementation(projects.services.toolbox.test) } 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 03f17330505..25ee77f1372 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 @@ -12,11 +12,13 @@ import io.element.android.libraries.androidutils.file.getSizeOfFiles import io.element.android.libraries.core.bool.orFalse import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.coroutine.childScope +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.featureflag.api.FeatureFlagService import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.analytics.SdkStoreSizes import io.element.android.libraries.matrix.api.core.DeviceId import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomAlias @@ -26,6 +28,8 @@ import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.createroom.CreateRoomParameters import io.element.android.libraries.matrix.api.createroom.RoomAccessRules 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.oidc.AccountManagementAction import io.element.android.libraries.matrix.api.room.BaseRoom @@ -46,6 +50,9 @@ import io.element.android.libraries.matrix.api.user.MatrixSearchUserResults import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.impl.encryption.RustEncryptionService import io.element.android.libraries.matrix.impl.exception.mapClientException +import io.element.android.libraries.matrix.impl.linknewdevice.RustLinkDesktopHandler +import io.element.android.libraries.matrix.impl.linknewdevice.RustLinkMobileHandler +import io.element.android.libraries.matrix.impl.linknewdevice.RustQrCodeDataParser import io.element.android.libraries.matrix.impl.mapper.map import io.element.android.libraries.matrix.impl.media.RustMediaLoader import io.element.android.libraries.matrix.impl.media.RustMediaPreviewService @@ -76,7 +83,10 @@ import io.element.android.libraries.matrix.impl.util.SessionPathsProvider import io.element.android.libraries.matrix.impl.util.cancelAndDestroy import io.element.android.libraries.matrix.impl.util.mxCallbackFlow import io.element.android.libraries.matrix.impl.verification.RustSessionVerificationService +import io.element.android.libraries.matrix.impl.workmanager.PerformDatabaseVacuumWorkManagerRequest import io.element.android.libraries.sessionstorage.api.SessionStore +import io.element.android.libraries.workmanager.api.WorkManagerRequestType +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.collections.immutable.ImmutableList @@ -135,6 +145,7 @@ class RustMatrixClient( timelineEventTypeFilterFactory: TimelineEventTypeFilterFactory, private val featureFlagService: FeatureFlagService, private val analyticsService: AnalyticsService, + private val workManagerScheduler: WorkManagerScheduler, ) : MatrixClient { override val sessionId: UserId = UserId(innerClient.userId()) override val deviceId: DeviceId = DeviceId(innerClient.deviceId()) @@ -142,7 +153,9 @@ class RustMatrixClient( private val sessionDispatcher = dispatchers.io.limitedParallelism(64) private val innerRoomListService = innerSyncService.roomListService() - private val innerSpaceService = innerClient.spaceService() + + // TODO refactor this and `innerNotificationClient` to be behind a suspend function instead + private val innerSpaceService = runBlocking { innerClient.spaceService() } override val roomMembershipObserver = RoomMembershipObserver() @@ -192,6 +205,7 @@ class RustMatrixClient( roomMembershipObserver = roomMembershipObserver, sessionCoroutineScope = sessionCoroutineScope, sessionDispatcher = sessionDispatcher, + analyticsService = analyticsService, ) override val sessionVerificationService = RustSessionVerificationService( @@ -278,6 +292,9 @@ class RustMatrixClient( // Force a refresh of the profile getUserProfile() } + + // Schedule regular database vacuuming to ensure DB performance remains optimal + scheduleDatabaseVacuum() } override fun userIdServerName(): String { @@ -385,6 +402,7 @@ class RustMatrixClient( joinRuleOverride = createRoomParams.joinRuleOverride?.map(), historyVisibilityOverride = createRoomParams.historyVisibilityOverride?.map(), canonicalAlias = createRoomParams.roomAliasName.getOrNull(), + isSpace = createRoomParams.isSpace, ) val roomId = RoomId(innerClient.createRoom(rustParams, isFederated = true)) // TODO fix the federated value // Wait to receive the room back from the sync but do not returns failure if it fails. @@ -567,6 +585,17 @@ class RustMatrixClient( return getCacheSize(includeCryptoDb = false) } + override suspend fun getDatabaseSizes(): Result = runCatchingExceptions { + innerClient.getStoreSizes().run { + SdkStoreSizes( + stateStore = stateStore?.bytes, + eventCacheStore = eventCacheStore?.bytes, + mediaStore = mediaStore?.bytes, + cryptoStore = cryptoStore?.bytes, + ) + } + } + override suspend fun clearCache() { innerClient.clearCaches(innerSyncService) destroy() @@ -727,6 +756,35 @@ class RustMatrixClient( } } + override suspend fun canLinkNewDevice(): Result = withContext(sessionDispatcher) { + runCatchingExceptions { + innerClient.isLoginWithQrCodeSupported() + } + } + + override fun createLinkMobileHandler(): Result { + return runCatchingExceptions { + val handler = innerClient.newGrantLoginWithQrCodeHandler() + RustLinkMobileHandler( + inner = handler, + sessionCoroutineScope = sessionCoroutineScope, + sessionDispatcher = sessionDispatcher, + ) + } + } + + override fun createLinkDesktopHandler(): Result { + return runCatchingExceptions { + val handler = innerClient.newGrantLoginWithQrCodeHandler() + RustLinkDesktopHandler( + inner = handler, + sessionCoroutineScope = sessionCoroutineScope, + sessionDispatcher = sessionDispatcher, + qrCodeDataParser = RustQrCodeDataParser(), + ) + } + } + override suspend fun markRoomAsFullyRead(roomId: RoomId, eventId: EventId): Result = withContext(sessionDispatcher) { runCatchingExceptions { val room = innerClient.getRoom(roomId.value) ?: error("Could not fetch associated room") @@ -734,6 +792,20 @@ class RustMatrixClient( } } + override suspend fun performDatabaseVacuum(): Result = withContext(sessionDispatcher) { + runCatchingExceptions { + Timber.d("Performing database vacuuming for session $sessionId...") + innerClient.optimizeStores() + } + } + + override suspend fun resetWellKnownConfig(): Result { + return runCatchingExceptions { + Timber.d("Resetting well-known config for session $sessionId") + innerClient.resetWellKnown() + } + } + private suspend fun getCacheSize( includeCryptoDb: Boolean = false, ): Long = withContext(sessionDispatcher) { @@ -758,6 +830,15 @@ class RustMatrixClient( // Delete all the files for this session sessionPathsProvider.provides(sessionId)?.deleteRecursively() } + + private fun scheduleDatabaseVacuum() { + // If there's already a periodic work request, do not schedule another one + if (workManagerScheduler.hasPendingWork(sessionId, WorkManagerRequestType.DB_VACUUM)) return + + Timber.i("Scheduling periodic database vacuuming for session $sessionId") + val request = PerformDatabaseVacuumWorkManagerRequest(sessionId) + workManagerScheduler.submit(request) + } } private val defaultRoomCreationPowerLevels = 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 19b96168e0e..b7733b3e895 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 @@ -26,10 +26,12 @@ import io.element.android.libraries.matrix.impl.paths.SessionPaths 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.TimelineEventTypeFilterFactory +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 @@ -40,7 +42,6 @@ import org.matrix.rustcomponents.sdk.RequestConfig import org.matrix.rustcomponents.sdk.Session import org.matrix.rustcomponents.sdk.SlidingSyncVersion import org.matrix.rustcomponents.sdk.SlidingSyncVersionBuilder -import org.matrix.rustcomponents.sdk.SqliteStoreBuilder import org.matrix.rustcomponents.sdk.use import timber.log.Timber import uniffi.matrix_sdk_base.MediaRetentionPolicy @@ -67,6 +68,8 @@ class RustMatrixClientFactory( private val featureFlagService: FeatureFlagService, private val timelineEventTypeFilterFactory: TimelineEventTypeFilterFactory, private val clientBuilderProvider: ClientBuilderProvider, + private val sqliteStoreBuilderProvider: SqliteStoreBuilderProvider, + private val workManagerScheduler: WorkManagerScheduler, ) { private val sessionDelegate = RustClientSessionDelegate(sessionStore, appCoroutineScope, coroutineDispatchers) @@ -83,9 +86,9 @@ class RustMatrixClientFactory( client.setMediaRetentionPolicy( MediaRetentionPolicy( // Make this 500MB instead of 400MB - maxCacheSize = 500.megaBytes.to(ByteUnit.BYTES).toULong(), + maxCacheSize = 500.megaBytes.into(ByteUnit.BYTES).toULong(), // This is the default value, but let's make it explicit - maxFileSize = 20.megaBytes.to(ByteUnit.BYTES).toULong(), + maxFileSize = 20.megaBytes.into(ByteUnit.BYTES).toULong(), // Use 30 days instead of 60 lastAccessExpiry = 30.days.toJavaDuration(), // This is the default value, but let's make it explicit @@ -120,6 +123,7 @@ class RustMatrixClientFactory( timelineEventTypeFilterFactory = timelineEventTypeFilterFactory, featureFlagService = featureFlagService, analyticsService = analyticsService, + workManagerScheduler = workManagerScheduler, ).also { Timber.tag(it.toString()).d("Creating Client with access token '$anonymizedAccessToken' and refresh token '$anonymizedRefreshToken'") } @@ -131,12 +135,11 @@ class RustMatrixClientFactory( slidingSyncType: ClientBuilderSlidingSync, ): ClientBuilder { var builder = clientBuilderProvider.provide() - .sqliteStore( - SqliteStoreBuilder( - dataPath = sessionPaths.fileDirectory.absolutePath, - cachePath = sessionPaths.cacheDirectory.absolutePath, - ).passphrase(passphrase) - ) + .run { + sqliteStoreBuilderProvider.provide(sessionPaths) + .passphrase(passphrase) + .setupClientBuilder(this) + } .setSessionDelegate(sessionDelegate) .userAgent(userAgentProvider.provide()) .addRootCertificates(userCertificatesProvider.provides()) @@ -220,7 +223,7 @@ sealed interface ClientBuilderSlidingSync { data object Native : ClientBuilderSlidingSync } -private fun SessionData.toSession() = Session( +fun SessionData.toSession() = Session( accessToken = accessToken, refreshToken = refreshToken, userId = userId, 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 new file mode 100644 index 00000000000..89734831322 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/analytics/DefaultGetDatabaseSizesUseCase.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2025 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.analytics + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.matrix.api.MatrixClientProvider +import io.element.android.libraries.matrix.api.analytics.GetDatabaseSizesUseCase +import io.element.android.libraries.matrix.api.analytics.SdkStoreSizes +import io.element.android.libraries.matrix.api.core.SessionId + +@ContributesBinding(AppScope::class) +class DefaultGetDatabaseSizesUseCase( + private val clientProvider: Lazy, +) : GetDatabaseSizesUseCase { + override suspend fun invoke(sessionId: SessionId): Result { + val client = clientProvider.value.getOrNull(sessionId) + ?: return Result.failure(IllegalArgumentException("No MatrixClient for session $sessionId")) + + return client.getDatabaseSizes() + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/analytics/RustAnalyticsSdkManager.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/analytics/RustAnalyticsSdkManager.kt new file mode 100644 index 00000000000..a74acab6835 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/analytics/RustAnalyticsSdkManager.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2025 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.analytics + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.services.analytics.api.AnalyticsSdkManager +import io.element.android.services.analytics.api.AnalyticsSdkSpan +import org.matrix.rustcomponents.sdk.enableSentryLogging + +@ContributesBinding(AppScope::class) +class RustAnalyticsSdkManager : AnalyticsSdkManager { + override fun enableSdkAnalytics(enabled: Boolean) { + enableSentryLogging(enabled) + } + + override fun startSpan(name: String, parentTraceId: String?): AnalyticsSdkSpan { + return RustAnalyticsSdkSpan(name = name, parentTraceId = parentTraceId) + } + + override fun bridge(parentTraceId: String?): AnalyticsSdkSpan { + // A bridge span has no name + return RustAnalyticsSdkSpan(name = null, parentTraceId = parentTraceId) + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/analytics/RustAnalyticsSdkSpan.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/analytics/RustAnalyticsSdkSpan.kt new file mode 100644 index 00000000000..9ed8763ec44 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/analytics/RustAnalyticsSdkSpan.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2025 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.analytics + +import io.element.android.libraries.core.extensions.runCatchingExceptions +import io.element.android.services.analytics.api.AnalyticsSdkSpan +import kotlinx.coroutines.DelicateCoroutinesApi +import org.matrix.rustcomponents.sdk.LogLevel +import org.matrix.rustcomponents.sdk.Span +import timber.log.Timber + +class RustAnalyticsSdkSpan( + name: String? = null, + private val parentTraceId: String?, +) : AnalyticsSdkSpan { + private val inner = if (name != null) { + Span( + target = "elementx", + name = name, + file = "-", + line = null, + level = LogLevel.WARN, + bridgeTraceId = parentTraceId, + ) + } else { + Span.newBridgeSpan( + target = "elementx", + parentTraceId = parentTraceId, + ) + } + + override fun enter() { + if (Span.current().isNone()) { + inner.enter() + } else { + Timber.w("Not entering span sentry.trace='$parentTraceId' because another span is already active") + } + } + + @OptIn(DelicateCoroutinesApi::class) + override fun exit() { + inner.exit() + runCatchingExceptions { inner.destroy() } + Timber.d("Exited span sentry.trace='$parentTraceId'") + } +} 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 5b5458376e5..07d8d57b552 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 @@ -21,10 +21,12 @@ import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails import io.element.android.libraries.matrix.api.auth.OidcDetails import io.element.android.libraries.matrix.api.auth.OidcPrompt +import io.element.android.libraries.matrix.api.auth.SessionRestorationException import io.element.android.libraries.matrix.api.auth.external.ExternalSession import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginData import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeLoginStep import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus import io.element.android.libraries.matrix.impl.ClientBuilderSlidingSync import io.element.android.libraries.matrix.impl.RustMatrixClientFactory import io.element.android.libraries.matrix.impl.auth.qrlogin.QrErrorMapper @@ -35,11 +37,14 @@ import io.element.android.libraries.matrix.impl.keys.PassphraseGenerator import io.element.android.libraries.matrix.impl.mapper.toSessionData import io.element.android.libraries.matrix.impl.paths.SessionPaths import io.element.android.libraries.matrix.impl.paths.SessionPathsFactory +import io.element.android.libraries.matrix.impl.toSession import io.element.android.libraries.network.useragent.UserAgentProvider import io.element.android.libraries.sessionstorage.api.LoginType import io.element.android.libraries.sessionstorage.api.SessionStore import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.flow.first import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull import org.matrix.rustcomponents.sdk.Client import org.matrix.rustcomponents.sdk.ClientBuilder import org.matrix.rustcomponents.sdk.HumanQrLoginException @@ -51,6 +56,7 @@ import org.matrix.rustcomponents.sdk.tchapGetInstance import timber.log.Timber import uniffi.matrix_sdk.OAuthAuthorizationData import uniffi.matrix_sdk_tchap.TchapGetInstanceConfig +import kotlin.time.Duration.Companion.seconds @ContributesBinding(AppScope::class) @SingleIn(AppScope::class) @@ -97,10 +103,10 @@ class RustMatrixAuthenticationService( } rustMatrixClientFactory.create(sessionData) } else { - error("Token is not valid") + throw SessionRestorationException.InvalidToken() } } else { - error("No session to restore with id $sessionId") + throw SessionRestorationException.MissingSession(sessionId) } }.mapFailure { failure -> failure.mapClientException() @@ -184,7 +190,7 @@ class RustMatrixAuthenticationService( override suspend fun importCreatedSession(externalSession: ExternalSession): Result = withContext(coroutineDispatchers.io) { runCatchingExceptions { - currentClient ?: error("You need to call `setHomeserver()` first") + val client = currentClient ?: error("You need to call `setHomeserver()` first") val currentSessionPaths = sessionPaths ?: error("You need to call `setHomeserver()` first") val sessionData = externalSession.toSessionData( isTokenValid = true, @@ -192,8 +198,21 @@ class RustMatrixAuthenticationService( passphrase = pendingPassphrase, sessionPaths = currentSessionPaths, ) - clear() + + // We restore the client using the just retrieved session data + client.restoreSession(sessionData.toSession()) + val matrixClient = rustMatrixClientFactory.create(client) + + // We wait for the verification state to be known + matrixClient.waitForKnownVerificationState() + + // And once it's ready we share it and save the actual session data + newMatrixClientObservers.forEach { it.invoke(matrixClient) } sessionStore.addSession(sessionData) + + // Clean up the strong reference held here since it's no longer necessary + currentClient = null + SessionId(sessionData.userId) } } @@ -262,6 +281,8 @@ class RustMatrixAuthenticationService( sessionPaths = currentSessionPaths, ) val matrixClient = rustMatrixClientFactory.create(client) + matrixClient.waitForKnownVerificationState() + newMatrixClientObservers.forEach { it.invoke(matrixClient) } sessionStore.addSession(sessionData) @@ -380,4 +401,12 @@ class RustMatrixAuthenticationService( currentClient?.close() currentClient = null } + + private suspend fun MatrixClient.waitForKnownVerificationState() { + withTimeoutOrNull(10.seconds) { + Timber.d("Waiting for a known verification status...") + val status = sessionVerificationService.sessionVerifiedStatus.first { it != SessionVerifiedStatus.Unknown } + Timber.d("Finished waiting for a known verification status: $status") + } ?: Timber.w("Timed out waiting for a known verification status") + } } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/RecoveryExceptionMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/RecoveryExceptionMapper.kt index 76a72cd65bb..f5d1a4253b1 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/RecoveryExceptionMapper.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/RecoveryExceptionMapper.kt @@ -30,7 +30,7 @@ fun Throwable.mapRecoveryException(): RecoveryException { } } else -> RecoveryException.Client( - ClientException.Other("Unknown error") + ClientException.Other("Unknown error", this) ) } } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/exception/ClientException.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/exception/ClientException.kt index 668e55e9bff..eefa6d4986f 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/exception/ClientException.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/exception/ClientException.kt @@ -15,15 +15,16 @@ fun Throwable.mapClientException(): ClientException { return when (this) { is RustClientException -> { when (this) { - is RustClientException.Generic -> ClientException.Generic(msg, details) + is RustClientException.Generic -> ClientException.Generic(message = msg, details = details, cause = this) is RustClientException.MatrixApi -> ClientException.MatrixApi( kind = kind.map(), code = code, message = msg, details = details, + cause = this, ) } } - else -> ClientException.Other(message ?: "Unknown error") + else -> ClientException.Other(message ?: "Unknown error", this) } } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/HumanQrGrantLoginExceptionExtension.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/HumanQrGrantLoginExceptionExtension.kt new file mode 100644 index 00000000000..2d47b60def8 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/HumanQrGrantLoginExceptionExtension.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.linknewdevice + +import io.element.android.libraries.matrix.api.linknewdevice.ErrorType +import org.matrix.rustcomponents.sdk.HumanQrGrantLoginException + +internal fun HumanQrGrantLoginException.map() = when (this) { + is HumanQrGrantLoginException.DeviceIdAlreadyInUse -> ErrorType.DeviceIdAlreadyInUse(message.orEmpty()) + is HumanQrGrantLoginException.InvalidCheckCode -> ErrorType.InvalidCheckCode(message.orEmpty()) + is HumanQrGrantLoginException.MissingSecretsBackup -> ErrorType.MissingSecretsBackup(message.orEmpty()) + is HumanQrGrantLoginException.NotFound -> ErrorType.NotFound(message.orEmpty()) + is HumanQrGrantLoginException.UnableToCreateDevice -> ErrorType.UnableToCreateDevice(message.orEmpty()) + is HumanQrGrantLoginException.Unknown -> ErrorType.Unknown(message.orEmpty()) + is HumanQrGrantLoginException.UnsupportedProtocol -> ErrorType.UnsupportedProtocol(message.orEmpty()) +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/QrCodeDataParser.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/QrCodeDataParser.kt new file mode 100644 index 00000000000..6472850a8cf --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/QrCodeDataParser.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.linknewdevice + +import org.matrix.rustcomponents.sdk.QrCodeData + +interface QrCodeDataParser { + fun parse(data: ByteArray): QrCodeData +} + +class RustQrCodeDataParser : QrCodeDataParser { + override fun parse(data: ByteArray): QrCodeData { + return QrCodeData.fromBytes(data) + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustCheckCodeSender.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustCheckCodeSender.kt new file mode 100644 index 00000000000..9919d1fafce --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustCheckCodeSender.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.linknewdevice + +import io.element.android.libraries.core.extensions.runCatchingExceptions +import io.element.android.libraries.matrix.api.linknewdevice.CheckCodeSender +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.withContext +import org.matrix.rustcomponents.sdk.CheckCodeSender as FfiCheckCodeSender + +class RustCheckCodeSender( + private val inner: FfiCheckCodeSender, + private val sessionDispatcher: CoroutineDispatcher, +) : CheckCodeSender { + override suspend fun validate(code: UByte): Boolean = withContext(sessionDispatcher) { + runCatchingExceptions { + // TODO https://github.com/matrix-org/matrix-rust-sdk/pull/5957 + // inner.validate(code) + true + }.getOrNull() ?: true + } + + override suspend fun send(code: UByte): Result = withContext(sessionDispatcher) { + runCatchingExceptions { + inner.send(code) + } + } +} 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 new file mode 100644 index 00000000000..211bdc3d4ee --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkDesktopHandler.kt @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.linknewdevice + +import io.element.android.libraries.core.log.logger.LoggerTag +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.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.matrix.rustcomponents.sdk.GrantLoginWithQrCodeHandler +import org.matrix.rustcomponents.sdk.GrantQrLoginProgress +import org.matrix.rustcomponents.sdk.GrantQrLoginProgressListener +import org.matrix.rustcomponents.sdk.HumanQrGrantLoginException +import org.matrix.rustcomponents.sdk.QrCodeDecodeException +import timber.log.Timber + +private val tag = LoggerTag("RustLinkDesktopHandler", LoggerTags.linkNewDevice) + +class RustLinkDesktopHandler( + private val inner: GrantLoginWithQrCodeHandler, + private val sessionCoroutineScope: CoroutineScope, + private val sessionDispatcher: CoroutineDispatcher, + private val qrCodeDataParser: QrCodeDataParser, +) : LinkDesktopHandler { + private val _linkDesktopStep = MutableStateFlow(LinkDesktopStep.Uninitialized) + override val linkDesktopStep: StateFlow = _linkDesktopStep.asStateFlow() + + override suspend fun handleScannedQrCode(data: ByteArray) = withContext(sessionDispatcher) { + Timber.tag(tag.value).d("Emit Uninitialized") + _linkDesktopStep.emit(LinkDesktopStep.Uninitialized) + try { + val qrCodeData = qrCodeDataParser.parse(data) + inner.scan( + qrCodeData = qrCodeData, + progressListener = object : GrantQrLoginProgressListener { + override fun onUpdate(state: GrantQrLoginProgress) { + sessionCoroutineScope.launch { + val mappedState = state.map() + Timber.tag(tag.value).d("Emit ${mappedState::class.java.simpleName}") + _linkDesktopStep.emit(mappedState) + } + } + } + ) + } catch (e: QrCodeDecodeException) { + Timber.tag(tag.value).w(e, "Invalid QR code scanned") + _linkDesktopStep.emit( + LinkDesktopStep.InvalidQrCode( + error = QrErrorMapper.map(e) + ) + ) + } catch (e: HumanQrGrantLoginException) { + Timber.tag(tag.value).w(e, "Error during QR login grant") + _linkDesktopStep.emit(LinkDesktopStep.Error(e.map())) + } + } + + private fun GrantQrLoginProgress.map() = when (this) { + GrantQrLoginProgress.Done -> LinkDesktopStep.Done + GrantQrLoginProgress.Starting -> LinkDesktopStep.Starting + GrantQrLoginProgress.SyncingSecrets -> LinkDesktopStep.SyncingSecrets + is GrantQrLoginProgress.WaitingForAuth -> LinkDesktopStep.WaitingForAuth( + verificationUri = verificationUri, + ) + is GrantQrLoginProgress.EstablishingSecureChannel -> LinkDesktopStep.EstablishingSecureChannel( + checkCode = checkCode, + checkCodeString = checkCodeString, + ) + } +} 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 new file mode 100644 index 00000000000..0189987d961 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkMobileHandler.kt @@ -0,0 +1,75 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.linknewdevice + +import io.element.android.libraries.core.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.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.matrix.rustcomponents.sdk.GrantGeneratedQrLoginProgress +import org.matrix.rustcomponents.sdk.GrantGeneratedQrLoginProgressListener +import org.matrix.rustcomponents.sdk.GrantLoginWithQrCodeHandler +import org.matrix.rustcomponents.sdk.HumanQrGrantLoginException +import timber.log.Timber + +private val tag = LoggerTag("RustLinkMobileHandler", LoggerTags.linkNewDevice) + +class RustLinkMobileHandler( + private val inner: GrantLoginWithQrCodeHandler, + private val sessionCoroutineScope: CoroutineScope, + private val sessionDispatcher: CoroutineDispatcher, +) : LinkMobileHandler { + private val _linkMobileStep = MutableStateFlow(LinkMobileStep.Uninitialized) + override val linkMobileStep: Flow = _linkMobileStep.asStateFlow() + + override suspend fun start() = withContext(sessionDispatcher) { + Timber.tag(tag.value).d("Emit Uninitialized") + _linkMobileStep.emit(LinkMobileStep.Uninitialized) + try { + inner.generate( + progressListener = object : GrantGeneratedQrLoginProgressListener { + override fun onUpdate(state: GrantGeneratedQrLoginProgress) { + sessionCoroutineScope.launch { + val mappedState = state.map() + Timber.tag(tag.value).d("Emit ${mappedState::class.java.simpleName}") + _linkMobileStep.emit(mappedState) + } + } + } + ) + } catch (e: HumanQrGrantLoginException) { + Timber.tag(tag.value).w(e, "Error during QR login grant") + _linkMobileStep.emit(LinkMobileStep.Error(e.map())) + } + } + + private fun GrantGeneratedQrLoginProgress.map(): LinkMobileStep { + return when (this) { + GrantGeneratedQrLoginProgress.Done -> LinkMobileStep.Done + is GrantGeneratedQrLoginProgress.QrReady -> { + LinkMobileStep.QrReady(String(qrCode.toBytes(), Charsets.ISO_8859_1)) + } + is GrantGeneratedQrLoginProgress.QrScanned -> LinkMobileStep.QrScanned( + RustCheckCodeSender( + inner = checkCodeSender, + sessionDispatcher = sessionDispatcher, + ) + ) + GrantGeneratedQrLoginProgress.Starting -> LinkMobileStep.Starting + GrantGeneratedQrLoginProgress.SyncingSecrets -> LinkMobileStep.SyncingSecrets + is GrantGeneratedQrLoginProgress.WaitingForAuth -> LinkMobileStep.WaitingForAuth(verificationUri) + } + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/NotificationMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/NotificationMapper.kt index 8e33117cc77..511c899fdb1 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/NotificationMapper.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/NotificationMapper.kt @@ -53,6 +53,7 @@ class NotificationMapper( roomDisplayName = item.roomInfo.displayName, isDirect = item.roomInfo.isDirect, isDm = isDm, + isSpace = item.roomInfo.isSpace, isEncrypted = item.roomInfo.isEncrypted.orFalse(), isNoisy = item.isNoisy.orFalse(), timestamp = timestamp, diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/TimelineEventToNotificationContentMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/TimelineEventToNotificationContentMapper.kt index 2bdd6b58fa6..aeb23127fe7 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/TimelineEventToNotificationContentMapper.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/notification/TimelineEventToNotificationContentMapper.kt @@ -18,7 +18,7 @@ import io.element.android.libraries.matrix.impl.timeline.item.event.EventMessage import org.matrix.rustcomponents.sdk.MessageLikeEventContent import org.matrix.rustcomponents.sdk.StateEventContent import org.matrix.rustcomponents.sdk.TimelineEvent -import org.matrix.rustcomponents.sdk.TimelineEventType +import org.matrix.rustcomponents.sdk.TimelineEventContent import org.matrix.rustcomponents.sdk.use import org.matrix.rustcomponents.sdk.RtcNotificationType as SdkRtcNotificationType @@ -27,18 +27,18 @@ class TimelineEventToNotificationContentMapper { return runCatchingExceptions { timelineEvent.use { val senderId = UserId(timelineEvent.senderId()) - timelineEvent.eventType().use { eventType -> - eventType.toContent(senderId = senderId) + timelineEvent.content().use { eventContent -> + eventContent.toContent(senderId = senderId) } } } } } -private fun TimelineEventType.toContent(senderId: UserId): NotificationContent { +private fun TimelineEventContent.toContent(senderId: UserId): NotificationContent { return when (this) { - is TimelineEventType.MessageLike -> content.toContent(senderId) - is TimelineEventType.State -> content.toContent() + is TimelineEventContent.MessageLike -> content.toContent(senderId) + is TimelineEventContent.State -> content.toContent() } } 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 e140950161c..f2ba1dc782d 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 @@ -27,6 +27,7 @@ import io.element.android.libraries.matrix.api.room.CreateTimelineParams import io.element.android.libraries.matrix.api.room.IntentionalMention import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.room.RoomNotificationSettingsState +import io.element.android.libraries.matrix.api.room.SendQueueUpdate import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibility import io.element.android.libraries.matrix.api.room.join.JoinRule import io.element.android.libraries.matrix.api.room.knock.KnockRequest @@ -67,6 +68,8 @@ import org.matrix.rustcomponents.sdk.DateDividerMode import org.matrix.rustcomponents.sdk.IdentityStatusChangeListener import org.matrix.rustcomponents.sdk.KnockRequestsListener import org.matrix.rustcomponents.sdk.RoomMessageEventMessageType +import org.matrix.rustcomponents.sdk.RoomSendQueueUpdate +import org.matrix.rustcomponents.sdk.SendQueueListener import org.matrix.rustcomponents.sdk.TimelineConfiguration import org.matrix.rustcomponents.sdk.TimelineFilter import org.matrix.rustcomponents.sdk.TimelineFocus @@ -399,10 +402,12 @@ class JoinedRustRoom( invite = roomPowerLevelsValues.invite, kick = roomPowerLevelsValues.kick, redact = roomPowerLevelsValues.redactEvents, - eventsDefault = roomPowerLevelsValues.sendEvents, + stateDefault = roomPowerLevelsValues.stateDefault, + eventsDefault = roomPowerLevelsValues.eventsDefault, roomName = roomPowerLevelsValues.roomName, roomAvatar = roomPowerLevelsValues.roomAvatar, roomTopic = roomPowerLevelsValues.roomTopic, + spaceChild = roomPowerLevelsValues.spaceChild, ) innerRoom.applyPowerLevelChanges(changes) } @@ -486,6 +491,16 @@ class JoinedRustRoom( } } + override fun subscribeToSendQueueUpdates(): Flow { + return mxCallbackFlow { + innerRoom.subscribeToSendQueueUpdates(object : SendQueueListener { + override fun onUpdate(update: RoomSendQueueUpdate) { + trySend(update.map()) + } + }) + } + } + override fun close() = destroy() override fun destroy() { @@ -509,6 +524,7 @@ class JoinedRustRoom( ) } + // TCHAP access rule override suspend fun setAccessRule(rule: RoomAccessRules): Result = withContext(roomDispatcher) { runCatchingExceptions { innerRoom.setAccessRule( diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/MessageEventType.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/MessageEventType.kt index dffa5c1acbd..47b37b79237 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/MessageEventType.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/MessageEventType.kt @@ -12,11 +12,21 @@ import io.element.android.libraries.matrix.api.room.MessageEventType import org.matrix.rustcomponents.sdk.MessageLikeEventType fun MessageEventType.map(): MessageLikeEventType = when (this) { + MessageEventType.Audio -> MessageLikeEventType.Audio + MessageEventType.Beacon -> MessageLikeEventType.Beacon MessageEventType.CallAnswer -> MessageLikeEventType.CallAnswer + MessageEventType.CallCandidates -> MessageLikeEventType.CallCandidates MessageEventType.CallInvite -> MessageLikeEventType.CallInvite MessageEventType.CallHangup -> MessageLikeEventType.CallHangup - MessageEventType.CallCandidates -> MessageLikeEventType.CallCandidates - MessageEventType.RtcNotification -> MessageLikeEventType.RtcNotification + MessageEventType.CallNegotiate -> MessageLikeEventType.CallNegotiate + MessageEventType.CallNotify -> MessageLikeEventType.CallNotify + MessageEventType.CallReject -> MessageLikeEventType.CallReject + MessageEventType.CallSdpStreamMetadataChanged -> MessageLikeEventType.CallSdpStreamMetadataChanged + MessageEventType.CallSelectAnswer -> MessageLikeEventType.CallSelectAnswer + MessageEventType.Emote -> MessageLikeEventType.Emote + MessageEventType.Encrypted -> MessageLikeEventType.Encrypted + MessageEventType.File -> MessageLikeEventType.File + MessageEventType.Image -> MessageLikeEventType.Image MessageEventType.KeyVerificationReady -> MessageLikeEventType.KeyVerificationReady MessageEventType.KeyVerificationStart -> MessageLikeEventType.KeyVerificationStart MessageEventType.KeyVerificationCancel -> MessageLikeEventType.KeyVerificationCancel @@ -24,17 +34,23 @@ fun MessageEventType.map(): MessageLikeEventType = when (this) { MessageEventType.KeyVerificationKey -> MessageLikeEventType.KeyVerificationKey MessageEventType.KeyVerificationMac -> MessageLikeEventType.KeyVerificationMac MessageEventType.KeyVerificationDone -> MessageLikeEventType.KeyVerificationDone + MessageEventType.Location -> MessageLikeEventType.Location + MessageEventType.Message -> MessageLikeEventType.Message MessageEventType.Reaction -> MessageLikeEventType.Reaction MessageEventType.RoomEncrypted -> MessageLikeEventType.RoomEncrypted MessageEventType.RoomMessage -> MessageLikeEventType.RoomMessage MessageEventType.RoomRedaction -> MessageLikeEventType.RoomRedaction + MessageEventType.RtcDecline -> MessageLikeEventType.RtcDecline MessageEventType.Sticker -> MessageLikeEventType.Sticker MessageEventType.PollEnd -> MessageLikeEventType.PollEnd MessageEventType.PollResponse -> MessageLikeEventType.PollResponse MessageEventType.PollStart -> MessageLikeEventType.PollStart + MessageEventType.RtcNotification -> MessageLikeEventType.RtcNotification MessageEventType.UnstablePollEnd -> MessageLikeEventType.UnstablePollEnd MessageEventType.UnstablePollResponse -> MessageLikeEventType.UnstablePollResponse MessageEventType.UnstablePollStart -> MessageLikeEventType.UnstablePollStart + MessageEventType.Video -> MessageLikeEventType.Video + MessageEventType.Voice -> MessageLikeEventType.Voice is MessageEventType.Other -> MessageLikeEventType.Other(type) } @@ -62,5 +78,21 @@ fun MessageLikeEventType.map(): MessageEventType = when (this) { MessageLikeEventType.UnstablePollEnd -> MessageEventType.UnstablePollEnd MessageLikeEventType.UnstablePollResponse -> MessageEventType.UnstablePollResponse MessageLikeEventType.UnstablePollStart -> MessageEventType.UnstablePollStart + MessageLikeEventType.Audio -> MessageEventType.Audio + MessageLikeEventType.Beacon -> MessageEventType.Beacon + MessageLikeEventType.CallNegotiate -> MessageEventType.CallNegotiate + MessageLikeEventType.CallNotify -> MessageEventType.CallNotify + MessageLikeEventType.CallReject -> MessageEventType.CallReject + MessageLikeEventType.CallSdpStreamMetadataChanged -> MessageEventType.CallSdpStreamMetadataChanged + MessageLikeEventType.CallSelectAnswer -> MessageEventType.CallSelectAnswer + MessageLikeEventType.Emote -> MessageEventType.Emote + MessageLikeEventType.Encrypted -> MessageEventType.Encrypted + MessageLikeEventType.File -> MessageEventType.File + MessageLikeEventType.Image -> MessageEventType.Image + MessageLikeEventType.Location -> MessageEventType.Location + MessageLikeEventType.Message -> MessageEventType.Message + MessageLikeEventType.RtcDecline -> MessageEventType.RtcDecline + MessageLikeEventType.Video -> MessageEventType.Video + MessageLikeEventType.Voice -> MessageEventType.Voice is MessageLikeEventType.Other -> MessageEventType.Other(v1) } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomSyncSubscriber.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomSyncSubscriber.kt index 6a6b45b7a72..bf31c2edf38 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomSyncSubscriber.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RoomSyncSubscriber.kt @@ -21,20 +21,16 @@ class RoomSyncSubscriber( private val roomListService: RoomListService, private val dispatchers: CoroutineDispatchers, ) { - private val subscribedRoomIds = mutableSetOf() private val mutex = Mutex() suspend fun subscribe(roomId: RoomId) { mutex.withLock { withContext(dispatchers.io) { try { - if (!isSubscribedTo(roomId)) { - Timber.d("Subscribing to room $roomId}") - roomListService.subscribeToRooms(listOf(roomId.value)) - } - subscribedRoomIds.add(roomId) + Timber.d("Subscribing to room $roomId}") + roomListService.subscribeToRooms(listOf(roomId.value)) } catch (exception: Exception) { - Timber.e("Failed to subscribe to room $roomId") + Timber.e(exception, "Failed to subscribe to room $roomId") } } } @@ -43,12 +39,8 @@ class RoomSyncSubscriber( suspend fun batchSubscribe(roomIds: List) = mutex.withLock { withContext(dispatchers.io) { try { - val roomIdsToSubscribeTo = roomIds.filterNot { isSubscribedTo(it) } - if (roomIdsToSubscribeTo.isNotEmpty()) { - Timber.d("Subscribing to rooms: $roomIds") - roomListService.subscribeToRooms(roomIdsToSubscribeTo.map { it.value }) - subscribedRoomIds.addAll(roomIds) - } + Timber.d("Subscribing to rooms: $roomIds") + roomListService.subscribeToRooms(roomIds.map { it.value }) } catch (cancellationException: CancellationException) { throw cancellationException } catch (exception: Exception) { @@ -56,8 +48,4 @@ class RoomSyncSubscriber( } } } - - fun isSubscribedTo(roomId: RoomId): Boolean { - return subscribedRoomIds.contains(roomId) - } } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustBaseRoom.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustBaseRoom.kt index be7f6240eee..8c4847a73fb 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustBaseRoom.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/RustBaseRoom.kt @@ -18,13 +18,12 @@ 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.UserId import io.element.android.libraries.matrix.api.room.BaseRoom -import io.element.android.libraries.matrix.api.room.MessageEventType import io.element.android.libraries.matrix.api.room.RoomInfo import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMembersState import io.element.android.libraries.matrix.api.room.RoomMembershipObserver -import io.element.android.libraries.matrix.api.room.StateEventType import io.element.android.libraries.matrix.api.room.draft.ComposerDraft +import io.element.android.libraries.matrix.api.room.powerlevels.RoomPermissions import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevelsValues import io.element.android.libraries.matrix.api.room.tombstone.PredecessorRoom import io.element.android.libraries.matrix.api.roomdirectory.RoomVisibility @@ -33,6 +32,7 @@ import io.element.android.libraries.matrix.impl.room.draft.into import io.element.android.libraries.matrix.impl.room.member.RoomMemberListFetcher import io.element.android.libraries.matrix.impl.room.member.RoomMemberMapper import io.element.android.libraries.matrix.impl.room.powerlevels.RoomPowerLevelsValuesMapper +import io.element.android.libraries.matrix.impl.room.powerlevels.RustRoomPermissions import io.element.android.libraries.matrix.impl.room.tombstone.map import io.element.android.libraries.matrix.impl.roomdirectory.map import io.element.android.libraries.matrix.impl.timeline.toRustReceiptType @@ -85,7 +85,9 @@ class RustBaseRoom( }.stateIn(roomCoroutineScope, started = SharingStarted.Lazily, initialValue = initialRoomInfo) override fun predecessorRoom(): PredecessorRoom? { - return innerRoom.predecessorRoom()?.map() + return runCatchingExceptions { innerRoom.predecessorRoom()?.map() } + .onFailure { Timber.e(it, "Could not get predecessor room") } + .getOrNull() } override suspend fun subscribeToSync() = roomSyncSubscriber.subscribe(roomId) @@ -178,57 +180,9 @@ class RustBaseRoom( } } - override suspend fun canUserInvite(userId: UserId): Result = withContext(roomDispatcher) { + override suspend fun roomPermissions(): Result = withContext(roomDispatcher) { runCatchingExceptions { - innerRoom.getPowerLevels().use { it.canUserInvite(userId.value) } - } - } - - override suspend fun canUserKick(userId: UserId): Result = withContext(roomDispatcher) { - runCatchingExceptions { - innerRoom.getPowerLevels().use { it.canUserKick(userId.value) } - } - } - - override suspend fun canUserBan(userId: UserId): Result = withContext(roomDispatcher) { - runCatchingExceptions { - innerRoom.getPowerLevels().use { it.canUserBan(userId.value) } - } - } - - override suspend fun canUserRedactOwn(userId: UserId): Result = withContext(roomDispatcher) { - runCatchingExceptions { - innerRoom.getPowerLevels().use { it.canUserRedactOwn(userId.value) } - } - } - - override suspend fun canUserRedactOther(userId: UserId): Result = withContext(roomDispatcher) { - runCatchingExceptions { - innerRoom.getPowerLevels().use { it.canUserRedactOther(userId.value) } - } - } - - override suspend fun canUserSendState(userId: UserId, type: StateEventType): Result = withContext(roomDispatcher) { - runCatchingExceptions { - innerRoom.getPowerLevels().use { it.canUserSendState(userId.value, type.map()) } - } - } - - override suspend fun canUserSendMessage(userId: UserId, type: MessageEventType): Result = withContext(roomDispatcher) { - runCatchingExceptions { - innerRoom.getPowerLevels().use { it.canUserSendMessage(userId.value, type.map()) } - } - } - - override suspend fun canUserTriggerRoomNotification(userId: UserId): Result = withContext(roomDispatcher) { - runCatchingExceptions { - innerRoom.getPowerLevels().use { it.canUserTriggerRoomNotification(userId.value) } - } - } - - override suspend fun canUserPinUnpin(userId: UserId): Result = withContext(roomDispatcher) { - runCatchingExceptions { - innerRoom.getPowerLevels().use { it.canUserPinUnpin(userId.value) } + RustRoomPermissions(innerRoom.getPowerLevels()) } } 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 f184356f154..cbc39b1c61f 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 @@ -25,6 +25,7 @@ import io.element.android.libraries.matrix.impl.room.preview.RoomPreviewInfoMapp import io.element.android.libraries.matrix.impl.roomlist.roomOrNull import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction import io.element.android.services.analytics.api.AnalyticsService +import io.element.android.services.analytics.api.inBridgeSdkSpan import io.element.android.services.analytics.api.recordTransaction import io.element.android.services.analyticsproviders.api.recordChildTransaction import io.element.android.services.toolbox.api.systemclock.SystemClock @@ -127,17 +128,19 @@ class RustRoomFactory( val timeline = transaction.recordChildTransaction( operation = "sdkRoom.timelineWithConfiguration", description = "Get timeline from the SDK", - ) { - sdkRoom.timelineWithConfiguration( - TimelineConfiguration( - focus = TimelineFocus.Live(hideThreadedEvents = hideThreadedEvents), - filter = eventFilters?.let(TimelineFilter::EventTypeFilter) ?: TimelineFilter.All, - internalIdPrefix = "live", - dateDividerMode = DateDividerMode.DAILY, - trackReadReceipts = TimelineReadReceiptTracking.ALL_EVENTS, - reportUtds = true, + ) { timelineTransaction -> + analyticsService.inBridgeSdkSpan(parentTraceId = timelineTransaction.traceId()) { + sdkRoom.timelineWithConfiguration( + TimelineConfiguration( + focus = TimelineFocus.Live(hideThreadedEvents = hideThreadedEvents), + filter = eventFilters?.let(TimelineFilter::EventTypeFilter) ?: TimelineFilter.All, + internalIdPrefix = "live", + dateDividerMode = DateDividerMode.DAILY, + trackReadReceipts = TimelineReadReceiptTracking.ALL_EVENTS, + reportUtds = true, + ) ) - ) + } } GetRoomResult.Joined( diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/SendQueueUpdatesExt.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/SendQueueUpdatesExt.kt new file mode 100644 index 00000000000..efc723e1dfd --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/SendQueueUpdatesExt.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 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.room + +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.TransactionId +import io.element.android.libraries.matrix.api.room.SendQueueUpdate +import io.element.android.libraries.matrix.impl.media.map +import org.matrix.rustcomponents.sdk.RoomSendQueueUpdate + +fun RoomSendQueueUpdate.map(): SendQueueUpdate = when (this) { + is RoomSendQueueUpdate.NewLocalEvent -> SendQueueUpdate.NewLocalEvent(TransactionId(transactionId)) + is RoomSendQueueUpdate.CancelledLocalEvent -> SendQueueUpdate.CancelledLocalEvent(TransactionId(transactionId)) + is RoomSendQueueUpdate.MediaUpload -> SendQueueUpdate.MediaUpload( + relatedTo = EventId(relatedTo), + file = file?.map(), + index = index.toLong(), + progress = progress.current.toFloat() / progress.total.toFloat(), + ) + is RoomSendQueueUpdate.ReplacedLocalEvent -> SendQueueUpdate.ReplacedLocalEvent(TransactionId(transactionId)) + is RoomSendQueueUpdate.RetryEvent -> SendQueueUpdate.RetrySendingEvent(TransactionId(transactionId)) + is RoomSendQueueUpdate.SendError -> SendQueueUpdate.SendError(TransactionId(transactionId)) + is RoomSendQueueUpdate.SentEvent -> SendQueueUpdate.SentEvent(TransactionId(transactionId), EventId(eventId)) +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/StateEventType.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/StateEventType.kt index c1ba8c9728c..76fea0beefb 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/StateEventType.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/StateEventType.kt @@ -12,51 +12,61 @@ import io.element.android.libraries.matrix.api.room.StateEventType import org.matrix.rustcomponents.sdk.StateEventType as RustStateEventType fun StateEventType.map(): RustStateEventType = when (this) { - StateEventType.POLICY_RULE_ROOM -> RustStateEventType.POLICY_RULE_ROOM - StateEventType.POLICY_RULE_SERVER -> RustStateEventType.POLICY_RULE_SERVER - StateEventType.POLICY_RULE_USER -> RustStateEventType.POLICY_RULE_USER - StateEventType.CALL_MEMBER -> RustStateEventType.CALL_MEMBER - StateEventType.ROOM_ALIASES -> RustStateEventType.ROOM_ALIASES - StateEventType.ROOM_AVATAR -> RustStateEventType.ROOM_AVATAR - StateEventType.ROOM_CANONICAL_ALIAS -> RustStateEventType.ROOM_CANONICAL_ALIAS - StateEventType.ROOM_CREATE -> RustStateEventType.ROOM_CREATE - StateEventType.ROOM_ENCRYPTION -> RustStateEventType.ROOM_ENCRYPTION - StateEventType.ROOM_GUEST_ACCESS -> RustStateEventType.ROOM_GUEST_ACCESS - StateEventType.ROOM_HISTORY_VISIBILITY -> RustStateEventType.ROOM_HISTORY_VISIBILITY - StateEventType.ROOM_JOIN_RULES -> RustStateEventType.ROOM_JOIN_RULES - StateEventType.ROOM_MEMBER_EVENT -> RustStateEventType.ROOM_MEMBER_EVENT - StateEventType.ROOM_NAME -> RustStateEventType.ROOM_NAME - StateEventType.ROOM_PINNED_EVENTS -> RustStateEventType.ROOM_PINNED_EVENTS - StateEventType.ROOM_POWER_LEVELS -> RustStateEventType.ROOM_POWER_LEVELS - StateEventType.ROOM_SERVER_ACL -> RustStateEventType.ROOM_SERVER_ACL - StateEventType.ROOM_THIRD_PARTY_INVITE -> RustStateEventType.ROOM_THIRD_PARTY_INVITE - StateEventType.ROOM_TOMBSTONE -> RustStateEventType.ROOM_TOMBSTONE - StateEventType.ROOM_TOPIC -> RustStateEventType.ROOM_TOPIC - StateEventType.SPACE_CHILD -> RustStateEventType.SPACE_CHILD - StateEventType.SPACE_PARENT -> RustStateEventType.SPACE_PARENT + StateEventType.PolicyRuleRoom -> RustStateEventType.PolicyRuleRoom + StateEventType.PolicyRuleServer -> RustStateEventType.PolicyRuleServer + StateEventType.PolicyRuleUser -> RustStateEventType.PolicyRuleUser + StateEventType.CallMember -> RustStateEventType.CallMember + StateEventType.RoomAliases -> RustStateEventType.RoomAliases + StateEventType.RoomAvatar -> RustStateEventType.RoomAvatar + StateEventType.RoomCanonicalAlias -> RustStateEventType.RoomCanonicalAlias + StateEventType.RoomCreate -> RustStateEventType.RoomCreate + StateEventType.RoomEncryption -> RustStateEventType.RoomEncryption + StateEventType.RoomGuestAccess -> RustStateEventType.RoomGuestAccess + StateEventType.RoomHistoryVisibility -> RustStateEventType.RoomHistoryVisibility + StateEventType.RoomJoinRules -> RustStateEventType.RoomJoinRules + StateEventType.RoomMemberEvent -> RustStateEventType.RoomMemberEvent + StateEventType.RoomName -> RustStateEventType.RoomName + StateEventType.RoomPinnedEvents -> RustStateEventType.RoomPinnedEvents + StateEventType.RoomPowerLevels -> RustStateEventType.RoomPowerLevels + StateEventType.RoomServerAcl -> RustStateEventType.RoomServerAcl + StateEventType.RoomThirdPartyInvite -> RustStateEventType.RoomThirdPartyInvite + StateEventType.RoomTombstone -> RustStateEventType.RoomTombstone + StateEventType.RoomTopic -> RustStateEventType.RoomTopic + StateEventType.SpaceChild -> RustStateEventType.SpaceChild + StateEventType.SpaceParent -> RustStateEventType.SpaceParent + StateEventType.BeaconInfo -> RustStateEventType.BeaconInfo + StateEventType.MemberHints -> RustStateEventType.MemberHints + StateEventType.RoomImagePack -> RustStateEventType.RoomImagePack + StateEventType.RoomLanguage -> RustStateEventType.RoomLanguage + is StateEventType.Custom -> RustStateEventType.Custom(type) } fun RustStateEventType.map(): StateEventType = when (this) { - RustStateEventType.POLICY_RULE_ROOM -> StateEventType.POLICY_RULE_ROOM - RustStateEventType.POLICY_RULE_SERVER -> StateEventType.POLICY_RULE_SERVER - RustStateEventType.POLICY_RULE_USER -> StateEventType.POLICY_RULE_USER - RustStateEventType.CALL_MEMBER -> StateEventType.CALL_MEMBER - RustStateEventType.ROOM_ALIASES -> StateEventType.ROOM_ALIASES - RustStateEventType.ROOM_AVATAR -> StateEventType.ROOM_AVATAR - RustStateEventType.ROOM_CANONICAL_ALIAS -> StateEventType.ROOM_CANONICAL_ALIAS - RustStateEventType.ROOM_CREATE -> StateEventType.ROOM_CREATE - RustStateEventType.ROOM_ENCRYPTION -> StateEventType.ROOM_ENCRYPTION - RustStateEventType.ROOM_GUEST_ACCESS -> StateEventType.ROOM_GUEST_ACCESS - RustStateEventType.ROOM_HISTORY_VISIBILITY -> StateEventType.ROOM_HISTORY_VISIBILITY - RustStateEventType.ROOM_JOIN_RULES -> StateEventType.ROOM_JOIN_RULES - RustStateEventType.ROOM_MEMBER_EVENT -> StateEventType.ROOM_MEMBER_EVENT - RustStateEventType.ROOM_NAME -> StateEventType.ROOM_NAME - RustStateEventType.ROOM_PINNED_EVENTS -> StateEventType.ROOM_PINNED_EVENTS - RustStateEventType.ROOM_POWER_LEVELS -> StateEventType.ROOM_POWER_LEVELS - RustStateEventType.ROOM_SERVER_ACL -> StateEventType.ROOM_SERVER_ACL - RustStateEventType.ROOM_THIRD_PARTY_INVITE -> StateEventType.ROOM_THIRD_PARTY_INVITE - RustStateEventType.ROOM_TOMBSTONE -> StateEventType.ROOM_TOMBSTONE - RustStateEventType.ROOM_TOPIC -> StateEventType.ROOM_TOPIC - RustStateEventType.SPACE_CHILD -> StateEventType.SPACE_CHILD - RustStateEventType.SPACE_PARENT -> StateEventType.SPACE_PARENT + RustStateEventType.PolicyRuleRoom -> StateEventType.PolicyRuleRoom + RustStateEventType.PolicyRuleServer -> StateEventType.PolicyRuleServer + RustStateEventType.PolicyRuleUser -> StateEventType.PolicyRuleUser + RustStateEventType.CallMember -> StateEventType.CallMember + RustStateEventType.RoomAliases -> StateEventType.RoomAliases + RustStateEventType.RoomAvatar -> StateEventType.RoomAvatar + RustStateEventType.RoomCanonicalAlias -> StateEventType.RoomCanonicalAlias + RustStateEventType.RoomCreate -> StateEventType.RoomCreate + RustStateEventType.RoomEncryption -> StateEventType.RoomEncryption + RustStateEventType.RoomGuestAccess -> StateEventType.RoomGuestAccess + RustStateEventType.RoomHistoryVisibility -> StateEventType.RoomHistoryVisibility + RustStateEventType.RoomJoinRules -> StateEventType.RoomJoinRules + RustStateEventType.RoomMemberEvent -> StateEventType.RoomMemberEvent + RustStateEventType.RoomName -> StateEventType.RoomName + RustStateEventType.RoomPinnedEvents -> StateEventType.RoomPinnedEvents + RustStateEventType.RoomPowerLevels -> StateEventType.RoomPowerLevels + RustStateEventType.RoomServerAcl -> StateEventType.RoomServerAcl + RustStateEventType.RoomThirdPartyInvite -> StateEventType.RoomThirdPartyInvite + RustStateEventType.RoomTombstone -> StateEventType.RoomTombstone + RustStateEventType.RoomTopic -> StateEventType.RoomTopic + RustStateEventType.SpaceChild -> StateEventType.SpaceChild + RustStateEventType.SpaceParent -> StateEventType.SpaceParent + RustStateEventType.BeaconInfo -> StateEventType.BeaconInfo + RustStateEventType.MemberHints -> StateEventType.MemberHints + RustStateEventType.RoomImagePack -> StateEventType.RoomImagePack + RustStateEventType.RoomLanguage -> StateEventType.RoomLanguage + is RustStateEventType.Custom -> StateEventType.Custom(value) } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/powerlevels/RoomPowerLevelsValuesMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/powerlevels/RoomPowerLevelsValuesMapper.kt index 081d08457ee..5e2a1c82da5 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/powerlevels/RoomPowerLevelsValuesMapper.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/powerlevels/RoomPowerLevelsValuesMapper.kt @@ -19,7 +19,8 @@ object RoomPowerLevelsValuesMapper { ban = values.ban, invite = values.invite, kick = values.kick, - sendEvents = values.eventsDefault, + eventsDefault = values.eventsDefault, + stateDefault = values.stateDefault, redactEvents = values.redact, roomName = values.roomName, roomAvatar = values.roomAvatar, diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/powerlevels/RustRoomPermissions.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/powerlevels/RustRoomPermissions.kt new file mode 100644 index 00000000000..57e20b166f7 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/powerlevels/RustRoomPermissions.kt @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2025 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.room.powerlevels + +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.room.MessageEventType +import io.element.android.libraries.matrix.api.room.StateEventType +import io.element.android.libraries.matrix.api.room.powerlevels.RoomPermissions +import io.element.android.libraries.matrix.impl.room.map +import org.matrix.rustcomponents.sdk.RoomPowerLevels + +class RustRoomPermissions( + private val inner: RoomPowerLevels, +) : RoomPermissions { + override fun canOwnUserBan(): Boolean { + return inner.canOwnUserBan() + } + + override fun canOwnUserInvite(): Boolean { + return inner.canOwnUserInvite() + } + + override fun canOwnUserKick(): Boolean { + return inner.canOwnUserKick() + } + + override fun canOwnUserPinUnpin(): Boolean { + return inner.canOwnUserPinUnpin() + } + + override fun canOwnUserRedactOther(): Boolean { + return inner.canOwnUserRedactOther() + } + + override fun canOwnUserRedactOwn(): Boolean { + return inner.canOwnUserRedactOwn() + } + + override fun canOwnUserSendMessage(message: MessageEventType): Boolean { + return inner.canOwnUserSendMessage(message.map()) + } + + override fun canOwnUserSendState(stateEvent: StateEventType): Boolean { + return inner.canOwnUserSendState(stateEvent.map()) + } + + override fun canOwnUserTriggerRoomNotification(): Boolean { + return inner.canOwnUserTriggerRoomNotification() + } + + override fun canUserBan(userId: UserId): Boolean { + return inner.canUserBan(userId.value) + } + + override fun canUserInvite(userId: UserId): Boolean { + return inner.canUserInvite(userId.value) + } + + override fun canUserKick(userId: UserId): Boolean { + return inner.canUserKick(userId.value) + } + + override fun canUserPinUnpin(userId: UserId): Boolean { + return inner.canUserPinUnpin(userId.value) + } + + override fun canUserRedactOther(userId: UserId): Boolean { + return inner.canUserRedactOther(userId.value) + } + + override fun canUserRedactOwn(userId: UserId): Boolean { + return inner.canUserRedactOwn(userId.value) + } + + override fun canUserSendMessage(userId: UserId, message: MessageEventType): Boolean { + return inner.canUserSendMessage(userId.value, message.map()) + } + + override fun canUserSendState(userId: UserId, stateEvent: StateEventType): Boolean { + return inner.canUserSendState(userId.value, stateEvent.map()) + } + + override fun canUserTriggerRoomNotification(userId: UserId): Boolean { + return inner.canUserTriggerRoomNotification(userId.value) + } + + override fun close() { + inner.close() + } +} 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 84e759040d3..ae241e9c02f 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 @@ -48,7 +48,7 @@ fun RoomListInterface.loadingStateFlow(): Flow = try { send(result.state) } catch (exception: Exception) { - Timber.d("loadingStateFlow() initialState failed.") + Timber.d(exception, "loadingStateFlow() initialState failed.") } result.stateStream }.catch { diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListFactory.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListFactory.kt index b15411c3829..fa3f4a8acf2 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListFactory.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListFactory.kt @@ -56,7 +56,7 @@ internal class RoomListFactory( val loadingStateFlow: MutableStateFlow = MutableStateFlow(RoomList.LoadingState.NotLoaded) val filteredSummariesFlow = MutableSharedFlow>(replay = 1, extraBufferCapacity = 1) val summariesFlow = MutableSharedFlow>(replay = 1, extraBufferCapacity = 1) - val processor = RoomSummaryListProcessor(summariesFlow, innerRoomListService, coroutineContext, roomSummaryFactory) + val processor = RoomSummaryListProcessor(summariesFlow, innerRoomListService, coroutineContext, roomSummaryFactory, analyticsService) // Makes sure we don't miss any events val dynamicEvents = MutableSharedFlow(replay = 100) val currentFilter = MutableStateFlow(initialFilter) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomSummaryFactory.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomSummaryFactory.kt index 3d5efedd54b..738c1f72ea1 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomSummaryFactory.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomSummaryFactory.kt @@ -16,6 +16,7 @@ import io.element.android.libraries.matrix.impl.timeline.item.event.TimelineEven import io.element.android.libraries.matrix.impl.timeline.item.event.map import org.matrix.rustcomponents.sdk.Room import org.matrix.rustcomponents.sdk.use +import uniffi.matrix_sdk_ui.LatestEventValueLocalState import org.matrix.rustcomponents.sdk.LatestEventValue as RustLatestEventValue class RoomSummaryFactory( @@ -24,16 +25,27 @@ class RoomSummaryFactory( ) { suspend fun create(room: Room): RoomSummary { val roomInfo = room.roomInfo().let(roomInfoMapper::map) - val latestEvent = room.newLatestEvent().use { event -> + val latestEvent = room.latestEvent().use { event -> when (event) { is RustLatestEventValue.None -> LatestEventValue.None - is RustLatestEventValue.Local -> LatestEventValue.Local( - timestamp = event.timestamp.toLong(), - content = contentMapper.map(event.content), - isSending = event.isSending, - senderId = UserId(event.sender), - senderProfile = event.profile.map(), - ) + is RustLatestEventValue.Local -> when (event.state) { + LatestEventValueLocalState.IS_SENDING, + LatestEventValueLocalState.CANNOT_BE_SENT -> LatestEventValue.Local( + timestamp = event.timestamp.toLong(), + content = contentMapper.map(event.content), + isSending = event.state == LatestEventValueLocalState.IS_SENDING, + senderId = UserId(event.sender), + senderProfile = event.profile.map(), + ) + // This is the same as a remote event, we just haven't received the local -> remote update yet + LatestEventValueLocalState.HAS_BEEN_SENT -> LatestEventValue.Remote( + timestamp = event.timestamp.toLong(), + content = contentMapper.map(event.content), + senderId = UserId(event.sender), + senderProfile = event.profile.map(), + isOwn = true, + ) + } is RustLatestEventValue.Remote -> LatestEventValue.Remote( timestamp = event.timestamp.toLong(), content = contentMapper.map(event.content), 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 3f5a9191397..968a768fa21 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 @@ -9,6 +9,7 @@ package io.element.android.libraries.matrix.impl.roomlist import io.element.android.libraries.matrix.api.roomlist.RoomSummary +import io.element.android.services.analytics.api.AnalyticsService import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -18,6 +19,7 @@ import org.matrix.rustcomponents.sdk.RoomListEntriesUpdate import org.matrix.rustcomponents.sdk.RoomListServiceInterface import org.matrix.rustcomponents.sdk.use import timber.log.Timber +import kotlin.collections.groupingBy import kotlin.coroutines.CoroutineContext class RoomSummaryListProcessor( @@ -25,26 +27,21 @@ class RoomSummaryListProcessor( private val roomListService: RoomListServiceInterface, private val coroutineContext: CoroutineContext, private val roomSummaryFactory: RoomSummaryFactory, + private val analyticsService: AnalyticsService, ) { private val mutex = Mutex() suspend fun postUpdate(updates: List) { - updateRoomSummaries { + updateRoomSummaries(updates) { Timber.v("Update rooms from postUpdates (with ${updates.size} items) on ${Thread.currentThread()}") updates.forEach { update -> applyUpdate(update) } - - // TODO remove once https://github.com/element-hq/element-x-android/issues/5031 has been confirmed as fixed - val duplicates = groupingBy { it.roomId }.eachCount().filter { it.value > 1 } - if (duplicates.isNotEmpty()) { - Timber.e("Found duplicates in room summaries after a list update from the SDK: $duplicates. Updates: $updates") - } } } suspend fun rebuildRoomSummaries() { - updateRoomSummaries { + updateRoomSummaries(emptyList()) { forEachIndexed { i, summary -> val result = buildRoomSummaryForIdentifier(summary.roomId.value) if (result != null) { @@ -112,12 +109,32 @@ class RoomSummaryListProcessor( } } - private suspend fun updateRoomSummaries(block: suspend MutableList.() -> Unit) = withContext(coroutineContext) { + private suspend fun updateRoomSummaries(updates: List, block: suspend MutableList.() -> Unit) = withContext( + coroutineContext + ) { mutex.withLock { val current = roomSummaries.replayCache.lastOrNull() val mutableRoomSummaries = current.orEmpty().toMutableList() block(mutableRoomSummaries) - roomSummaries.emit(mutableRoomSummaries) + + // TODO remove once https://github.com/element-hq/element-x-android/issues/5031 has been confirmed as fixed + val uniqueRooms = mutableRoomSummaries.distinctBy { it.roomId } + + if (uniqueRooms.size != mutableRoomSummaries.size) { + val duplicates = mutableRoomSummaries.groupingBy { it.roomId }.eachCount().filter { it.value > 1 } + if (duplicates.isNotEmpty()) { + analyticsService.trackError( + IllegalStateException( + "Found duplicates in room summaries after a list update from the SDK: $duplicates. " + + "Updates: ${updates.description()}" + ) + ) + } + } + + roomSummaries.emit(uniqueRooms) } } } + +private fun List.description(): String = joinToString { it.describe() } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceRoomList.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceRoomList.kt index c6fe86700eb..80652bee712 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceRoomList.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceRoomList.kt @@ -12,6 +12,7 @@ import io.element.android.libraries.core.extensions.runCatchingExceptions import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.spaces.SpaceRoom import io.element.android.libraries.matrix.api.spaces.SpaceRoomList +import io.element.android.services.analytics.api.AnalyticsService import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -32,6 +33,7 @@ class RustSpaceRoomList( private val innerProvider: suspend () -> InnerSpaceRoomList, private val coroutineScope: CoroutineScope, spaceRoomMapper: SpaceRoomMapper, + private val analyticsService: AnalyticsService, ) : SpaceRoomList { private val innerCompletable = CompletableDeferred() @@ -43,7 +45,8 @@ class RustSpaceRoomList( MutableStateFlow(SpaceRoomList.PaginationStatus.Idle(hasMoreToLoad = false)) private val spaceListUpdateProcessor = SpaceListUpdateProcessor( spaceRoomsFlow = spaceRoomsFlow, - mapper = spaceRoomMapper + mapper = spaceRoomMapper, + analyticsService = analyticsService, ) init { diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceService.kt index 84da9f1b528..2ce184484c1 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceService.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceService.kt @@ -17,6 +17,7 @@ import io.element.android.libraries.matrix.api.spaces.SpaceRoom import io.element.android.libraries.matrix.api.spaces.SpaceRoomList import io.element.android.libraries.matrix.api.spaces.SpaceService import io.element.android.libraries.matrix.impl.util.cancelAndDestroy +import io.element.android.services.analytics.api.AnalyticsService import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.channels.Channel @@ -41,23 +42,40 @@ class RustSpaceService( private val sessionCoroutineScope: CoroutineScope, private val sessionDispatcher: CoroutineDispatcher, private val roomMembershipObserver: RoomMembershipObserver, + private val analyticsService: AnalyticsService, ) : SpaceService { private val spaceRoomMapper = SpaceRoomMapper() override val spaceRoomsFlow = MutableSharedFlow>(replay = 1, extraBufferCapacity = 1) private val spaceListUpdateProcessor = SpaceListUpdateProcessor( spaceRoomsFlow = spaceRoomsFlow, - mapper = spaceRoomMapper + mapper = spaceRoomMapper, + analyticsService = analyticsService, ) override suspend fun joinedSpaces(): Result> = withContext(sessionDispatcher) { runCatchingExceptions { - innerSpaceService.joinedSpaces() - .map { - it.let(spaceRoomMapper::map) - } + innerSpaceService + .topLevelJoinedSpaces() + .map(spaceRoomMapper::map) } } + override suspend fun joinedParents(spaceId: RoomId): Result> = withContext(sessionDispatcher) { + runCatchingExceptions { + innerSpaceService + .joinedParentsOfChild(spaceId.value) + .map(spaceRoomMapper::map) + } + } + + override suspend fun getSpaceRoom(spaceId: RoomId): SpaceRoom? = withContext(sessionDispatcher) { + runCatchingExceptions { + innerSpaceService.getSpaceRoom(spaceId.value)?.let { spaceRoom -> + spaceRoomMapper.map(spaceRoom) + } + }.getOrNull() + } + override fun spaceRoomList(id: RoomId): SpaceRoomList { val childCoroutineScope = sessionCoroutineScope.childScope(sessionDispatcher, "SpaceRoomListScope-$this") return RustSpaceRoomList( @@ -65,6 +83,7 @@ class RustSpaceService( innerProvider = { innerSpaceService.spaceRoomList(id.value) }, coroutineScope = childCoroutineScope, spaceRoomMapper = spaceRoomMapper, + analyticsService = analyticsService, ) } @@ -79,6 +98,12 @@ class RustSpaceService( } } + override suspend fun removeChildFromSpace(spaceId: RoomId, childId: RoomId): Result = withContext(sessionDispatcher) { + runCatchingExceptions { + innerSpaceService.removeChildFromSpace(childId = childId.value, spaceId = spaceId.value) + } + } + init { innerSpaceService .spaceListUpdate() @@ -97,7 +122,7 @@ internal fun SpaceServiceInterface.spaceListUpdate(): Flow } } Timber.d("Open spaceDiffFlow for SpaceServiceInterface ${this@spaceListUpdate}") - val taskHandle = subscribeToJoinedSpaces(listener) + val taskHandle = subscribeToTopLevelJoinedSpaces(listener) awaitClose { Timber.d("Close spaceDiffFlow for SpaceServiceInterface ${this@spaceListUpdate}") taskHandle.cancelAndDestroy() diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/SpaceListUpdateProcessor.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/SpaceListUpdateProcessor.kt index 41f2cc1266a..c9763dffa8e 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/SpaceListUpdateProcessor.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/spaces/SpaceListUpdateProcessor.kt @@ -9,6 +9,7 @@ package io.element.android.libraries.matrix.impl.spaces import io.element.android.libraries.matrix.api.spaces.SpaceRoom +import io.element.android.services.analytics.api.AnalyticsService import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.sync.Mutex @@ -19,17 +20,18 @@ import timber.log.Timber internal class SpaceListUpdateProcessor( private val spaceRoomsFlow: MutableSharedFlow>, private val mapper: SpaceRoomMapper, + private val analyticsService: AnalyticsService, ) { private val mutex = Mutex() suspend fun postUpdates(updates: List) { Timber.v("Update space rooms from postUpdates (with ${updates.size} items) on ${Thread.currentThread()}") - updateSpaceRooms { + updateSpaceRooms(updates) { updates.forEach { update -> applyUpdate(update) } } } - private suspend fun updateSpaceRooms(block: MutableList.() -> Unit) = + private suspend fun updateSpaceRooms(updates: List, block: MutableList.() -> Unit) = mutex.withLock { val spaceRooms = if (spaceRoomsFlow.replayCache.isNotEmpty()) { spaceRoomsFlow.first().toMutableList() @@ -37,7 +39,17 @@ internal class SpaceListUpdateProcessor( mutableListOf() } block(spaceRooms) - spaceRoomsFlow.emit(spaceRooms) + val uniqueRooms = spaceRooms.distinctBy { it.roomId } + + // TODO remove once https://github.com/element-hq/element-x-android/issues/5031 has been confirmed as fixed + if (spaceRooms.size != uniqueRooms.size) { + val duplicateKeys = spaceRooms.groupBy { it.roomId }.filter { it.value.size > 1 }.keys + analyticsService.trackError( + IllegalStateException("Found duplicate keys in space rooms list ($duplicateKeys) after SDK updates: ${updates.description()}") + ) + } + + spaceRoomsFlow.emit(uniqueRooms) } private fun MutableList.applyUpdate(update: SpaceListUpdate) { @@ -83,3 +95,19 @@ internal class SpaceListUpdateProcessor( } } } + +private fun List.description(): String = joinToString { it.description() } + +private fun SpaceListUpdate.description(): String = when (this) { + is SpaceListUpdate.Append -> "Append(${values.map { it.roomId }})" + SpaceListUpdate.Clear -> "Clear" + is SpaceListUpdate.Insert -> "Insert($index, ${value.roomId})" + SpaceListUpdate.PopBack -> "PopBack" + SpaceListUpdate.PopFront -> "PopFront" + is SpaceListUpdate.PushBack -> "PushBack(${value.roomId})" + is SpaceListUpdate.PushFront -> "PushFront(${value.roomId})" + is SpaceListUpdate.Remove -> "Remove($index)" + is SpaceListUpdate.Reset -> "Reset(${values.map { it.roomId }})" + is SpaceListUpdate.Set -> "Set($index, ${value.roomId})" + is SpaceListUpdate.Truncate -> "Truncate($length)" +} 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 new file mode 100644 index 00000000000..7b5d9fb31c2 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/storage/SqliteStoreBuilder.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2025 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.storage + +import io.element.android.libraries.matrix.impl.paths.SessionPaths +import org.matrix.rustcomponents.sdk.ClientBuilder +import org.matrix.rustcomponents.sdk.SqliteStoreBuilder as SdkSqliteStoreBuilder + +interface SqliteStoreBuilder { + fun passphrase(passphrase: String?): SqliteStoreBuilder + fun setupClientBuilder(clientBuilder: ClientBuilder): ClientBuilder +} + +class RustSqliteStoreBuilder( + private val sessionPaths: SessionPaths, +) : SqliteStoreBuilder { + private var inner = SdkSqliteStoreBuilder( + dataPath = sessionPaths.fileDirectory.absolutePath, + cachePath = sessionPaths.cacheDirectory.absolutePath, + ) + + override fun passphrase(passphrase: String?): SqliteStoreBuilder { + inner = inner.passphrase(passphrase) + return this + } + + override fun setupClientBuilder(clientBuilder: ClientBuilder): ClientBuilder { + return clientBuilder.sqliteStore(this.inner) + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/storage/SqliteStoreBuilderProvider.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/storage/SqliteStoreBuilderProvider.kt new file mode 100644 index 00000000000..29a30f3c56c --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/storage/SqliteStoreBuilderProvider.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 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.storage + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.matrix.impl.paths.SessionPaths + +interface SqliteStoreBuilderProvider { + fun provide(sessionPaths: SessionPaths): SqliteStoreBuilder +} + +@ContributesBinding(AppScope::class) +class RustSqliteStoreBuilderProvider : SqliteStoreBuilderProvider { + override fun provide(sessionPaths: SessionPaths): SqliteStoreBuilder { + return RustSqliteStoreBuilder(sessionPaths) + } +} 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 67151698991..a95b2acccc7 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 @@ -30,7 +30,7 @@ import org.matrix.rustcomponents.sdk.QueueWedgeError import org.matrix.rustcomponents.sdk.Reaction import org.matrix.rustcomponents.sdk.ShieldState import org.matrix.rustcomponents.sdk.TimelineItemContent -import uniffi.matrix_sdk_common.ShieldStateCode +import uniffi.matrix_sdk_ui.TimelineEventShieldStateCode import org.matrix.rustcomponents.sdk.EventSendState as RustEventSendState import org.matrix.rustcomponents.sdk.EventTimelineItem as RustEventTimelineItem import org.matrix.rustcomponents.sdk.EventTimelineItemDebugInfo as RustEventTimelineItemDebugInfo @@ -58,7 +58,7 @@ class EventTimelineItemMapper( content = contentMapper.map(content), origin = origin?.map(), timelineItemDebugInfoProvider = { lazyProvider.debugInfo().map() }, - messageShieldProvider = { strict -> lazyProvider.getShields(strict)?.map() }, + messageShieldProvider = { strict -> lazyProvider.getShields(strict).map() }, sendHandleProvider = { lazyProvider.getSendHandle()?.let(::RustSendHandle) } ) } @@ -182,13 +182,13 @@ private fun ShieldState?.map(): MessageShield? { is ShieldState.Red -> true } return when (shieldStateCode) { - ShieldStateCode.AUTHENTICITY_NOT_GUARANTEED -> MessageShield.AuthenticityNotGuaranteed(isCritical) - ShieldStateCode.UNKNOWN_DEVICE -> MessageShield.UnknownDevice(isCritical) - ShieldStateCode.UNSIGNED_DEVICE -> MessageShield.UnsignedDevice(isCritical) - ShieldStateCode.UNVERIFIED_IDENTITY -> MessageShield.UnverifiedIdentity(isCritical) - ShieldStateCode.SENT_IN_CLEAR -> MessageShield.SentInClear(isCritical) - ShieldStateCode.VERIFICATION_VIOLATION -> MessageShield.VerificationViolation(isCritical) - ShieldStateCode.MISMATCHED_SENDER -> MessageShield.MismatchedSender(isCritical) + TimelineEventShieldStateCode.AUTHENTICITY_NOT_GUARANTEED -> MessageShield.AuthenticityNotGuaranteed(isCritical) + TimelineEventShieldStateCode.UNKNOWN_DEVICE -> MessageShield.UnknownDevice(isCritical) + TimelineEventShieldStateCode.UNSIGNED_DEVICE -> MessageShield.UnsignedDevice(isCritical) + TimelineEventShieldStateCode.UNVERIFIED_IDENTITY -> MessageShield.UnverifiedIdentity(isCritical) + TimelineEventShieldStateCode.SENT_IN_CLEAR -> MessageShield.SentInClear(isCritical) + TimelineEventShieldStateCode.VERIFICATION_VIOLATION -> MessageShield.VerificationViolation(isCritical) + TimelineEventShieldStateCode.MISMATCHED_SENDER -> MessageShield.MismatchedSender(isCritical) } } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/TimelineEventContentMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/TimelineEventContentMapper.kt index d0545d3f0a6..b1758fb7340 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/TimelineEventContentMapper.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/TimelineEventContentMapper.kt @@ -36,6 +36,7 @@ import io.element.android.libraries.matrix.impl.room.join.map import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableMap import org.matrix.rustcomponents.sdk.EmbeddedEventDetails +import org.matrix.rustcomponents.sdk.MsgLikeContent import org.matrix.rustcomponents.sdk.MsgLikeKind import org.matrix.rustcomponents.sdk.TimelineItemContent import org.matrix.rustcomponents.sdk.use @@ -68,37 +69,11 @@ class TimelineEventContentMapper( when (val kind = it.content.kind) { is MsgLikeKind.Message -> { val inReplyTo = it.content.inReplyTo - val threadSummary = it.content.threadSummary?.use { summary -> - val numberOfReplies = summary.numReplies().toLong() - val latestEvent = summary.latestEvent() - val details = when (latestEvent) { - is EmbeddedEventDetails.Unavailable -> AsyncData.Uninitialized - is EmbeddedEventDetails.Pending -> AsyncData.Loading() - is EmbeddedEventDetails.Error -> AsyncData.Failure(IllegalStateException(latestEvent.message)) - is EmbeddedEventDetails.Ready -> { - AsyncData.Success( - EmbeddedEventInfo( - eventOrTransactionId = latestEvent.eventOrTransactionId.map(), - content = map(latestEvent.content), - senderId = UserId(latestEvent.sender), - senderProfile = latestEvent.senderProfile.map(), - timestamp = latestEvent.timestamp.toLong(), - ) - ) - } - } - ThreadSummary( - latestEvent = details, - numberOfReplies = numberOfReplies, - ) - } - val threadRootId = it.content.threadRoot?.let(::ThreadId) - val threadInfo = when { - threadSummary != null -> EventThreadInfo.ThreadRoot(threadSummary) - threadRootId != null -> EventThreadInfo.ThreadResponse(threadRootId) - else -> null - } - eventMessageMapper.map(kind, inReplyTo, threadInfo) + eventMessageMapper.map( + message = kind, + inReplyTo = inReplyTo, + threadInfo = extractThreadInfo(it.content) + ) } is MsgLikeKind.Redacted -> { RedactedContent @@ -114,11 +89,13 @@ class TimelineEventContentMapper( }.toImmutableMap(), endTime = kind.endTime, isEdited = kind.hasBeenEdited, + threadInfo = extractThreadInfo(it.content), ) } is MsgLikeKind.UnableToDecrypt -> { UnableToDecryptContent( - data = kind.msg.map() + data = kind.msg.map(), + threadInfo = extractThreadInfo(it.content), ) } is MsgLikeKind.Sticker -> { @@ -127,6 +104,7 @@ class TimelineEventContentMapper( body = null, info = kind.info.map(), source = kind.source.map(), + threadInfo = extractThreadInfo(it.content), ) } is MsgLikeKind.Other -> UnknownContent @@ -159,6 +137,43 @@ class TimelineEventContentMapper( } } } + + private fun extractThreadInfo(content: MsgLikeContent): EventThreadInfo? { + val threadSummary = extractThreadSummary(content.threadSummary) + val threadRootId = content.threadRoot?.let(::ThreadId) + return when { + threadSummary != null -> EventThreadInfo.ThreadRoot(threadSummary) + threadRootId != null -> EventThreadInfo.ThreadResponse(threadRootId) + else -> null + } + } + + private fun extractThreadSummary(threadSummary: org.matrix.rustcomponents.sdk.ThreadSummary?): ThreadSummary? { + return threadSummary?.use { summary -> + val numberOfReplies = summary.numReplies().toLong() + val latestEvent = summary.latestEvent() + val details = when (latestEvent) { + is EmbeddedEventDetails.Unavailable -> AsyncData.Uninitialized + is EmbeddedEventDetails.Pending -> AsyncData.Loading() + is EmbeddedEventDetails.Error -> AsyncData.Failure(IllegalStateException(latestEvent.message)) + is EmbeddedEventDetails.Ready -> { + AsyncData.Success( + EmbeddedEventInfo( + eventOrTransactionId = latestEvent.eventOrTransactionId.map(), + content = map(latestEvent.content), + senderId = UserId(latestEvent.sender), + senderProfile = latestEvent.senderProfile.map(), + timestamp = latestEvent.timestamp.toLong(), + ) + ) + } + } + ThreadSummary( + latestEvent = details, + numberOfReplies = numberOfReplies, + ) + } + } } private fun RustMembershipChange.map(): MembershipChange { diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/tracing/RustTracingService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/tracing/RustTracingService.kt index 204fece309c..6f235cd0db7 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/tracing/RustTracingService.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/tracing/RustTracingService.kt @@ -60,5 +60,5 @@ fun TracingConfiguration.map(): org.matrix.rustcomponents.sdk.TracingConfigurati extraTargets = extraTargets, traceLogPacks = traceLogPacks.map(), writeToFiles = writesToFilesConfiguration.toTracingFileConfiguration(), - sentryDsn = null, + sentryDsn = sdkSentryDsn, ) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/tracing/TraceLogPacksMapping.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/tracing/TraceLogPacksMapping.kt index 0e26935b5a5..ec70fc90f87 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/tracing/TraceLogPacksMapping.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/tracing/TraceLogPacksMapping.kt @@ -16,6 +16,8 @@ fun TraceLogPack.map(): RustTraceLogPack = when (this) { TraceLogPack.EVENT_CACHE -> RustTraceLogPack.EVENT_CACHE TraceLogPack.TIMELINE -> RustTraceLogPack.TIMELINE TraceLogPack.NOTIFICATION_CLIENT -> RustTraceLogPack.NOTIFICATION_CLIENT + TraceLogPack.LATEST_EVENTS -> RustTraceLogPack.LATEST_EVENTS + TraceLogPack.SYNC_PROFILING -> RustTraceLogPack.SYNC_PROFILING } fun Collection.map(): List { diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/util/Error.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/util/Error.kt index 0a1f45d1d17..70fea1287e4 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/util/Error.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/util/Error.kt @@ -14,10 +14,10 @@ import timber.log.Timber fun logError(throwable: Throwable) { when (throwable) { is ClientException.Generic -> { - Timber.e("Error ${throwable.msg}", throwable) + Timber.e(throwable, "Error ${throwable.msg}") } else -> { - Timber.e("Error", throwable) + Timber.e(throwable, "Error") } } } 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 3014618b9da..7fb2935897b 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 @@ -44,6 +44,7 @@ import org.matrix.rustcomponents.sdk.VerificationState import org.matrix.rustcomponents.sdk.VerificationStateListener import org.matrix.rustcomponents.sdk.use import timber.log.Timber +import java.util.concurrent.atomic.AtomicBoolean import kotlin.time.Duration.Companion.seconds import org.matrix.rustcomponents.sdk.SessionVerificationData as RustSessionVerificationData import org.matrix.rustcomponents.sdk.SessionVerificationRequestDetails as RustSessionVerificationRequestDetails @@ -66,9 +67,17 @@ class RustSessionVerificationService( private val recoveryState = MutableStateFlow(RecoveryState.UNKNOWN) + private val isInitialized = AtomicBoolean(false) + // Listen for changes in verification status and update accordingly private val verificationStateListenerTaskHandle = encryptionService.verificationStateListener(object : VerificationStateListener { override fun onUpdate(status: VerificationState) { + // If the status is verified, just use it. It can't be a false positive like unknown or unverified + if (!isInitialized.get() && status != VerificationState.VERIFIED) { + Timber.d("Discarding new verifications state: $status. E2EE is not initialised yet") + return + } + Timber.d("New verification state: $status") _sessionVerifiedStatus.value = status.map() } @@ -77,6 +86,11 @@ class RustSessionVerificationService( // In case we enter the recovery key instead we check changes in the recovery state, since the listener above won't be triggered private val recoveryStateListenerTaskHandle = encryptionService.recoveryStateListener(object : RecoveryStateListener { override fun onUpdate(status: RecoveryState) { + if (!isInitialized.get()) { + Timber.d("Discarding new recovery state: $status. E2EE is not initialised yet") + return + } + Timber.d("New recovery state: $status") // We could check the `RecoveryState`, but it's easier to just use the verification state directly recoveryState.value = status @@ -87,7 +101,7 @@ class RustSessionVerificationService( * The internal service that checks verification can only run after the initial sync. * This [StateFlow] will notify consumers when the service is ready to be used. */ - private val isReady = isSyncServiceReady.stateIn(sessionCoroutineScope, SharingStarted.Eagerly, false) + private val canVerify = isSyncServiceReady.stateIn(sessionCoroutineScope, SharingStarted.Eagerly, false) override val needsSessionVerification = sessionVerifiedStatus.map { verificationStatus -> verificationStatus == SessionVerifiedStatus.NotVerified @@ -99,14 +113,11 @@ class RustSessionVerificationService( private var listener: SessionVerificationServiceListener? = null + private val initializationMutex = Mutex() + init { // Instantiate the verification controller when possible, this is needed to get incoming verification requests - sessionCoroutineScope.launch { - tryOrNull { - encryptionService.waitForE2eeInitializationTasks() - initVerificationControllerIfNeeded() - } - } + sessionCoroutineScope.launch { ensureEncryptionIsInitialized() } } override fun setListener(listener: SessionVerificationServiceListener?) { @@ -114,13 +125,13 @@ class RustSessionVerificationService( } override suspend fun requestCurrentSessionVerification() = tryOrFail { - initVerificationControllerIfNeeded() + ensureEncryptionIsInitialized() verificationController.requestDeviceVerification() currentVerificationRequest = VerificationRequest.Outgoing.CurrentSession } override suspend fun requestUserVerification(userId: UserId) = tryOrFail { - initVerificationControllerIfNeeded() + ensureEncryptionIsInitialized() verificationController.requestUserVerification(userId.value) currentVerificationRequest = VerificationRequest.Outgoing.User(userId) } @@ -140,7 +151,7 @@ class RustSessionVerificationService( } override suspend fun acknowledgeVerificationRequest(verificationRequest: VerificationRequest.Incoming) = tryOrFail { - initVerificationControllerIfNeeded() + ensureEncryptionIsInitialized() verificationController.acknowledgeVerificationRequest( senderId = verificationRequest.details.senderProfile.userId.value, flowId = verificationRequest.details.flowId.value, @@ -225,7 +236,7 @@ class RustSessionVerificationService( override suspend fun reset(cancelAnyPendingVerificationAttempt: Boolean) { currentVerificationRequest = null - if (isReady.value && cancelAnyPendingVerificationAttempt) { + if (canVerify.value && cancelAnyPendingVerificationAttempt) { // Cancel any pending verification attempt tryOrNull { verificationController.cancelVerification() } } @@ -241,23 +252,28 @@ class RustSessionVerificationService( } } - private var initControllerMutex = Mutex() - - private suspend fun initVerificationControllerIfNeeded() = initControllerMutex.withLock { - if (!this::verificationController.isInitialized) { - tryOrFail { - verificationController = client.getSessionVerificationController() - verificationController.setDelegate(this) - } - } - } - private fun updateVerificationStatus() { runCatchingExceptions { _sessionVerifiedStatus.value = encryptionService.verificationState().map() Timber.d("New verification status: ${_sessionVerifiedStatus.value}") } } + + private suspend fun ensureEncryptionIsInitialized() = initializationMutex.withLock { + // We're keeping the separate checks instead of unconditionally calling the suspend methods + // so we can skip crossing the FFI layer when it's not needed + tryOrFail { + if (!isInitialized.get()) { + encryptionService.waitForE2eeInitializationTasks() + isInitialized.set(true) + } + + if (!this::verificationController.isInitialized) { + verificationController = client.getSessionVerificationController() + verificationController.setDelegate(this) + } + } + } } private fun VerificationState.map() = when (this) { diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/widget/RustWidgetDriver.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/widget/RustWidgetDriver.kt index 1d1824d240c..b908082ab60 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/widget/RustWidgetDriver.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/widget/RustWidgetDriver.kt @@ -62,7 +62,7 @@ class RustWidgetDriver( override suspend fun send(message: String) { try { driverAndHandle.handle.send(message) - } catch (e: IllegalStateException) { + } catch (_: IllegalStateException) { // The handle is closed, ignore } } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/workmanager/PerformDatabaseVacuumWorkManagerRequest.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/workmanager/PerformDatabaseVacuumWorkManagerRequest.kt new file mode 100644 index 00000000000..9c192bd96db --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/workmanager/PerformDatabaseVacuumWorkManagerRequest.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2025 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.workmanager + +import androidx.work.Constraints +import androidx.work.Data +import androidx.work.PeriodicWorkRequest +import androidx.work.WorkRequest +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.impl.workmanager.VacuumDatabaseWorker.Companion.SESSION_ID_PARAM +import io.element.android.libraries.workmanager.api.WorkManagerRequest +import io.element.android.libraries.workmanager.api.WorkManagerRequestType +import io.element.android.libraries.workmanager.api.workManagerTag +import java.util.concurrent.TimeUnit + +class PerformDatabaseVacuumWorkManagerRequest( + private val sessionId: SessionId, +) : WorkManagerRequest { + override fun build(): Result> { + val data = Data.Builder().putString(SESSION_ID_PARAM, sessionId.value).build() + val workRequest = PeriodicWorkRequest.Builder( + workerClass = VacuumDatabaseWorker::class, + // Run once a day + repeatInterval = 1, + repeatIntervalTimeUnit = TimeUnit.DAYS, + ) + .addTag(workManagerTag(sessionId, WorkManagerRequestType.DB_VACUUM)) + .setInputData(data) + // Only run when the device is idle to avoid impacting user experience + .setConstraints(Constraints.Builder().setRequiresDeviceIdle(true).build()) + .build() + + return Result.success(listOf(workRequest)) + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/workmanager/VacuumDatabaseWorker.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/workmanager/VacuumDatabaseWorker.kt new file mode 100644 index 00000000000..d52edddbb4c --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/workmanager/VacuumDatabaseWorker.kt @@ -0,0 +1,63 @@ +/* + * Copyright (c) 2025 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.workmanager + +import android.content.Context +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.workmanager.api.di.MetroWorkerFactory +import io.element.android.libraries.workmanager.api.di.WorkerKey +import io.element.android.services.analytics.api.AnalyticsService +import io.element.android.services.analytics.api.recordTransaction +import timber.log.Timber + +@AssistedInject +class VacuumDatabaseWorker( + @Assisted workerParams: WorkerParameters, + @ApplicationContext private val context: Context, + private val matrixClientProvider: MatrixClientProvider, + private val analyticsService: AnalyticsService, +) : CoroutineWorker(context, workerParams) { + companion object { + const val SESSION_ID_PARAM = "session_id" + } + + override suspend fun doWork(): Result { + Timber.d("Starting database vacuuming...") + val sessionId = inputData.getString(SESSION_ID_PARAM)?.let(::SessionId) ?: return Result.failure() + val client = matrixClientProvider.getOrRestore(sessionId).getOrNull() ?: return Result.failure() + return analyticsService.recordTransaction("Vacuuming DBs", "vacuuming") { transaction -> + client.performDatabaseVacuum() + .fold( + onSuccess = { + Timber.d("Database vacuuming finished successfully") + Result.success() + }, + onFailure = { error -> + transaction.attachError(error) + Timber.e(error, "Database vacuuming failed") + Result.failure() + } + ) + } + } + + @ContributesIntoMap(AppScope::class, binding = binding>()) + @WorkerKey(VacuumDatabaseWorker::class) + @AssistedFactory + interface Factory : MetroWorkerFactory.WorkerInstanceFactory +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/FakeFfiSqliteStoreBuilder.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/FakeFfiSqliteStoreBuilder.kt new file mode 100644 index 00000000000..8e3240f5be6 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/FakeFfiSqliteStoreBuilder.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 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.NoHandle +import org.matrix.rustcomponents.sdk.SqliteStoreBuilder + +class FakeFfiSqliteStoreBuilder : SqliteStoreBuilder(NoHandle) { + override fun cacheSize(cacheSize: UInt?): SqliteStoreBuilder = this + override fun journalSizeLimit(limit: UInt?): SqliteStoreBuilder = this + override fun passphrase(passphrase: String?): SqliteStoreBuilder = this + override fun poolMaxSize(poolMaxSize: UInt?): SqliteStoreBuilder = this + override fun systemIsMemoryConstrained(): SqliteStoreBuilder = this +} 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 471efa30a86..fa69752afeb 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 @@ -14,26 +14,33 @@ import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.impl.auth.FakeProxyProvider import io.element.android.libraries.matrix.impl.auth.FakeUserCertificatesProvider import io.element.android.libraries.matrix.impl.room.FakeTimelineEventTypeFilterFactory +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.sessionstorage.test.aSessionData +import io.element.android.libraries.workmanager.api.WorkManagerRequest +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.test.TestScope import kotlinx.coroutines.test.runTest -import org.junit.Ignore import org.junit.Test import java.io.File -@Ignore("JNA direct mapping has broken unit tests with FFI fakes") class RustMatrixClientFactoryTest { @Test fun test() = runTest { - val sut = createRustMatrixClientFactory() + val scheduleVacuumLambda = lambdaRecorder {} + val workManagerScheduler = FakeWorkManagerScheduler(submitLambda = scheduleVacuumLambda) + val sut = createRustMatrixClientFactory(workManagerScheduler = workManagerScheduler) + val result = sut.create(aSessionData()) + assertThat(result.sessionId).isEqualTo(SessionId("@alice:server.org")) + scheduleVacuumLambda.assertions().isCalledOnce() result.destroy() } } @@ -44,6 +51,7 @@ fun TestScope.createRustMatrixClientFactory( updateUserProfileResult = { _, _, _ -> }, ), clientBuilderProvider: ClientBuilderProvider = FakeClientBuilderProvider(), + workManagerScheduler: FakeWorkManagerScheduler = FakeWorkManagerScheduler(), ) = RustMatrixClientFactory( cacheDirectory = cacheDirectory, appCoroutineScope = backgroundScope, @@ -57,4 +65,6 @@ fun TestScope.createRustMatrixClientFactory( featureFlagService = FakeFeatureFlagService(), timelineEventTypeFilterFactory = FakeTimelineEventTypeFilterFactory(), clientBuilderProvider = clientBuilderProvider, + sqliteStoreBuilderProvider = FakeSqliteStoreBuilderProvider(), + 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 06bd91eb240..fc459dba868 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 @@ -11,6 +11,7 @@ package io.element.android.libraries.matrix.impl 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.impl.fixtures.fakes.FakeFfiClient import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiSyncService @@ -22,6 +23,7 @@ import io.element.android.libraries.matrix.test.A_USER_NAME 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.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 @@ -31,13 +33,12 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.advanceUntilIdle import kotlinx.coroutines.test.runTest -import org.junit.Ignore import org.junit.Test import org.matrix.rustcomponents.sdk.Client +import org.matrix.rustcomponents.sdk.StoreSizes import org.matrix.rustcomponents.sdk.UserProfile import java.io.File -@Ignore("JNA direct mapping has broken unit tests with FFI fakes") class RustMatrixClientTest { @Test fun `ensure that sessionId and deviceId can be retrieved from the client`() = runTest { @@ -99,6 +100,20 @@ class RustMatrixClientTest { client.destroy() } + @Test + fun `getDatabaseSizes returns the database sizes`() = runTest { + val client = createRustMatrixClient( + client = FakeFfiClient(getStoreSizesResult = { StoreSizes(null, 10uL, 11uL, 12uL) }) + ) + + client.getDatabaseSizes().getOrThrow().run { + assertThat(cryptoStore).isNull() + assertThat(stateStore).isEqualTo(10.bytes) + assertThat(eventCacheStore).isEqualTo(11.bytes) + assertThat(mediaStore).isEqualTo(12.bytes) + } + } + private fun TestScope.createRustMatrixClient( client: Client = FakeFfiClient(), sessionStore: SessionStore = InMemorySessionStore( @@ -118,5 +133,6 @@ class RustMatrixClientTest { timelineEventTypeFilterFactory = FakeTimelineEventTypeFilterFactory(), featureFlagService = FakeFeatureFlagService(), analyticsService = FakeAnalyticsService(), + workManagerScheduler = FakeWorkManagerScheduler(submitLambda = {}), ) } diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/HomeserverDetailsKtTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/HomeserverDetailsKtTest.kt index 82fa3dfbc76..a5c7b2dbc80 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/HomeserverDetailsKtTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/HomeserverDetailsKtTest.kt @@ -11,10 +11,8 @@ package io.element.android.libraries.matrix.impl.auth import com.google.common.truth.Truth.assertThat import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiHomeserverLoginDetails -import org.junit.Ignore import org.junit.Test -@Ignore("JNA direct mapping has broken unit tests with FFI fakes") class HomeserverDetailsKtTest { @Test fun `map should be correct`() { diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/RustHomeserverLoginCompatibilityCheckerTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/RustHomeserverLoginCompatibilityCheckerTest.kt index c0122b2a7c7..50d1f3723b4 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/RustHomeserverLoginCompatibilityCheckerTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/RustHomeserverLoginCompatibilityCheckerTest.kt @@ -14,10 +14,8 @@ 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.fixtures.fakes.FakeFfiHomeserverLoginDetails import kotlinx.coroutines.test.runTest -import org.junit.Ignore import org.junit.Test -@Ignore("JNA direct mapping has broken unit tests with FFI fakes") class RustHomeserverLoginCompatibilityCheckerTest { @Test fun `check - is valid if it supports OIDC login`() = runTest { 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 f620e942a9c..f4ce7b1fdd8 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationServiceTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationServiceTest.kt @@ -23,12 +23,10 @@ import io.element.android.libraries.sessionstorage.test.InMemorySessionStore import io.element.android.tests.testutils.testCoroutineDispatchers import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest -import org.junit.Ignore import org.junit.Test import java.io.File class RustMatrixAuthenticationServiceTest { - @Ignore("JNA direct mapping has broken unit tests with FFI fakes") @Test fun `setHomeserver is successful`() = runTest { val sut = createRustMatrixAuthenticationService( diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/qrlogin/SdkQrCodeLoginDataTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/qrlogin/SdkQrCodeLoginDataTest.kt index d4c71f2a831..a0c8f4598fa 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/qrlogin/SdkQrCodeLoginDataTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/qrlogin/SdkQrCodeLoginDataTest.kt @@ -11,10 +11,8 @@ package io.element.android.libraries.matrix.impl.auth.qrlogin import com.google.common.truth.Truth.assertThat import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiQrCodeData import io.element.android.libraries.matrix.test.A_HOMESERVER_URL -import org.junit.Ignore import org.junit.Test -@Ignore("JNA direct mapping has broken unit tests with FFI fakes") class SdkQrCodeLoginDataTest { @Test fun `getServer reads the value from the Rust side, null case`() { 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 1e5692d59c6..41823a0fbb4 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 @@ -21,22 +21,24 @@ import org.matrix.rustcomponents.sdk.ShieldState import org.matrix.rustcomponents.sdk.TimelineItemContent import uniffi.matrix_sdk_ui.EventItemOrigin -fun aRustEventTimelineItem( +internal fun aRustEventTimelineItem( isRemote: Boolean = true, eventOrTransactionId: EventOrTransactionId = EventOrTransactionId.EventId(AN_EVENT_ID.value), sender: String = A_USER_ID.value, senderProfile: ProfileDetails = ProfileDetails.Unavailable, isOwn: Boolean = true, isEditable: Boolean = true, - content: TimelineItemContent = aRustTimelineItemMessageContent(), + content: TimelineItemContent = aRustTimelineItemContentMsgLike(), timestamp: ULong = 0uL, debugInfo: EventTimelineItemDebugInfo = anEventTimelineItemDebugInfo(), localSendState: EventSendState? = null, readReceipts: Map = emptyMap(), origin: EventItemOrigin? = EventItemOrigin.SYNC, canBeRepliedTo: Boolean = true, - shieldsState: ShieldState? = null, + shieldsState: ShieldState = ShieldState.None, localCreatedAt: ULong? = null, + forwarder: String? = null, + forwarderProfile: ProfileDetails? = null, ) = EventTimelineItem( isRemote = isRemote, eventOrTransactionId = eventOrTransactionId, @@ -54,5 +56,7 @@ fun aRustEventTimelineItem( lazyProvider = FakeFfiLazyTimelineItemProvider( debugInfo = debugInfo, shieldsState = shieldsState, - ) + ), + forwarder = forwarder, + forwarderProfile = forwarderProfile, ) diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/EventTimelineItemDebugInfo.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/EventTimelineItemDebugInfo.kt index 6995c4915f4..badd4c3aba1 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/EventTimelineItemDebugInfo.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/EventTimelineItemDebugInfo.kt @@ -10,7 +10,7 @@ package io.element.android.libraries.matrix.impl.fixtures.factories import org.matrix.rustcomponents.sdk.EventTimelineItemDebugInfo -fun anEventTimelineItemDebugInfo( +internal fun anEventTimelineItemDebugInfo( model: String = "model", originalJson: String? = null, latestEditJson: String? = null, diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/NotificationItem.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/NotificationItem.kt index e5b77b249c7..672439ad6ce 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/NotificationItem.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/NotificationItem.kt @@ -13,6 +13,7 @@ import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiTimelineEv import io.element.android.libraries.matrix.test.A_ROOM_NAME import io.element.android.libraries.matrix.test.A_USER_NAME import org.matrix.rustcomponents.sdk.Action +import org.matrix.rustcomponents.sdk.BatchNotificationResult import org.matrix.rustcomponents.sdk.JoinRule import org.matrix.rustcomponents.sdk.NotificationEvent import org.matrix.rustcomponents.sdk.NotificationItem @@ -21,7 +22,7 @@ import org.matrix.rustcomponents.sdk.NotificationSenderInfo import org.matrix.rustcomponents.sdk.NotificationStatus import org.matrix.rustcomponents.sdk.TimelineEvent -fun aRustNotificationItem( +internal fun aRustNotificationItem( event: NotificationEvent = aRustNotificationEventTimeline(), senderInfo: NotificationSenderInfo = aRustNotificationSenderInfo(), roomInfo: NotificationRoomInfo = aRustNotificationRoomInfo(), @@ -39,13 +40,13 @@ fun aRustNotificationItem( actions = actions, ) -fun aRustBatchNotificationResult( +internal fun aRustBatchNotificationResultOk( notificationStatus: NotificationStatus = NotificationStatus.Event(aRustNotificationItem()), -) = org.matrix.rustcomponents.sdk.BatchNotificationResult.Ok( +) = BatchNotificationResult.Ok( status = notificationStatus, ) -fun aRustNotificationSenderInfo( +internal fun aRustNotificationSenderInfo( displayName: String? = A_USER_NAME, avatarUrl: String? = null, isNameAmbiguous: Boolean = false, @@ -55,7 +56,7 @@ fun aRustNotificationSenderInfo( isNameAmbiguous = isNameAmbiguous, ) -fun aRustNotificationRoomInfo( +internal fun aRustNotificationRoomInfo( displayName: String = A_ROOM_NAME, avatarUrl: String? = null, canonicalAlias: String? = null, @@ -77,7 +78,7 @@ fun aRustNotificationRoomInfo( isSpace = isSpace, ) -fun aRustNotificationEventTimeline( +internal fun aRustNotificationEventTimeline( event: TimelineEvent = FakeFfiTimelineEvent(), ) = NotificationEvent.Timeline( event = event, diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/RoomDescription.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/RoomDescription.kt index 94909371260..14960e9f706 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/RoomDescription.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/RoomDescription.kt @@ -22,15 +22,13 @@ internal fun aRustRoomDescription( joinRule: PublicRoomJoinRule = PublicRoomJoinRule.PUBLIC, isWorldReadable: Boolean = true, joinedMembers: ULong = 2u, -): RoomDescription { - return RoomDescription( - roomId = roomId, - name = name, - topic = topic, - alias = alias, - avatarUrl = avatarUrl, - joinRule = joinRule, - isWorldReadable = isWorldReadable, - joinedMembers = joinedMembers, - ) -} +) = RoomDescription( + roomId = roomId, + name = name, + topic = topic, + alias = alias, + avatarUrl = avatarUrl, + joinRule = joinRule, + isWorldReadable = isWorldReadable, + joinedMembers = joinedMembers, +) diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/RoomHero.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/RoomHero.kt index 6725d8058f0..6abb08c667c 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/RoomHero.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/RoomHero.kt @@ -14,10 +14,8 @@ import org.matrix.rustcomponents.sdk.RoomHero internal fun aRustRoomHero( userId: UserId = A_USER_ID, -): RoomHero { - return RoomHero( - userId = userId.value, - displayName = "displayName", - avatarUrl = "avatarUrl", - ) -} +) = RoomHero( + userId = userId.value, + displayName = "displayName", + avatarUrl = "avatarUrl", +) diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/RoomInfo.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/RoomInfo.kt index b8454b23180..298db5e722b 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/RoomInfo.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/RoomInfo.kt @@ -22,7 +22,7 @@ import org.matrix.rustcomponents.sdk.RoomPowerLevels import org.matrix.rustcomponents.sdk.SuccessorRoom import uniffi.matrix_sdk_base.EncryptionState -fun aRustRoomInfo( +internal fun aRustRoomInfo( id: String = A_ROOM_ID.value, displayName: String? = A_ROOM_NAME, rawName: String? = A_ROOM_NAME, diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/RoomMember.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/RoomMember.kt index 90043409391..77fa814ccad 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/RoomMember.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/RoomMember.kt @@ -14,7 +14,7 @@ import org.matrix.rustcomponents.sdk.PowerLevel import org.matrix.rustcomponents.sdk.RoomMember import uniffi.matrix_sdk.RoomMemberRole -fun aRustRoomMember( +internal fun aRustRoomMember( userId: UserId, displayName: String? = null, avatarUrl: String? = null, diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/RoomNotificationSettings.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/RoomNotificationSettings.kt index 30b9ed4a088..a66fb3f3323 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/RoomNotificationSettings.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/RoomNotificationSettings.kt @@ -11,7 +11,7 @@ package io.element.android.libraries.matrix.impl.fixtures.factories import org.matrix.rustcomponents.sdk.RoomNotificationMode import org.matrix.rustcomponents.sdk.RoomNotificationSettings -fun aRustRoomNotificationSettings( +internal fun aRustRoomNotificationSettings( mode: RoomNotificationMode = RoomNotificationMode.ALL_MESSAGES, isDefault: Boolean = true, ) = RoomNotificationSettings( diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/RoomPowerLevels.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/RoomPowerLevelsValues.kt similarity index 100% rename from libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/RoomPowerLevels.kt rename to libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/RoomPowerLevelsValues.kt diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/RoomPreviewInfo.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/RoomPreviewInfo.kt index 0ff92f2bdc5..aacb809a70e 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/RoomPreviewInfo.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/RoomPreviewInfo.kt @@ -19,20 +19,18 @@ internal fun aRustRoomPreviewInfo( canonicalAlias: String? = A_ROOM_ALIAS.value, membership: Membership? = Membership.JOINED, joinRule: JoinRule = JoinRule.Public, -): RoomPreviewInfo { - return RoomPreviewInfo( - roomId = A_ROOM_ID.value, - canonicalAlias = canonicalAlias, - name = "name", - topic = "topic", - avatarUrl = "avatarUrl", - numJoinedMembers = 1u, - numActiveMembers = 1u, - isDirect = false, - roomType = RoomType.Room, - isHistoryWorldReadable = true, - membership = membership, - joinRule = joinRule, - heroes = null, - ) -} +) = RoomPreviewInfo( + roomId = A_ROOM_ID.value, + canonicalAlias = canonicalAlias, + name = "name", + topic = "topic", + avatarUrl = "avatarUrl", + numJoinedMembers = 1u, + numActiveMembers = 1u, + isDirect = false, + roomType = RoomType.Room, + isHistoryWorldReadable = true, + membership = membership, + joinRule = joinRule, + heroes = null, +) diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/Session.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/Session.kt index 133e7ff49ff..4671c457b0c 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/Session.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/Session.kt @@ -18,14 +18,12 @@ internal fun aRustSession( proxy: SlidingSyncVersion = SlidingSyncVersion.NONE, accessToken: String = "accessToken", refreshToken: String = "refreshToken", -): Session { - return Session( - accessToken = accessToken, - refreshToken = refreshToken, - userId = A_USER_ID.value, - deviceId = A_DEVICE_ID.value, - homeserverUrl = A_HOMESERVER_URL, - oidcData = null, - slidingSyncVersion = proxy, - ) -} +) = Session( + accessToken = accessToken, + refreshToken = refreshToken, + userId = A_USER_ID.value, + deviceId = A_DEVICE_ID.value, + homeserverUrl = A_HOMESERVER_URL, + oidcData = null, + slidingSyncVersion = proxy, +) diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/SpaceRoom.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/SpaceRoom.kt index 7b821270af1..50115055c2b 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/SpaceRoom.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/SpaceRoom.kt @@ -16,7 +16,7 @@ import org.matrix.rustcomponents.sdk.RoomHero import org.matrix.rustcomponents.sdk.RoomType import org.matrix.rustcomponents.sdk.SpaceRoom -fun aRustSpaceRoom( +internal fun aRustSpaceRoom( roomId: RoomId = A_ROOM_ID, isDirect: Boolean = false, canonicalAlias: String? = null, diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/TimelineEventType.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/TimelineEventContentMessageLike.kt similarity index 78% rename from libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/TimelineEventType.kt rename to libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/TimelineEventContentMessageLike.kt index 395b2aefe24..877f2056e9d 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/TimelineEventType.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/TimelineEventContentMessageLike.kt @@ -13,17 +13,15 @@ import org.matrix.rustcomponents.sdk.FormattedBody import org.matrix.rustcomponents.sdk.MessageLikeEventContent import org.matrix.rustcomponents.sdk.MessageType import org.matrix.rustcomponents.sdk.TextMessageContent -import org.matrix.rustcomponents.sdk.TimelineEventType +import org.matrix.rustcomponents.sdk.TimelineEventContent -fun aRustTimelineEventTypeMessageLike( +internal fun aRustTimelineEventContentMessageLike( content: MessageLikeEventContent = aRustMessageLikeEventContentRoomMessage(), -): TimelineEventType.MessageLike { - return TimelineEventType.MessageLike( - content = content, - ) -} +) = TimelineEventContent.MessageLike( + content = content, +) -fun aRustMessageLikeEventContentRoomMessage( +internal fun aRustMessageLikeEventContentRoomMessage( messageType: MessageType = aRustMessageTypeText(), inReplyToEventId: String? = null, ) = MessageLikeEventContent.RoomMessage( @@ -31,13 +29,13 @@ fun aRustMessageLikeEventContentRoomMessage( inReplyToEventId = inReplyToEventId, ) -fun aRustMessageTypeText( +internal fun aRustMessageTypeText( content: TextMessageContent = aRustTextMessageContent(), ) = MessageType.Text( content = content, ) -fun aRustTextMessageContent( +internal fun aRustTextMessageContent( body: String = A_MESSAGE, formatted: FormattedBody? = null, ) = TextMessageContent( diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/EventTimelineItemContent.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/TimelineItemContentMsgLike.kt similarity index 90% rename from libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/EventTimelineItemContent.kt rename to libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/TimelineItemContentMsgLike.kt index cf29697f6a5..0db7590a5c1 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/EventTimelineItemContent.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/TimelineItemContentMsgLike.kt @@ -15,7 +15,9 @@ import org.matrix.rustcomponents.sdk.MsgLikeKind import org.matrix.rustcomponents.sdk.TextMessageContent import org.matrix.rustcomponents.sdk.TimelineItemContent -fun aRustTimelineItemMessageContent(body: String = "Hello") = TimelineItemContent.MsgLike( +internal fun aRustTimelineItemContentMsgLike( + body: String = "Hello", +) = TimelineItemContent.MsgLike( content = MsgLikeContent( kind = MsgLikeKind.Message( content = MessageContent( diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/UnableToDecryptInfo.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/UnableToDecryptInfo.kt index 7a7acec4798..4faa914e8cb 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/UnableToDecryptInfo.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/factories/UnableToDecryptInfo.kt @@ -19,14 +19,12 @@ internal fun aRustUnableToDecryptInfo( userTrustsOwnIdentity: Boolean = false, senderHomeserver: String = "", ownHomeserver: String = "", -): UnableToDecryptInfo { - return UnableToDecryptInfo( - eventId = eventId, - timeToDecryptMs = timeToDecryptMs, - cause = cause, - eventLocalAgeMillis = eventLocalAgeMillis, - userTrustsOwnIdentity = userTrustsOwnIdentity, - senderHomeserver = senderHomeserver, - ownHomeserver = ownHomeserver, - ) -} +) = UnableToDecryptInfo( + eventId = eventId, + timeToDecryptMs = timeToDecryptMs, + cause = cause, + eventLocalAgeMillis = eventLocalAgeMillis, + userTrustsOwnIdentity = userTrustsOwnIdentity, + senderHomeserver = senderHomeserver, + ownHomeserver = ownHomeserver, +) 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 c91327a6926..8713e08f1cb 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 @@ -11,7 +11,7 @@ package io.element.android.libraries.matrix.impl.fixtures.factories import io.element.android.libraries.matrix.test.A_USER_ID import org.matrix.rustcomponents.sdk.UserProfile -fun aRustUserProfile( +internal fun aRustUserProfile( userId: String = A_USER_ID.value, displayName: String = "displayName", avatarUrl: String = "avatarUrl", diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiCheckCodeSender.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiCheckCodeSender.kt new file mode 100644 index 00000000000..a19c4e37662 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiCheckCodeSender.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 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 io.element.android.tests.testutils.lambda.lambdaError +import org.matrix.rustcomponents.sdk.CheckCodeSender +import org.matrix.rustcomponents.sdk.NoHandle + +class FakeFfiCheckCodeSender( + private val sendResult: (UByte) -> Unit = { _ -> lambdaError() } +) : CheckCodeSender(NoHandle) { + override suspend fun send(code: UByte) { + sendResult(code) + } +} 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 a2227ab63a5..dc9d21cddc4 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 @@ -28,11 +28,13 @@ import org.matrix.rustcomponents.sdk.RoomDirectorySearch import org.matrix.rustcomponents.sdk.Session import org.matrix.rustcomponents.sdk.SessionVerificationController import org.matrix.rustcomponents.sdk.SpaceService +import org.matrix.rustcomponents.sdk.StoreSizes import org.matrix.rustcomponents.sdk.SyncService 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_base.MediaRetentionPolicy class FakeFfiClient( private val userId: String = A_USER_ID.value, @@ -45,6 +47,7 @@ class FakeFfiClient( private val withUtdHook: (UnableToDecryptDelegate) -> Unit = { lambdaError() }, private val getProfileResult: (String) -> UserProfile = { UserProfile(userId = userId, displayName = null, avatarUrl = null) }, private val homeserverLoginDetailsResult: () -> HomeserverLoginDetails = { lambdaError() }, + private val getStoreSizesResult: () -> StoreSizes = { lambdaError() }, private val closeResult: () -> Unit = {}, ) : Client(NoHandle) { override fun userId(): String = userId @@ -57,7 +60,7 @@ class FakeFfiClient( override suspend fun cachedAvatarUrl(): String? = null override suspend fun restoreSession(session: Session) = Unit override fun syncService(): SyncServiceBuilder = FakeFfiSyncServiceBuilder() - override fun spaceService(): SpaceService = FakeFfiSpaceService() + override suspend fun spaceService(): SpaceService = FakeFfiSpaceService() override fun roomDirectorySearch(): RoomDirectorySearch = FakeFfiRoomDirectorySearch() override suspend fun setPusher( identifiers: PusherIdentifiers, @@ -88,5 +91,11 @@ class FakeFfiClient( return homeserverLoginDetailsResult() } + override suspend fun setMediaRetentionPolicy(policy: MediaRetentionPolicy) {} + + override suspend fun getStoreSizes(): StoreSizes { + return getStoreSizesResult() + } + 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 3ac1fefd7ed..fa10a63e424 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 @@ -44,5 +44,6 @@ class FakeFfiClientBuilder( override fun enableShareHistoryOnInvite(enableShareHistoryOnInvite: Boolean): ClientBuilder = this override fun threadsEnabled(enabled: Boolean, threadSubscriptions: Boolean): ClientBuilder = this override fun sqliteStore(config: SqliteStoreBuilder): ClientBuilder = this + override fun inMemoryStore(): 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 new file mode 100644 index 00000000000..cd0733695ba --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiGrantLoginWithQrCodeHandler.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2025 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.GrantGeneratedQrLoginProgress +import org.matrix.rustcomponents.sdk.GrantGeneratedQrLoginProgressListener +import org.matrix.rustcomponents.sdk.GrantLoginWithQrCodeHandler +import org.matrix.rustcomponents.sdk.GrantQrLoginProgress +import org.matrix.rustcomponents.sdk.GrantQrLoginProgressListener +import org.matrix.rustcomponents.sdk.NoHandle +import org.matrix.rustcomponents.sdk.QrCodeData + +class FakeFfiGrantLoginWithQrCodeHandler( + private val generateResult: () -> Unit = {}, + private val scanResult: (QrCodeData) -> Unit = {}, +) : GrantLoginWithQrCodeHandler(NoHandle) { + private var generateProgressListener: GrantGeneratedQrLoginProgressListener? = null + private var scanProgressListener: GrantQrLoginProgressListener? = null + override suspend fun generate(progressListener: GrantGeneratedQrLoginProgressListener) { + generateProgressListener = progressListener + generateResult() + } + + fun emitGenerateProgress(progress: GrantGeneratedQrLoginProgress) { + generateProgressListener?.onUpdate(progress) + } + + override suspend fun scan(qrCodeData: QrCodeData, progressListener: GrantQrLoginProgressListener) { + scanProgressListener = progressListener + scanResult(qrCodeData) + } + + fun emitScanProgress(progress: GrantQrLoginProgress) { + scanProgressListener?.onUpdate(progress) + } +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiLazyTimelineItemProvider.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiLazyTimelineItemProvider.kt index 8ee167d7698..a62f1a5f61e 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiLazyTimelineItemProvider.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiLazyTimelineItemProvider.kt @@ -17,7 +17,7 @@ import org.matrix.rustcomponents.sdk.ShieldState class FakeFfiLazyTimelineItemProvider( private val debugInfo: EventTimelineItemDebugInfo = anEventTimelineItemDebugInfo(), - private val shieldsState: ShieldState? = null, + private val shieldsState: ShieldState = ShieldState.None, ) : LazyTimelineItemProvider(NoHandle) { override fun getShields(strict: Boolean) = shieldsState override fun debugInfo() = debugInfo diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiQrCodeData.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiQrCodeData.kt index d377643fa72..4070d1b2cda 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiQrCodeData.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiQrCodeData.kt @@ -14,8 +14,13 @@ import org.matrix.rustcomponents.sdk.QrCodeData class FakeFfiQrCodeData( private val serverNameResult: () -> String? = { lambdaError() }, + private val toBytesResult: () -> ByteArray = { lambdaError() }, ) : QrCodeData(NoHandle) { override fun serverName(): String? { return serverNameResult() } + + override fun toBytes(): ByteArray { + return toBytesResult() + } } diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiRoom.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiRoom.kt index 86625c9737b..953e42b1329 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiRoom.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiRoom.kt @@ -12,7 +12,7 @@ import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.impl.fixtures.factories.aRustRoomInfo import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.tests.testutils.lambda.lambdaError -import org.matrix.rustcomponents.sdk.EventTimelineItem +import org.matrix.rustcomponents.sdk.LatestEventValue import org.matrix.rustcomponents.sdk.NoHandle import org.matrix.rustcomponents.sdk.Room import org.matrix.rustcomponents.sdk.RoomInfo @@ -24,7 +24,7 @@ class FakeFfiRoom( private val getMembers: () -> RoomMembersIterator = { lambdaError() }, private val getMembersNoSync: () -> RoomMembersIterator = { lambdaError() }, private val leaveLambda: () -> Unit = { lambdaError() }, - private val latestEventLambda: () -> EventTimelineItem? = { lambdaError() }, + private val latestEventLambda: () -> LatestEventValue = { lambdaError() }, private val suggestedRoleForUserLambda: (String) -> RoomMemberRole = { lambdaError() }, private val roomInfo: RoomInfo = aRustRoomInfo(id = roomId.value), ) : Room(NoHandle) { @@ -48,7 +48,7 @@ class FakeFfiRoom( return roomInfo } - override suspend fun latestEvent(): EventTimelineItem? { + override suspend fun latestEvent(): LatestEventValue { return latestEventLambda() } diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiRoomPowerLevels.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiRoomPowerLevels.kt index 72464827ed6..c47c4406b62 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiRoomPowerLevels.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiRoomPowerLevels.kt @@ -25,11 +25,11 @@ fun defaultFfiRoomPowerLevelValues() = RoomPowerLevelsValues( invite = 0, kick = 50, eventsDefault = 0, + stateDefault = 50, redact = 50, - roomName = 100, - roomAvatar = 100, - roomTopic = 100, - stateDefault = 0, + roomName = 50, + roomAvatar = 50, + roomTopic = 50, + spaceChild = 50, usersDefault = 0, - spaceChild = 100, ) diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiTimelineEvent.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiTimelineEvent.kt index f364ca635a8..80eec5efe77 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiTimelineEvent.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiTimelineEvent.kt @@ -8,19 +8,19 @@ package io.element.android.libraries.matrix.impl.fixtures.fakes -import io.element.android.libraries.matrix.impl.fixtures.factories.aRustTimelineEventTypeMessageLike +import io.element.android.libraries.matrix.impl.fixtures.factories.aRustTimelineEventContentMessageLike import io.element.android.libraries.matrix.test.A_USER_ID_2 import io.element.android.services.toolbox.test.systemclock.A_FAKE_TIMESTAMP import org.matrix.rustcomponents.sdk.NoHandle import org.matrix.rustcomponents.sdk.TimelineEvent -import org.matrix.rustcomponents.sdk.TimelineEventType +import org.matrix.rustcomponents.sdk.TimelineEventContent open class FakeFfiTimelineEvent( val timestamp: ULong = A_FAKE_TIMESTAMP.toULong(), - val timelineEventType: TimelineEventType = aRustTimelineEventTypeMessageLike(), + val timelineEventContent: TimelineEventContent = aRustTimelineEventContentMessageLike(), val senderId: String = A_USER_ID_2.value, ) : TimelineEvent(NoHandle) { override fun timestamp(): ULong = timestamp - override fun eventType(): TimelineEventType = timelineEventType + override fun content(): TimelineEventContent = timelineEventContent override fun senderId(): String = senderId } diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/FakeQrCodeDataParser.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/FakeQrCodeDataParser.kt new file mode 100644 index 00000000000..254258fcf7e --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/FakeQrCodeDataParser.kt @@ -0,0 +1,17 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.linknewdevice + +import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiQrCodeData +import org.matrix.rustcomponents.sdk.QrCodeData + +class FakeQrCodeDataParser : QrCodeDataParser { + override fun parse(data: ByteArray): QrCodeData { + return FakeFfiQrCodeData() + } +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustCheckCodeSenderTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustCheckCodeSenderTest.kt new file mode 100644 index 00000000000..5fb46989768 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustCheckCodeSenderTest.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +package io.element.android.libraries.matrix.impl.linknewdevice + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiCheckCodeSender +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class RustCheckCodeSenderTest { + @Test + fun `send invokes the Ffi object`() = runTest { + val sendResult = lambdaRecorder { } + val sut = RustCheckCodeSender( + inner = FakeFfiCheckCodeSender( + sendResult = sendResult, + ), + sessionDispatcher = StandardTestDispatcher(testScheduler), + ) + sut.send(1.toUByte()) + sendResult.assertions().isCalledOnce().with(value(1.toUByte())) + } + + @Test + fun `validate always returns true for now`() = runTest { + val sut = RustCheckCodeSender( + inner = FakeFfiCheckCodeSender(), + sessionDispatcher = StandardTestDispatcher(testScheduler), + ) + val result = sut.validate(1.toUByte()) + assertThat(result).isTrue() + } +} 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 new file mode 100644 index 00000000000..a180e4d5152 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkDesktopHandlerTest.kt @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package io.element.android.libraries.matrix.impl.linknewdevice + +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.linknewdevice.ErrorType +import io.element.android.libraries.matrix.api.linknewdevice.LinkDesktopStep +import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiGrantLoginWithQrCodeHandler +import io.element.android.libraries.matrix.test.QR_CODE_DATA +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.matrix.rustcomponents.sdk.GrantQrLoginProgress +import org.matrix.rustcomponents.sdk.HumanQrGrantLoginException +import org.matrix.rustcomponents.sdk.QrCodeDecodeException + +class RustLinkDesktopHandlerTest { + @Test + fun `handleScannedQrCode function works as expected`() = runTest { + val handler = FakeFfiGrantLoginWithQrCodeHandler() + val sut = createRustLinkDesktopHandler( + handler, + ) + sut.linkDesktopStep.test { + val initialItem = awaitItem() + assertThat(initialItem).isEqualTo(LinkDesktopStep.Uninitialized) + backgroundScope.launch { + sut.handleScannedQrCode(QR_CODE_DATA) + } + runCurrent() + // progress from the handler is mapped and emitted + listOf( + GrantQrLoginProgress.Starting to LinkDesktopStep.Starting, + GrantQrLoginProgress.SyncingSecrets to LinkDesktopStep.SyncingSecrets, + GrantQrLoginProgress.WaitingForAuth("aVerificationUri") + to LinkDesktopStep.WaitingForAuth("aVerificationUri"), + GrantQrLoginProgress.EstablishingSecureChannel(1.toUByte(), "1") + to LinkDesktopStep.EstablishingSecureChannel(1.toUByte(), "1"), + GrantQrLoginProgress.Done to LinkDesktopStep.Done, + ).forEach { (progress, expectedStep) -> + handler.emitScanProgress(progress) + assertThat(awaitItem()).isEqualTo(expectedStep) + } + } + } + + @Test + fun `when handleScannedQrCode throws QrCodeDecodeException, the handler emits error step`() = runTest { + val handler = FakeFfiGrantLoginWithQrCodeHandler( + scanResult = { throw QrCodeDecodeException.Crypto("Scan failed") } + ) + val sut = createRustLinkDesktopHandler( + handler, + ) + sut.linkDesktopStep.test { + val initialItem = awaitItem() + assertThat(initialItem).isEqualTo(LinkDesktopStep.Uninitialized) + backgroundScope.launch { + sut.handleScannedQrCode(QR_CODE_DATA) + } + runCurrent() + val errorState = awaitItem() + assertThat(errorState).isInstanceOf(LinkDesktopStep.InvalidQrCode::class.java) + } + } + + @Test + fun `when handleScannedQrCode throws HumanQrGrantLoginException, the handler emits error step`() = runTest { + val handler = FakeFfiGrantLoginWithQrCodeHandler( + scanResult = { throw HumanQrGrantLoginException.InvalidCheckCode("Invalid check code") } + ) + val sut = createRustLinkDesktopHandler( + handler, + ) + sut.linkDesktopStep.test { + val initialItem = awaitItem() + assertThat(initialItem).isEqualTo(LinkDesktopStep.Uninitialized) + backgroundScope.launch { + sut.handleScannedQrCode(QR_CODE_DATA) + } + runCurrent() + val errorState = awaitItem() + assertThat(errorState).isInstanceOf(LinkDesktopStep.Error::class.java) + val errorType = (errorState as LinkDesktopStep.Error).errorType + assertThat(errorType).isInstanceOf(ErrorType.InvalidCheckCode::class.java) + } + } + + private fun TestScope.createRustLinkDesktopHandler( + handler: FakeFfiGrantLoginWithQrCodeHandler = FakeFfiGrantLoginWithQrCodeHandler(), + ) = RustLinkDesktopHandler( + inner = handler, + sessionCoroutineScope = backgroundScope, + sessionDispatcher = StandardTestDispatcher(testScheduler), + qrCodeDataParser = FakeQrCodeDataParser(), + ) +} 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 new file mode 100644 index 00000000000..aa13996e8ab --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkMobileHandlerTest.kt @@ -0,0 +1,91 @@ +/* + * Copyright (c) 2025 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. + * Please see LICENSE files in the repository root for full details. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package io.element.android.libraries.matrix.impl.linknewdevice + +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.linknewdevice.ErrorType +import io.element.android.libraries.matrix.api.linknewdevice.LinkMobileStep +import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiCheckCodeSender +import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiGrantLoginWithQrCodeHandler +import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiQrCodeData +import io.element.android.libraries.matrix.test.QR_CODE_DATA_RECIPROCATE +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.StandardTestDispatcher +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.matrix.rustcomponents.sdk.GrantGeneratedQrLoginProgress +import org.matrix.rustcomponents.sdk.HumanQrGrantLoginException + +class RustLinkMobileHandlerTest { + @Test + fun `start function works as expected`() = runTest { + val handler = FakeFfiGrantLoginWithQrCodeHandler() + val sut = createRustLinkMobileHandler( + handler, + ) + sut.linkMobileStep.test { + val initialItem = awaitItem() + assertThat(initialItem).isEqualTo(LinkMobileStep.Uninitialized) + backgroundScope.launch { + sut.start() + } + runCurrent() + // progress from the handler is mapped and emitted + listOf( + GrantGeneratedQrLoginProgress.Starting to LinkMobileStep.Starting::class.java, + GrantGeneratedQrLoginProgress.SyncingSecrets to LinkMobileStep.SyncingSecrets::class.java, + GrantGeneratedQrLoginProgress.WaitingForAuth("aVerificationUri") + to LinkMobileStep.WaitingForAuth::class.java, + GrantGeneratedQrLoginProgress.QrScanned(FakeFfiCheckCodeSender()) + to LinkMobileStep.QrScanned::class.java, + GrantGeneratedQrLoginProgress.QrReady(FakeFfiQrCodeData(toBytesResult = { QR_CODE_DATA_RECIPROCATE })) + to LinkMobileStep.QrReady::class.java, + GrantGeneratedQrLoginProgress.Done to LinkMobileStep.Done::class.java, + ).forEach { (progress, expectedStepClass) -> + handler.emitGenerateProgress(progress) + assertThat(awaitItem()).isInstanceOf(expectedStepClass) + } + } + } + + @Test + fun `when start throws HumanQrGrantLoginException, the handler emits error step`() = runTest { + val handler = FakeFfiGrantLoginWithQrCodeHandler( + generateResult = { throw HumanQrGrantLoginException.NotFound("Timeout") } + ) + val sut = createRustLinkMobileHandler( + handler, + ) + sut.linkMobileStep.test { + val initialItem = awaitItem() + assertThat(initialItem).isEqualTo(LinkMobileStep.Uninitialized) + backgroundScope.launch { + sut.start() + } + runCurrent() + val errorState = awaitItem() + assertThat(errorState).isInstanceOf(LinkMobileStep.Error::class.java) + val errorType = (errorState as LinkMobileStep.Error).errorType + assertThat(errorType).isInstanceOf(ErrorType.NotFound::class.java) + } + } + + private fun TestScope.createRustLinkMobileHandler( + handler: FakeFfiGrantLoginWithQrCodeHandler = FakeFfiGrantLoginWithQrCodeHandler(), + ) = RustLinkMobileHandler( + inner = handler, + sessionCoroutineScope = backgroundScope, + sessionDispatcher = StandardTestDispatcher(testScheduler), + ) +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/notification/RustNotificationServiceTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/notification/RustNotificationServiceTest.kt index e6fc802ed28..eecc37a5297 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/notification/RustNotificationServiceTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/notification/RustNotificationServiceTest.kt @@ -12,7 +12,7 @@ import com.google.common.truth.Truth.assertThat import io.element.android.libraries.matrix.api.exception.NotificationResolverException import io.element.android.libraries.matrix.api.notification.NotificationContent import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType -import io.element.android.libraries.matrix.impl.fixtures.factories.aRustBatchNotificationResult +import io.element.android.libraries.matrix.impl.fixtures.factories.aRustBatchNotificationResultOk import io.element.android.libraries.matrix.impl.fixtures.factories.aRustNotificationEventTimeline import io.element.android.libraries.matrix.impl.fixtures.factories.aRustNotificationItem import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiNotificationClient @@ -29,18 +29,16 @@ import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.testCoroutineDispatchers import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest -import org.junit.Ignore import org.junit.Test import org.matrix.rustcomponents.sdk.NotificationClient import org.matrix.rustcomponents.sdk.NotificationStatus -import org.matrix.rustcomponents.sdk.TimelineEventType +import org.matrix.rustcomponents.sdk.TimelineEventContent class RustNotificationServiceTest { - @Ignore("JNA direct mapping has broken unit tests with FFI fakes") @Test fun test() = runTest { val notificationClient = FakeFfiNotificationClient( - notificationItemResult = mapOf(AN_EVENT_ID.value to aRustBatchNotificationResult()), + notificationItemResult = mapOf(AN_EVENT_ID.value to aRustBatchNotificationResultOk()), ) val sut = createRustNotificationService( notificationClient = notificationClient, @@ -58,21 +56,20 @@ class RustNotificationServiceTest { ) } - @Ignore("JNA direct mapping has broken unit tests with FFI fakes") @Test fun `test mapping invalid item only drops that item`() = runTest { - val error = IllegalStateException("This event type is not supported") + val error = IllegalStateException("This event content is not supported") val faultyEvent = object : FakeFfiTimelineEvent() { - override fun eventType(): TimelineEventType { + override fun content(): TimelineEventContent { throw error } } val notificationClient = FakeFfiNotificationClient( notificationItemResult = mapOf( - AN_EVENT_ID.value to aRustBatchNotificationResult( + AN_EVENT_ID.value to aRustBatchNotificationResultOk( notificationStatus = NotificationStatus.Event(aRustNotificationItem(aRustNotificationEventTimeline(faultyEvent))) ), - AN_EVENT_ID_2.value to aRustBatchNotificationResult() + AN_EVENT_ID_2.value to aRustBatchNotificationResultOk() ), ) val sut = createRustNotificationService( @@ -86,7 +83,6 @@ class RustNotificationServiceTest { assertThat(successfulResult?.isSuccess).isTrue() } - @Ignore("JNA direct mapping has broken unit tests with FFI fakes") @Test fun `test unable to resolve event`() = runTest { val notificationClient = FakeFfiNotificationClient( @@ -99,7 +95,6 @@ class RustNotificationServiceTest { assertThat(exception).isInstanceOf(NotificationResolverException::class.java) } - @Ignore("JNA direct mapping has broken unit tests with FFI fakes") @Test fun `close should invoke the close method of the service`() = runTest { val closeResult = lambdaRecorder { } diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/notificationsettings/RustNotificationSettingsServiceTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/notificationsettings/RustNotificationSettingsServiceTest.kt index 81d0a3307e4..73b7b888b21 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/notificationsettings/RustNotificationSettingsServiceTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/notificationsettings/RustNotificationSettingsServiceTest.kt @@ -16,12 +16,10 @@ import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.tests.testutils.testCoroutineDispatchers import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest -import org.junit.Ignore import org.junit.Test import org.matrix.rustcomponents.sdk.NotificationSettings class RustNotificationSettingsServiceTest { - @Ignore("JNA direct mapping has broken unit tests with FFI fakes") @Test fun test() = runTest { val sut = createRustNotificationSettingsService() diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/pushers/RustPushersServiceTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/pushers/RustPushersServiceTest.kt index 1843bf4dd16..61b2965cdde 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/pushers/RustPushersServiceTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/pushers/RustPushersServiceTest.kt @@ -13,10 +13,8 @@ import io.element.android.libraries.matrix.api.pusher.UnsetHttpPusherData import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiClient import io.element.android.tests.testutils.testCoroutineDispatchers import kotlinx.coroutines.test.runTest -import org.junit.Ignore import org.junit.Test -@Ignore("JNA direct mapping has broken unit tests with FFI fakes") class RustPushersServiceTest { @Test fun `setPusher should invoke the client method`() = runTest { diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/RoomInfoExtTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/RoomInfoExtTest.kt index dc4eba261b6..86a50c3926f 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/RoomInfoExtTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/RoomInfoExtTest.kt @@ -14,10 +14,8 @@ import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.impl.fixtures.factories.aRustRoomHero import io.element.android.libraries.matrix.impl.fixtures.factories.aRustRoomInfo import io.element.android.libraries.matrix.test.A_USER_ID -import org.junit.Ignore import org.junit.Test -@Ignore("JNA direct mapping has broken unit tests with FFI fakes") class RoomInfoExtTest { @Test fun `get non empty element Heroes`() { diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/RoomInfoMapperTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/RoomInfoMapperTest.kt index c964f02950f..5c1c4a2aa3f 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/RoomInfoMapperTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/RoomInfoMapperTest.kt @@ -32,7 +32,6 @@ import io.element.android.libraries.matrix.test.room.defaultRoomPowerLevelValues import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentMapOf import kotlinx.collections.immutable.toImmutableList -import org.junit.Ignore import org.junit.Test import org.matrix.rustcomponents.sdk.Membership import uniffi.matrix_sdk_base.EncryptionState @@ -40,7 +39,6 @@ import org.matrix.rustcomponents.sdk.JoinRule as RustJoinRule import org.matrix.rustcomponents.sdk.RoomHistoryVisibility as RustRoomHistoryVisibility import org.matrix.rustcomponents.sdk.RoomNotificationMode as RustRoomNotificationMode -@Ignore("JNA direct mapping has broken unit tests with FFI fakes") class RoomInfoMapperTest { @Test fun `mapping of RustRoomInfo should map all the fields`() { diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/RustBaseRoomTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/RustBaseRoomTest.kt index a98a222c1ec..6508c041126 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/RustBaseRoomTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/RustBaseRoomTest.kt @@ -31,11 +31,9 @@ import kotlinx.coroutines.flow.shareIn import kotlinx.coroutines.isActive import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest -import org.junit.Ignore import org.junit.Test import uniffi.matrix_sdk.RoomMemberRole -@Ignore("JNA direct mapping has broken unit tests with FFI fakes") class RustBaseRoomTest { @Test fun `RustBaseRoom should cancel the room coroutine scope when it is destroyed`() = runTest { @@ -176,8 +174,7 @@ class RustBaseRoomTest { dispatchers = dispatchers, ), roomMembershipObserver = roomMembershipObserver, - // Not using backgroundScope here, but the test scope - sessionCoroutineScope = this, + sessionCoroutineScope = backgroundScope, roomInfoMapper = RoomInfoMapper(), initialRoomInfo = initialRoomInfo, ) diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/StateEventTypeTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/StateEventTypeTest.kt index 245a321c65d..428bb7db7a8 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/StateEventTypeTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/StateEventTypeTest.kt @@ -16,53 +16,55 @@ import org.matrix.rustcomponents.sdk.StateEventType as RustStateEventType class StateEventTypeTest { @Test fun `mapping Rust type should work`() { - assertThat(RustStateEventType.CALL_MEMBER.map()).isEqualTo(StateEventType.CALL_MEMBER) - assertThat(RustStateEventType.POLICY_RULE_ROOM.map()).isEqualTo(StateEventType.POLICY_RULE_ROOM) - assertThat(RustStateEventType.POLICY_RULE_SERVER.map()).isEqualTo(StateEventType.POLICY_RULE_SERVER) - assertThat(RustStateEventType.POLICY_RULE_USER.map()).isEqualTo(StateEventType.POLICY_RULE_USER) - assertThat(RustStateEventType.ROOM_ALIASES.map()).isEqualTo(StateEventType.ROOM_ALIASES) - assertThat(RustStateEventType.ROOM_AVATAR.map()).isEqualTo(StateEventType.ROOM_AVATAR) - assertThat(RustStateEventType.ROOM_CANONICAL_ALIAS.map()).isEqualTo(StateEventType.ROOM_CANONICAL_ALIAS) - assertThat(RustStateEventType.ROOM_CREATE.map()).isEqualTo(StateEventType.ROOM_CREATE) - assertThat(RustStateEventType.ROOM_ENCRYPTION.map()).isEqualTo(StateEventType.ROOM_ENCRYPTION) - assertThat(RustStateEventType.ROOM_GUEST_ACCESS.map()).isEqualTo(StateEventType.ROOM_GUEST_ACCESS) - assertThat(RustStateEventType.ROOM_HISTORY_VISIBILITY.map()).isEqualTo(StateEventType.ROOM_HISTORY_VISIBILITY) - assertThat(RustStateEventType.ROOM_JOIN_RULES.map()).isEqualTo(StateEventType.ROOM_JOIN_RULES) - assertThat(RustStateEventType.ROOM_MEMBER_EVENT.map()).isEqualTo(StateEventType.ROOM_MEMBER_EVENT) - assertThat(RustStateEventType.ROOM_NAME.map()).isEqualTo(StateEventType.ROOM_NAME) - assertThat(RustStateEventType.ROOM_PINNED_EVENTS.map()).isEqualTo(StateEventType.ROOM_PINNED_EVENTS) - assertThat(RustStateEventType.ROOM_POWER_LEVELS.map()).isEqualTo(StateEventType.ROOM_POWER_LEVELS) - assertThat(RustStateEventType.ROOM_SERVER_ACL.map()).isEqualTo(StateEventType.ROOM_SERVER_ACL) - assertThat(RustStateEventType.ROOM_THIRD_PARTY_INVITE.map()).isEqualTo(StateEventType.ROOM_THIRD_PARTY_INVITE) - assertThat(RustStateEventType.ROOM_TOMBSTONE.map()).isEqualTo(StateEventType.ROOM_TOMBSTONE) - assertThat(RustStateEventType.ROOM_TOPIC.map()).isEqualTo(StateEventType.ROOM_TOPIC) - assertThat(RustStateEventType.SPACE_CHILD.map()).isEqualTo(StateEventType.SPACE_CHILD) - assertThat(RustStateEventType.SPACE_PARENT.map()).isEqualTo(StateEventType.SPACE_PARENT) + assertThat(RustStateEventType.CallMember.map()).isEqualTo(StateEventType.CallMember) + assertThat(RustStateEventType.PolicyRuleRoom.map()).isEqualTo(StateEventType.PolicyRuleRoom) + assertThat(RustStateEventType.PolicyRuleServer.map()).isEqualTo(StateEventType.PolicyRuleServer) + assertThat(RustStateEventType.PolicyRuleUser.map()).isEqualTo(StateEventType.PolicyRuleUser) + assertThat(RustStateEventType.RoomAliases.map()).isEqualTo(StateEventType.RoomAliases) + assertThat(RustStateEventType.RoomAvatar.map()).isEqualTo(StateEventType.RoomAvatar) + assertThat(RustStateEventType.RoomCanonicalAlias.map()).isEqualTo(StateEventType.RoomCanonicalAlias) + assertThat(RustStateEventType.RoomCreate.map()).isEqualTo(StateEventType.RoomCreate) + assertThat(RustStateEventType.RoomEncryption.map()).isEqualTo(StateEventType.RoomEncryption) + assertThat(RustStateEventType.RoomGuestAccess.map()).isEqualTo(StateEventType.RoomGuestAccess) + assertThat(RustStateEventType.RoomHistoryVisibility.map()).isEqualTo(StateEventType.RoomHistoryVisibility) + assertThat(RustStateEventType.RoomJoinRules.map()).isEqualTo(StateEventType.RoomJoinRules) + assertThat(RustStateEventType.RoomMemberEvent.map()).isEqualTo(StateEventType.RoomMemberEvent) + assertThat(RustStateEventType.RoomName.map()).isEqualTo(StateEventType.RoomName) + assertThat(RustStateEventType.RoomPinnedEvents.map()).isEqualTo(StateEventType.RoomPinnedEvents) + assertThat(RustStateEventType.RoomPowerLevels.map()).isEqualTo(StateEventType.RoomPowerLevels) + assertThat(RustStateEventType.RoomServerAcl.map()).isEqualTo(StateEventType.RoomServerAcl) + assertThat(RustStateEventType.RoomThirdPartyInvite.map()).isEqualTo(StateEventType.RoomThirdPartyInvite) + assertThat(RustStateEventType.RoomTombstone.map()).isEqualTo(StateEventType.RoomTombstone) + assertThat(RustStateEventType.RoomTopic.map()).isEqualTo(StateEventType.RoomTopic) + assertThat(RustStateEventType.SpaceChild.map()).isEqualTo(StateEventType.SpaceChild) + assertThat(RustStateEventType.SpaceParent.map()).isEqualTo(StateEventType.SpaceParent) + assertThat(RustStateEventType.Custom("foo").map()).isEqualTo(StateEventType.Custom("foo")) } @Test fun `mapping Kotlin type should work`() { - assertThat(StateEventType.CALL_MEMBER.map()).isEqualTo(RustStateEventType.CALL_MEMBER) - assertThat(StateEventType.POLICY_RULE_ROOM.map()).isEqualTo(RustStateEventType.POLICY_RULE_ROOM) - assertThat(StateEventType.POLICY_RULE_SERVER.map()).isEqualTo(RustStateEventType.POLICY_RULE_SERVER) - assertThat(StateEventType.POLICY_RULE_USER.map()).isEqualTo(RustStateEventType.POLICY_RULE_USER) - assertThat(StateEventType.ROOM_ALIASES.map()).isEqualTo(RustStateEventType.ROOM_ALIASES) - assertThat(StateEventType.ROOM_AVATAR.map()).isEqualTo(RustStateEventType.ROOM_AVATAR) - assertThat(StateEventType.ROOM_CANONICAL_ALIAS.map()).isEqualTo(RustStateEventType.ROOM_CANONICAL_ALIAS) - assertThat(StateEventType.ROOM_CREATE.map()).isEqualTo(RustStateEventType.ROOM_CREATE) - assertThat(StateEventType.ROOM_ENCRYPTION.map()).isEqualTo(RustStateEventType.ROOM_ENCRYPTION) - assertThat(StateEventType.ROOM_GUEST_ACCESS.map()).isEqualTo(RustStateEventType.ROOM_GUEST_ACCESS) - assertThat(StateEventType.ROOM_HISTORY_VISIBILITY.map()).isEqualTo(RustStateEventType.ROOM_HISTORY_VISIBILITY) - assertThat(StateEventType.ROOM_JOIN_RULES.map()).isEqualTo(RustStateEventType.ROOM_JOIN_RULES) - assertThat(StateEventType.ROOM_MEMBER_EVENT.map()).isEqualTo(RustStateEventType.ROOM_MEMBER_EVENT) - assertThat(StateEventType.ROOM_NAME.map()).isEqualTo(RustStateEventType.ROOM_NAME) - assertThat(StateEventType.ROOM_PINNED_EVENTS.map()).isEqualTo(RustStateEventType.ROOM_PINNED_EVENTS) - assertThat(StateEventType.ROOM_POWER_LEVELS.map()).isEqualTo(RustStateEventType.ROOM_POWER_LEVELS) - assertThat(StateEventType.ROOM_SERVER_ACL.map()).isEqualTo(RustStateEventType.ROOM_SERVER_ACL) - assertThat(StateEventType.ROOM_THIRD_PARTY_INVITE.map()).isEqualTo(RustStateEventType.ROOM_THIRD_PARTY_INVITE) - assertThat(StateEventType.ROOM_TOMBSTONE.map()).isEqualTo(RustStateEventType.ROOM_TOMBSTONE) - assertThat(StateEventType.ROOM_TOPIC.map()).isEqualTo(RustStateEventType.ROOM_TOPIC) - assertThat(StateEventType.SPACE_CHILD.map()).isEqualTo(RustStateEventType.SPACE_CHILD) - assertThat(StateEventType.SPACE_PARENT.map()).isEqualTo(RustStateEventType.SPACE_PARENT) + assertThat(StateEventType.CallMember.map()).isEqualTo(RustStateEventType.CallMember) + assertThat(StateEventType.PolicyRuleRoom.map()).isEqualTo(RustStateEventType.PolicyRuleRoom) + assertThat(StateEventType.PolicyRuleServer.map()).isEqualTo(RustStateEventType.PolicyRuleServer) + assertThat(StateEventType.PolicyRuleUser.map()).isEqualTo(RustStateEventType.PolicyRuleUser) + assertThat(StateEventType.RoomAliases.map()).isEqualTo(RustStateEventType.RoomAliases) + assertThat(StateEventType.RoomAvatar.map()).isEqualTo(RustStateEventType.RoomAvatar) + assertThat(StateEventType.RoomCanonicalAlias.map()).isEqualTo(RustStateEventType.RoomCanonicalAlias) + assertThat(StateEventType.RoomCreate.map()).isEqualTo(RustStateEventType.RoomCreate) + assertThat(StateEventType.RoomEncryption.map()).isEqualTo(RustStateEventType.RoomEncryption) + assertThat(StateEventType.RoomGuestAccess.map()).isEqualTo(RustStateEventType.RoomGuestAccess) + assertThat(StateEventType.RoomHistoryVisibility.map()).isEqualTo(RustStateEventType.RoomHistoryVisibility) + assertThat(StateEventType.RoomJoinRules.map()).isEqualTo(RustStateEventType.RoomJoinRules) + assertThat(StateEventType.RoomMemberEvent.map()).isEqualTo(RustStateEventType.RoomMemberEvent) + assertThat(StateEventType.RoomName.map()).isEqualTo(RustStateEventType.RoomName) + assertThat(StateEventType.RoomPinnedEvents.map()).isEqualTo(RustStateEventType.RoomPinnedEvents) + assertThat(StateEventType.RoomPowerLevels.map()).isEqualTo(RustStateEventType.RoomPowerLevels) + assertThat(StateEventType.RoomServerAcl.map()).isEqualTo(RustStateEventType.RoomServerAcl) + assertThat(StateEventType.RoomThirdPartyInvite.map()).isEqualTo(RustStateEventType.RoomThirdPartyInvite) + assertThat(StateEventType.RoomTombstone.map()).isEqualTo(RustStateEventType.RoomTombstone) + assertThat(StateEventType.RoomTopic.map()).isEqualTo(RustStateEventType.RoomTopic) + assertThat(StateEventType.SpaceChild.map()).isEqualTo(RustStateEventType.SpaceChild) + assertThat(StateEventType.SpaceParent.map()).isEqualTo(RustStateEventType.SpaceParent) + assertThat(StateEventType.Custom("foo").map()).isEqualTo(RustStateEventType.Custom("foo")) } } diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/join/DefaultJoinRoomTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/join/DefaultJoinRoomTest.kt index e4f0b02e335..52aaa62b976 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/join/DefaultJoinRoomTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/join/DefaultJoinRoomTest.kt @@ -25,10 +25,8 @@ import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.value import kotlinx.coroutines.test.runTest -import org.junit.Ignore import org.junit.Test -@Ignore("JNA direct mapping has broken unit tests with FFI fakes") class DefaultJoinRoomTest { @Test fun `when using roomId and there is no server names, the classic join room API is used`() = runTest { diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/member/RoomMemberListFetcherTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/member/RoomMemberListFetcherTest.kt index 266a4eed7c6..c1c436c4058 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/member/RoomMemberListFetcherTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/member/RoomMemberListFetcherTest.kt @@ -24,10 +24,8 @@ import io.element.android.libraries.matrix.test.A_USER_ID_3 import io.element.android.libraries.matrix.test.A_USER_ID_4 import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.test.runTest -import org.junit.Ignore import org.junit.Test -@Ignore("JNA direct mapping has broken unit tests with FFI fakes") class RoomMemberListFetcherTest { @Test fun `fetchRoomMembers with CACHE source - emits cached members, if any`() = runTest { diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/powerlevels/RoomPowerLevelsValuesMapperTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/powerlevels/RoomPowerLevelsValuesMapperTest.kt index 3c100283a17..f298da8b42a 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/powerlevels/RoomPowerLevelsValuesMapperTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/powerlevels/RoomPowerLevelsValuesMapperTest.kt @@ -37,8 +37,9 @@ class RoomPowerLevelsValuesMapperTest { ban = 1, invite = 2, kick = 3, - sendEvents = 5, redactEvents = 4, + eventsDefault = 5, + stateDefault = 6, roomName = 8, roomAvatar = 9, roomTopic = 10, diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/roomdirectory/RustBaseRoomDirectoryListTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/roomdirectory/RustBaseRoomDirectoryListTest.kt index cf3260b85c3..cf3c746ec09 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/roomdirectory/RustBaseRoomDirectoryListTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/roomdirectory/RustBaseRoomDirectoryListTest.kt @@ -19,12 +19,10 @@ import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest -import org.junit.Ignore import org.junit.Test import org.matrix.rustcomponents.sdk.RoomDirectorySearch import org.matrix.rustcomponents.sdk.RoomDirectorySearchEntryUpdate -@Ignore("JNA direct mapping has broken unit tests with FFI fakes") @OptIn(ExperimentalCoroutinesApi::class) class RustBaseRoomDirectoryListTest { @Test diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/roomdirectory/RustBaseRoomDirectoryServiceTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/roomdirectory/RustBaseRoomDirectoryServiceTest.kt index 04e6a9a3aaa..ed97152311b 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/roomdirectory/RustBaseRoomDirectoryServiceTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/roomdirectory/RustBaseRoomDirectoryServiceTest.kt @@ -11,11 +11,9 @@ package io.element.android.libraries.matrix.impl.roomdirectory import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiClient import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.runTest -import org.junit.Ignore import org.junit.Test class RustBaseRoomDirectoryServiceTest { - @Ignore("JNA direct mapping has broken unit tests with FFI fakes") @Test fun test() = runTest { val client = FakeFfiClient() diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListFactoryTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListFactoryTest.kt index ec8053e9405..ba45b310ef1 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListFactoryTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomListFactoryTest.kt @@ -12,12 +12,10 @@ import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiRoomList import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiRoomListService import io.element.android.services.analytics.test.FakeAnalyticsService import kotlinx.coroutines.test.runTest -import org.junit.Ignore import org.junit.Test import kotlin.coroutines.EmptyCoroutineContext class RoomListFactoryTest { - @Ignore("JNA direct mapping has broken unit tests with FFI fakes") @Test fun `createRoomList should work`() = runTest { val sut = RoomListFactory( diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomSummaryListProcessorTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomSummaryListProcessorTest.kt index eaa62abb65d..6fac54b9042 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomSummaryListProcessorTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/roomlist/RoomSummaryListProcessorTest.kt @@ -16,32 +16,31 @@ import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiRoomListSe import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_ROOM_ID_2 import io.element.android.libraries.matrix.test.A_ROOM_ID_3 +import io.element.android.libraries.matrix.test.A_ROOM_ID_4 import io.element.android.libraries.matrix.test.room.aRoomSummary +import io.element.android.services.analytics.test.FakeAnalyticsService import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest -import org.junit.Ignore import org.junit.Test +import org.matrix.rustcomponents.sdk.LatestEventValue import org.matrix.rustcomponents.sdk.RoomListEntriesUpdate class RoomSummaryListProcessorTest { private val summaries = MutableStateFlow>(emptyList()) - @Ignore("JNA direct mapping has broken unit tests with FFI fakes") @Test fun `Append adds new entries at the end of the list`() = runTest { summaries.value = listOf(aRoomSummary()) val processor = createProcessor() - val newEntry = aRustRoom(A_ROOM_ID_2) - processor.postUpdate(listOf(RoomListEntriesUpdate.Append(listOf(newEntry, newEntry, newEntry)))) + processor.postUpdate(listOf(RoomListEntriesUpdate.Append(listOf(aRustRoom(A_ROOM_ID_2), aRustRoom(A_ROOM_ID_3), aRustRoom(A_ROOM_ID_4))))) assertThat(summaries.value.count()).isEqualTo(4) - assertThat(summaries.value.subList(1, 4).all { it.roomId == A_ROOM_ID_2 }).isTrue() + assertThat(summaries.value.subList(1, 4).map { it.roomId }).isEqualTo(listOf(A_ROOM_ID_2, A_ROOM_ID_3, A_ROOM_ID_4)) } - @Ignore("JNA direct mapping has broken unit tests with FFI fakes") @Test fun `PushBack adds a new entry at the end of the list`() = runTest { summaries.value = listOf(aRoomSummary()) @@ -52,7 +51,6 @@ class RoomSummaryListProcessorTest { assertThat(summaries.value.last().roomId).isEqualTo(A_ROOM_ID_2) } - @Ignore("JNA direct mapping has broken unit tests with FFI fakes") @Test fun `PushFront inserts a new entry at the start of the list`() = runTest { summaries.value = listOf(aRoomSummary()) @@ -63,7 +61,6 @@ class RoomSummaryListProcessorTest { assertThat(summaries.value.first().roomId).isEqualTo(A_ROOM_ID_2) } - @Ignore("JNA direct mapping has broken unit tests with FFI fakes") @Test fun `Set replaces an entry at some index`() = runTest { summaries.value = listOf(aRoomSummary()) @@ -76,7 +73,6 @@ class RoomSummaryListProcessorTest { assertThat(summaries.value[index].roomId).isEqualTo(A_ROOM_ID_2) } - @Ignore("JNA direct mapping has broken unit tests with FFI fakes") @Test fun `Insert inserts a new entry at the provided index`() = runTest { summaries.value = listOf(aRoomSummary()) @@ -89,7 +85,6 @@ class RoomSummaryListProcessorTest { assertThat(summaries.value[index].roomId).isEqualTo(A_ROOM_ID_2) } - @Ignore("JNA direct mapping has broken unit tests with FFI fakes") @Test fun `Remove removes an entry at some index`() = runTest { summaries.value = listOf( @@ -105,7 +100,6 @@ class RoomSummaryListProcessorTest { assertThat(summaries.value[index].roomId).isEqualTo(A_ROOM_ID_2) } - @Ignore("JNA direct mapping has broken unit tests with FFI fakes") @Test fun `PopBack removes an entry at the end of the list`() = runTest { summaries.value = listOf( @@ -121,7 +115,6 @@ class RoomSummaryListProcessorTest { assertThat(summaries.value[index].roomId).isEqualTo(A_ROOM_ID) } - @Ignore("JNA direct mapping has broken unit tests with FFI fakes") @Test fun `PopFront removes an entry at the start of the list`() = runTest { summaries.value = listOf( @@ -137,7 +130,6 @@ class RoomSummaryListProcessorTest { assertThat(summaries.value[index].roomId).isEqualTo(A_ROOM_ID_2) } - @Ignore("JNA direct mapping has broken unit tests with FFI fakes") @Test fun `Clear removes all the entries`() = runTest { summaries.value = listOf( @@ -151,7 +143,6 @@ class RoomSummaryListProcessorTest { assertThat(summaries.value).isEmpty() } - @Ignore("JNA direct mapping has broken unit tests with FFI fakes") @Test fun `Truncate removes all entries after the provided length`() = runTest { summaries.value = listOf( @@ -167,7 +158,6 @@ class RoomSummaryListProcessorTest { assertThat(summaries.value[index].roomId).isEqualTo(A_ROOM_ID) } - @Ignore("JNA direct mapping has broken unit tests with FFI fakes") @Test fun `Reset removes all entries and add the provided ones`() = runTest { summaries.value = listOf( @@ -185,7 +175,7 @@ class RoomSummaryListProcessorTest { private fun aRustRoom(roomId: RoomId = A_ROOM_ID) = FakeFfiRoom( roomId = roomId, - latestEventLambda = { null }, + latestEventLambda = { LatestEventValue.None } ) private fun TestScope.createProcessor() = RoomSummaryListProcessor( @@ -193,5 +183,6 @@ class RoomSummaryListProcessorTest { FakeFfiRoomListService(), coroutineContext = StandardTestDispatcher(testScheduler), roomSummaryFactory = RoomSummaryFactory(), + analyticsService = FakeAnalyticsService(), ) } diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/roomlist/RustBaseRoomListServiceTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/roomlist/RustBaseRoomListServiceTest.kt index 9c8c0ddb696..88b57035e85 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/roomlist/RustBaseRoomListServiceTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/roomlist/RustBaseRoomListServiceTest.kt @@ -19,12 +19,10 @@ import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest -import org.junit.Ignore import org.junit.Test import org.matrix.rustcomponents.sdk.RoomListServiceSyncIndicator import org.matrix.rustcomponents.sdk.RoomListService as RustRoomListService -@Ignore("JNA direct mapping has broken unit tests with FFI fakes") @OptIn(ExperimentalCoroutinesApi::class) class RustBaseRoomListServiceTest { @Test diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/spaces/RoomSummaryListProcessorTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/spaces/RoomSummaryListProcessorTest.kt index 116b98aaeb8..f23f0886d40 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/spaces/RoomSummaryListProcessorTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/spaces/RoomSummaryListProcessorTest.kt @@ -14,31 +14,33 @@ import io.element.android.libraries.matrix.impl.fixtures.factories.aRustSpaceRoo import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_ROOM_ID_2 import io.element.android.libraries.matrix.test.A_ROOM_ID_3 +import io.element.android.libraries.matrix.test.A_ROOM_ID_4 import io.element.android.libraries.previewutils.room.aSpaceRoom +import io.element.android.services.analytics.test.FakeAnalyticsService import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.runTest -import org.junit.Ignore import org.junit.Test import org.matrix.rustcomponents.sdk.SpaceListUpdate class RoomSummaryListProcessorTest { private val spaceRoomsFlow = MutableStateFlow>(emptyList()) - @Ignore("JNA direct mapping has broken unit tests with FFI fakes") @Test fun `Append adds new entries at the end of the list`() = runTest { spaceRoomsFlow.value = listOf(aSpaceRoom()) val processor = createProcessor() - val newEntry = aRustSpaceRoom(roomId = A_ROOM_ID_2) - processor.postUpdates(listOf(SpaceListUpdate.Append(listOf(newEntry, newEntry, newEntry)))) + processor.postUpdates( + listOf( + SpaceListUpdate.Append(listOf(aRustSpaceRoom(roomId = A_ROOM_ID_2), aRustSpaceRoom(roomId = A_ROOM_ID_3), aRustSpaceRoom(roomId = A_ROOM_ID_4))) + ) + ) assertThat(spaceRoomsFlow.value.count()).isEqualTo(4) - assertThat(spaceRoomsFlow.value.subList(1, 4).all { it.roomId == A_ROOM_ID_2 }).isTrue() + assertThat(spaceRoomsFlow.value.subList(1, 4).map { it.roomId }).isEqualTo(listOf(A_ROOM_ID_2, A_ROOM_ID_3, A_ROOM_ID_4)) } - @Ignore("JNA direct mapping has broken unit tests with FFI fakes") @Test fun `PushBack adds a new entry at the end of the list`() = runTest { spaceRoomsFlow.value = listOf(aSpaceRoom()) @@ -49,7 +51,6 @@ class RoomSummaryListProcessorTest { assertThat(spaceRoomsFlow.value.last().roomId).isEqualTo(A_ROOM_ID_2) } - @Ignore("JNA direct mapping has broken unit tests with FFI fakes") @Test fun `PushFront inserts a new entry at the start of the list`() = runTest { spaceRoomsFlow.value = listOf(aSpaceRoom()) @@ -60,7 +61,6 @@ class RoomSummaryListProcessorTest { assertThat(spaceRoomsFlow.value.first().roomId).isEqualTo(A_ROOM_ID_2) } - @Ignore("JNA direct mapping has broken unit tests with FFI fakes") @Test fun `Set replaces an entry at some index`() = runTest { spaceRoomsFlow.value = listOf(aSpaceRoom()) @@ -73,7 +73,6 @@ class RoomSummaryListProcessorTest { assertThat(spaceRoomsFlow.value[index].roomId).isEqualTo(A_ROOM_ID_2) } - @Ignore("JNA direct mapping has broken unit tests with FFI fakes") @Test fun `Insert inserts a new entry at the provided index`() = runTest { spaceRoomsFlow.value = listOf(aSpaceRoom()) @@ -86,7 +85,6 @@ class RoomSummaryListProcessorTest { assertThat(spaceRoomsFlow.value[index].roomId).isEqualTo(A_ROOM_ID_2) } - @Ignore("JNA direct mapping has broken unit tests with FFI fakes") @Test fun `Remove removes an entry at some index`() = runTest { spaceRoomsFlow.value = listOf( @@ -102,7 +100,6 @@ class RoomSummaryListProcessorTest { assertThat(spaceRoomsFlow.value[index].roomId).isEqualTo(A_ROOM_ID_2) } - @Ignore("JNA direct mapping has broken unit tests with FFI fakes") @Test fun `PopBack removes an entry at the end of the list`() = runTest { spaceRoomsFlow.value = listOf( @@ -118,7 +115,6 @@ class RoomSummaryListProcessorTest { assertThat(spaceRoomsFlow.value[index].roomId).isEqualTo(A_ROOM_ID) } - @Ignore("JNA direct mapping has broken unit tests with FFI fakes") @Test fun `PopFront removes an entry at the start of the list`() = runTest { spaceRoomsFlow.value = listOf( @@ -134,7 +130,6 @@ class RoomSummaryListProcessorTest { assertThat(spaceRoomsFlow.value[index].roomId).isEqualTo(A_ROOM_ID_2) } - @Ignore("JNA direct mapping has broken unit tests with FFI fakes") @Test fun `Clear removes all the entries`() = runTest { spaceRoomsFlow.value = listOf( @@ -148,7 +143,6 @@ class RoomSummaryListProcessorTest { assertThat(spaceRoomsFlow.value).isEmpty() } - @Ignore("JNA direct mapping has broken unit tests with FFI fakes") @Test fun `Truncate removes all entries after the provided length`() = runTest { spaceRoomsFlow.value = listOf( @@ -164,7 +158,6 @@ class RoomSummaryListProcessorTest { assertThat(spaceRoomsFlow.value[index].roomId).isEqualTo(A_ROOM_ID) } - @Ignore("JNA direct mapping has broken unit tests with FFI fakes") @Test fun `Reset removes all entries and add the provided ones`() = runTest { spaceRoomsFlow.value = listOf( @@ -180,7 +173,6 @@ class RoomSummaryListProcessorTest { assertThat(spaceRoomsFlow.value[index].roomId).isEqualTo(A_ROOM_ID_3) } - @Ignore("JNA direct mapping has broken unit tests with FFI fakes") @Test fun `When there is no replay cache SpaceListUpdateProcessor starts with an empty list`() = runTest { val spaceRoomsSharedFlow = MutableSharedFlow>(replay = 1) @@ -199,5 +191,6 @@ class RoomSummaryListProcessorTest { ) = SpaceListUpdateProcessor( spaceRoomsFlow = spaceRoomsFlow, mapper = SpaceRoomMapper(), + analyticsService = FakeAnalyticsService(), ) } diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceRoomListTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceRoomListTest.kt index 74cc97d66e1..08f9407b938 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceRoomListTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/spaces/RustSpaceRoomListTest.kt @@ -18,19 +18,18 @@ import io.element.android.libraries.matrix.impl.fixtures.factories.aRustSpaceRoo import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiSpaceRoomList 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.services.analytics.test.FakeAnalyticsService import io.element.android.tests.testutils.lambda.lambdaRecorder import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest -import org.junit.Ignore import org.junit.Test import org.matrix.rustcomponents.sdk.SpaceListUpdate import uniffi.matrix_sdk_ui.SpaceRoomListPaginationState import org.matrix.rustcomponents.sdk.SpaceRoomList as InnerSpaceRoomList class RustSpaceRoomListTest { - @Ignore("JNA direct mapping has broken unit tests with FFI fakes") @Test fun `paginationStatusFlow emits values`() = runTest { val innerSpaceRoomList = FakeFfiSpaceRoomList( @@ -53,7 +52,6 @@ class RustSpaceRoomListTest { } } - @Ignore("JNA direct mapping has broken unit tests with FFI fakes") @Test fun `spaceRoomsFlow emits values`() = runTest { val innerSpaceRoomList = FakeFfiSpaceRoomList( @@ -76,7 +74,6 @@ class RustSpaceRoomListTest { } } - @Ignore("JNA direct mapping has broken unit tests with FFI fakes") @Test fun `paginate invokes paginate on the inner class`() = runTest { val paginateResult = lambdaRecorder { } @@ -101,6 +98,7 @@ class RustSpaceRoomListTest { innerProvider = innerProvider, coroutineScope = backgroundScope, spaceRoomMapper = spaceRoomMapper, + analyticsService = FakeAnalyticsService(), ) } } diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/storage/FakeSqliteStoreBuilder.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/storage/FakeSqliteStoreBuilder.kt new file mode 100644 index 00000000000..2f12587f5a3 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/storage/FakeSqliteStoreBuilder.kt @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2025 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.storage + +import org.matrix.rustcomponents.sdk.ClientBuilder + +class FakeSqliteStoreBuilder : SqliteStoreBuilder { + override fun passphrase(passphrase: String?): SqliteStoreBuilder = this + + override fun setupClientBuilder(clientBuilder: ClientBuilder): ClientBuilder { + return clientBuilder + } +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/storage/FakeSqliteStoreBuilderProvider.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/storage/FakeSqliteStoreBuilderProvider.kt new file mode 100644 index 00000000000..b196604ca43 --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/storage/FakeSqliteStoreBuilderProvider.kt @@ -0,0 +1,16 @@ +/* + * Copyright (c) 2025 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.storage + +import io.element.android.libraries.matrix.impl.paths.SessionPaths + +class FakeSqliteStoreBuilderProvider : SqliteStoreBuilderProvider { + override fun provide(sessionPaths: SessionPaths): SqliteStoreBuilder { + return FakeSqliteStoreBuilder() + } +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/MatrixTimelineDiffProcessorTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/MatrixTimelineDiffProcessorTest.kt index ba7f640514b..308d2f66a6c 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/MatrixTimelineDiffProcessorTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/MatrixTimelineDiffProcessorTest.kt @@ -21,7 +21,6 @@ import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest -import org.junit.Ignore import org.junit.Test import org.matrix.rustcomponents.sdk.TimelineDiff @@ -31,7 +30,6 @@ class MatrixTimelineDiffProcessorTest { private val anEvent = MatrixTimelineItem.Event(A_UNIQUE_ID, anEventTimelineItem()) private val anEvent2 = MatrixTimelineItem.Event(A_UNIQUE_ID_2, anEventTimelineItem()) - @Ignore("JNA direct mapping has broken unit tests with FFI fakes") @Test fun `Append adds new entries at the end of the list`() = runTest { timelineItems.value = listOf(anEvent) @@ -44,7 +42,6 @@ class MatrixTimelineDiffProcessorTest { ) } - @Ignore("JNA direct mapping has broken unit tests with FFI fakes") @Test fun `PushBack adds a new entry at the end of the list`() = runTest { timelineItems.value = listOf(anEvent) @@ -57,7 +54,6 @@ class MatrixTimelineDiffProcessorTest { ) } - @Ignore("JNA direct mapping has broken unit tests with FFI fakes") @Test fun `PushFront inserts a new entry at the start of the list`() = runTest { timelineItems.value = listOf(anEvent) @@ -70,7 +66,6 @@ class MatrixTimelineDiffProcessorTest { ) } - @Ignore("JNA direct mapping has broken unit tests with FFI fakes") @Test fun `Set replaces an entry at some index`() = runTest { timelineItems.value = listOf(anEvent, anEvent2) @@ -83,7 +78,6 @@ class MatrixTimelineDiffProcessorTest { ) } - @Ignore("JNA direct mapping has broken unit tests with FFI fakes") @Test fun `Insert inserts a new entry at the provided index`() = runTest { timelineItems.value = listOf(anEvent, anEvent2) @@ -97,7 +91,6 @@ class MatrixTimelineDiffProcessorTest { ) } - @Ignore("JNA direct mapping has broken unit tests with FFI fakes") @Test fun `Remove removes an entry at some index`() = runTest { timelineItems.value = listOf(anEvent, MatrixTimelineItem.Other, anEvent2) @@ -110,7 +103,6 @@ class MatrixTimelineDiffProcessorTest { ) } - @Ignore("JNA direct mapping has broken unit tests with FFI fakes") @Test fun `PopBack removes an entry at the end of the list`() = runTest { timelineItems.value = listOf(anEvent, anEvent2) @@ -122,7 +114,6 @@ class MatrixTimelineDiffProcessorTest { ) } - @Ignore("JNA direct mapping has broken unit tests with FFI fakes") @Test fun `PopFront removes an entry at the start of the list`() = runTest { timelineItems.value = listOf(anEvent, anEvent2) @@ -134,7 +125,6 @@ class MatrixTimelineDiffProcessorTest { ) } - @Ignore("JNA direct mapping has broken unit tests with FFI fakes") @Test fun `Clear removes all the entries`() = runTest { timelineItems.value = listOf(anEvent, anEvent2) @@ -143,7 +133,6 @@ class MatrixTimelineDiffProcessorTest { assertThat(timelineItems.value).isEmpty() } - @Ignore("JNA direct mapping has broken unit tests with FFI fakes") @Test fun `Truncate removes all entries after the provided length`() = runTest { timelineItems.value = listOf(anEvent, MatrixTimelineItem.Other, anEvent2) @@ -155,7 +144,6 @@ class MatrixTimelineDiffProcessorTest { ) } - @Ignore("JNA direct mapping has broken unit tests with FFI fakes") @Test fun `Reset removes all entries and add the provided ones`() = runTest { timelineItems.value = listOf(anEvent, MatrixTimelineItem.Other, anEvent2) diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimelineTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimelineTest.kt index 1dde04575eb..ab46e8aa5f9 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimelineTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimelineTest.kt @@ -30,13 +30,11 @@ import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest -import org.junit.Ignore import org.junit.Test import org.matrix.rustcomponents.sdk.TimelineDiff import uniffi.matrix_sdk.RoomPaginationStatus import org.matrix.rustcomponents.sdk.Timeline as InnerTimeline -@Ignore("JNA direct mapping has broken unit tests with FFI fakes") class RustTimelineTest { @Test fun `ensure that the timeline emits new loading item when pagination does not bring new events`() = runTest { diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/TimelineItemsSubscriberTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/TimelineItemsSubscriberTest.kt index 4accf26e68d..9a03374d7e8 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/TimelineItemsSubscriberTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/TimelineItemsSubscriberTest.kt @@ -20,7 +20,6 @@ import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest -import org.junit.Ignore import org.junit.Test import org.matrix.rustcomponents.sdk.Timeline import org.matrix.rustcomponents.sdk.TimelineDiff @@ -28,7 +27,6 @@ import uniffi.matrix_sdk_ui.EventItemOrigin @OptIn(ExperimentalCoroutinesApi::class) class TimelineItemsSubscriberTest { - @Ignore("JNA direct mapping has broken unit tests with FFI fakes") @Test fun `when timeline emits an empty list of items, the flow must emits an empty list`() = runTest { val timelineItems: MutableSharedFlow> = @@ -52,7 +50,6 @@ class TimelineItemsSubscriberTest { } } - @Ignore("JNA direct mapping has broken unit tests with FFI fakes") @Test fun `when timeline emits a non empty list of items, the flow must emits a non empty list`() = runTest { val timelineItems: MutableSharedFlow> = @@ -76,7 +73,6 @@ class TimelineItemsSubscriberTest { } } - @Ignore("JNA direct mapping has broken unit tests with FFI fakes") @Test fun `when timeline emits an item with SYNC origin`() = runTest { val timelineItems: MutableSharedFlow> = @@ -108,7 +104,6 @@ class TimelineItemsSubscriberTest { } } - @Ignore("JNA direct mapping has broken unit tests with FFI fakes") @Test fun `multiple subscriptions does not have side effect`() = runTest { val timelineItemsSubscriber = createTimelineItemsSubscriber() 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 940cce6164c..56527574d77 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 @@ -9,6 +9,7 @@ package io.element.android.libraries.matrix.test import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.analytics.SdkStoreSizes import io.element.android.libraries.matrix.api.core.DeviceId import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomAlias @@ -18,6 +19,8 @@ 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.createroom.CreateRoomParameters import io.element.android.libraries.matrix.api.encryption.EncryptionService +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.MediaPreviewService import io.element.android.libraries.matrix.api.notification.NotificationService @@ -95,6 +98,9 @@ class FakeMatrixClient( private val deactivateAccountResult: (String, Boolean) -> Result = { _, _ -> lambdaError() }, private val currentSlidingSyncVersionLambda: () -> Result = { lambdaError() }, private val ignoreUserResult: (UserId) -> Result = { lambdaError() }, + private val canLinkNewDeviceResult: () -> Result = { lambdaError() }, + private val createLinkMobileHandlerResult: () -> Result = { lambdaError() }, + private val createLinkDesktopHandlerResult: () -> Result = { lambdaError() }, private var unIgnoreUserResult: (UserId) -> Result = { Result.success(Unit) }, private val canReportRoomLambda: () -> Boolean = { false }, private val isLivekitRtcSupportedLambda: () -> Boolean = { false }, @@ -104,6 +110,9 @@ class FakeMatrixClient( private val getRecentEmojisLambda: () -> Result> = { Result.success(emptyList()) }, private val addRecentEmojiLambda: (String) -> Result = { Result.success(Unit) }, private val markRoomAsFullyReadResult: (RoomId, EventId) -> Result = { _, _ -> lambdaError() }, + private val performDatabaseVacuumLambda: () -> Result = { lambdaError() }, + private val getDatabaseSizesLambda: () -> Result = { lambdaError() }, + private val resetWellKnownConfigLambda: () -> Result = { lambdaError() }, ) : MatrixClient { var setDisplayNameCalled: Boolean = false private set @@ -183,6 +192,10 @@ class FakeMatrixClient( return 0 } + override suspend fun getDatabaseSizes(): Result { + return getDatabaseSizesLambda() + } + override suspend fun clearCache() = simulateLongTask { clearCacheLambda() } @@ -351,4 +364,24 @@ class FakeMatrixClient( override suspend fun markRoomAsFullyRead(roomId: RoomId, eventId: EventId): Result { return markRoomAsFullyReadResult(roomId, eventId) } + + override suspend fun performDatabaseVacuum(): Result { + return performDatabaseVacuumLambda() + } + + override suspend fun canLinkNewDevice(): Result = simulateLongTask { + return canLinkNewDeviceResult() + } + + override fun createLinkDesktopHandler(): Result { + return createLinkDesktopHandlerResult() + } + + override fun createLinkMobileHandler(): Result { + return createLinkMobileHandlerResult() + } + + override suspend fun resetWellKnownConfig(): Result { + return resetWellKnownConfigLambda() + } } diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt index dbbf2f2fae6..c41ce0fe3e9 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt @@ -46,6 +46,7 @@ val A_SPACE_ID_2 = SpaceId("!aSpaceId2:domain") val A_ROOM_ID = RoomId("!aRoomId:domain") val A_ROOM_ID_2 = RoomId("!aRoomId2:domain") val A_ROOM_ID_3 = RoomId("!aRoomId3:domain") +val A_ROOM_ID_4 = RoomId("!aRoomId4:domain") val A_THREAD_ID = ThreadId("\$aThreadId") val A_THREAD_ID_2 = ThreadId("\$aThreadId2") val AN_EVENT_ID = EventId("\$anEventId") @@ -100,3 +101,31 @@ const val A_LOGIN_HINT = "mxid:@alice:example.org" @ColorInt const val A_COLOR_INT: Int = 0xFFFF0000.toInt() + +// From https://github.com/matrix-org/matrix-rust-sdk/blob/3a63838cdb50cde3d74da920186fbae0a2e6db37/crates/matrix-sdk-crypto/src/types/qr_login.rs#L275 +// Test vector for the QR code data, copied from the MSC. +@Suppress("ktlint:standard:argument-list-wrapping") +val QR_CODE_DATA = listOf( + 0x4D, 0x41, 0x54, 0x52, 0x49, 0x58, 0x02, 0x03, 0xd8, 0x86, 0x68, 0x6a, 0xb2, 0x19, 0x7b, + 0x78, 0x0e, 0x30, 0x0a, 0x9d, 0x4a, 0x21, 0x47, 0x48, 0x07, 0x00, 0xd7, 0x92, 0x9f, 0x39, + 0xab, 0x31, 0xb9, 0xe5, 0x14, 0x37, 0x02, 0x48, 0xed, 0x6b, 0x00, 0x47, 0x68, 0x74, 0x74, + 0x70, 0x73, 0x3a, 0x2f, 0x2f, 0x72, 0x65, 0x6e, 0x64, 0x65, 0x7a, 0x76, 0x6f, 0x75, 0x73, + 0x2e, 0x6c, 0x61, 0x62, 0x2e, 0x65, 0x6c, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x64, 0x65, + 0x76, 0x2f, 0x65, 0x38, 0x64, 0x61, 0x36, 0x33, 0x35, 0x35, 0x2d, 0x35, 0x35, 0x30, 0x62, + 0x2d, 0x34, 0x61, 0x33, 0x32, 0x2d, 0x61, 0x31, 0x39, 0x33, 0x2d, 0x31, 0x36, 0x31, 0x39, + 0x64, 0x39, 0x38, 0x33, 0x30, 0x36, 0x36, 0x38, +).map { it.toByte() }.toByteArray() + +// Test vector for the QR code data, copied from the MSC, with the mode set to reciprocate. +@Suppress("ktlint:standard:argument-list-wrapping") +val QR_CODE_DATA_RECIPROCATE = listOf( + 0x4D, 0x41, 0x54, 0x52, 0x49, 0x58, 0x02, 0x04, 0xd8, 0x86, 0x68, 0x6a, 0xb2, 0x19, 0x7b, + 0x78, 0x0e, 0x30, 0x0a, 0x9d, 0x4a, 0x21, 0x47, 0x48, 0x07, 0x00, 0xd7, 0x92, 0x9f, 0x39, + 0xab, 0x31, 0xb9, 0xe5, 0x14, 0x37, 0x02, 0x48, 0xed, 0x6b, 0x00, 0x47, 0x68, 0x74, 0x74, + 0x70, 0x73, 0x3a, 0x2f, 0x2f, 0x72, 0x65, 0x6e, 0x64, 0x65, 0x7a, 0x76, 0x6f, 0x75, 0x73, + 0x2e, 0x6c, 0x61, 0x62, 0x2e, 0x65, 0x6c, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x2e, 0x64, 0x65, + 0x76, 0x2f, 0x65, 0x38, 0x64, 0x61, 0x36, 0x33, 0x35, 0x35, 0x2d, 0x35, 0x35, 0x30, 0x62, + 0x2d, 0x34, 0x61, 0x33, 0x32, 0x2d, 0x61, 0x31, 0x39, 0x33, 0x2d, 0x31, 0x36, 0x31, 0x39, + 0x64, 0x39, 0x38, 0x33, 0x30, 0x36, 0x36, 0x38, 0x00, 0x0A, 0x6d, 0x61, 0x74, 0x72, 0x69, + 0x78, 0x2e, 0x6f, 0x72, 0x67, +).map { it.toByte() }.toByteArray() diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/analytics/FakeAnalyticsSdkManager.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/analytics/FakeAnalyticsSdkManager.kt new file mode 100644 index 00000000000..ab4fd484770 --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/analytics/FakeAnalyticsSdkManager.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2025 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.analytics + +import io.element.android.services.analytics.api.AnalyticsSdkManager +import io.element.android.services.analytics.api.AnalyticsSdkSpan +import io.element.android.services.analytics.api.NoopAnalyticsSdkSpan +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeAnalyticsSdkManager( + private val enableSdkAnalyticsLambda: ((Boolean) -> Unit) = { lambdaError() }, +) : AnalyticsSdkManager { + override fun enableSdkAnalytics(enabled: Boolean) { + enableSdkAnalyticsLambda(enabled) + } + + override fun startSpan(name: String, parentTraceId: String?): AnalyticsSdkSpan = NoopAnalyticsSdkSpan + override fun bridge(parentTraceId: String?): AnalyticsSdkSpan = NoopAnalyticsSdkSpan +} diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/linknewdevice/FakeCheckCodeSender.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/linknewdevice/FakeCheckCodeSender.kt new file mode 100644 index 00000000000..a0692b6f5c0 --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/linknewdevice/FakeCheckCodeSender.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2025 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.linknewdevice + +import io.element.android.libraries.matrix.api.linknewdevice.CheckCodeSender +import io.element.android.tests.testutils.lambda.lambdaError +import io.element.android.tests.testutils.simulateLongTask + +class FakeCheckCodeSender( + private val validateResult: (UByte) -> Boolean = { lambdaError() }, + private val sendResult: (UByte) -> Result = { lambdaError() }, +) : CheckCodeSender { + override suspend fun validate(code: UByte): Boolean = simulateLongTask { + validateResult(code) + } + + override suspend fun send(code: UByte): Result = simulateLongTask { + sendResult(code) + } +} diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/linknewdevice/FakeLinkDesktopHandler.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/linknewdevice/FakeLinkDesktopHandler.kt new file mode 100644 index 00000000000..0bf9dafd018 --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/linknewdevice/FakeLinkDesktopHandler.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2025 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.linknewdevice + +import io.element.android.libraries.matrix.api.linknewdevice.LinkDesktopHandler +import io.element.android.libraries.matrix.api.linknewdevice.LinkDesktopStep +import io.element.android.tests.testutils.lambda.lambdaError +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +class FakeLinkDesktopHandler( + private val handleScannedQrCodeResult: (ByteArray) -> Unit = { lambdaError() }, +) : LinkDesktopHandler { + private val mutableLinkDesktopStep: MutableStateFlow = MutableStateFlow(LinkDesktopStep.Uninitialized) + override val linkDesktopStep: StateFlow + get() = mutableLinkDesktopStep.asStateFlow() + + override suspend fun handleScannedQrCode(data: ByteArray) { + handleScannedQrCodeResult(data) + } + + suspend fun emitStep(step: LinkDesktopStep) { + mutableLinkDesktopStep.emit(step) + } +} diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/linknewdevice/FakeLinkMobileHandler.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/linknewdevice/FakeLinkMobileHandler.kt new file mode 100644 index 00000000000..de704ea2dd5 --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/linknewdevice/FakeLinkMobileHandler.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2025 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.linknewdevice + +import io.element.android.libraries.matrix.api.linknewdevice.LinkMobileHandler +import io.element.android.libraries.matrix.api.linknewdevice.LinkMobileStep +import io.element.android.tests.testutils.lambda.lambdaError +import io.element.android.tests.testutils.simulateLongTask +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +class FakeLinkMobileHandler( + private val startResult: () -> Unit = { lambdaError() }, +) : LinkMobileHandler { + private val mutableLinkMobileStep: MutableStateFlow = MutableStateFlow(LinkMobileStep.Uninitialized) + override val linkMobileStep: StateFlow + get() = mutableLinkMobileStep.asStateFlow() + + override suspend fun start() = simulateLongTask { + startResult() + } + + suspend fun emitStep(step: LinkMobileStep) { + mutableLinkMobileStep.emit(step) + } +} diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/notification/NotificationData.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/notification/NotificationData.kt index 3eb0758b1b0..b6b6cb66e9a 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/notification/NotificationData.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/notification/NotificationData.kt @@ -21,6 +21,7 @@ import io.element.android.libraries.matrix.test.A_USER_NAME_2 fun aNotificationData( content: NotificationContent = NotificationContent.MessageLike.RoomEncrypted, isDirect: Boolean = false, + isSpace: Boolean = false, hasMention: Boolean = false, threadId: ThreadId? = null, timestamp: Long = A_TIMESTAMP, @@ -40,6 +41,7 @@ fun aNotificationData( roomDisplayName = roomDisplayName, isDirect = isDirect, isDm = false, + isSpace = isSpace, isEncrypted = false, isNoisy = false, timestamp = timestamp, diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeBaseRoom.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeBaseRoom.kt index 56f880dc11d..78765d1ec4b 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeBaseRoom.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeBaseRoom.kt @@ -15,18 +15,18 @@ 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.UserId import io.element.android.libraries.matrix.api.room.BaseRoom -import io.element.android.libraries.matrix.api.room.MessageEventType import io.element.android.libraries.matrix.api.room.RoomInfo import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMembersState -import io.element.android.libraries.matrix.api.room.StateEventType import io.element.android.libraries.matrix.api.room.draft.ComposerDraft +import io.element.android.libraries.matrix.api.room.powerlevels.RoomPermissions import io.element.android.libraries.matrix.api.room.powerlevels.RoomPowerLevelsValues import io.element.android.libraries.matrix.api.room.tombstone.PredecessorRoom import io.element.android.libraries.matrix.api.roomdirectory.RoomVisibility import io.element.android.libraries.matrix.api.timeline.ReceiptType 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.room.powerlevels.FakeRoomPermissions import io.element.android.tests.testutils.lambda.lambdaError import io.element.android.tests.testutils.simulateLongTask import kotlinx.coroutines.CoroutineScope @@ -40,6 +40,7 @@ class FakeBaseRoom( override val sessionId: SessionId = A_SESSION_ID, override val roomId: RoomId = A_ROOM_ID, initialRoomInfo: RoomInfo = aRoomInfo(), + private val roomPermissions: RoomPermissions = FakeRoomPermissions(), override val roomCoroutineScope: CoroutineScope = TestScope(), private var roomPermalinkResult: () -> Result = { lambdaError() }, private var eventPermalinkResult: (EventId) -> Result = { lambdaError() }, @@ -48,16 +49,6 @@ class FakeBaseRoom( private val userRoleResult: () -> Result = { lambdaError() }, private val getUpdatedMemberResult: (UserId) -> Result = { lambdaError() }, private val joinRoomResult: () -> Result = { lambdaError() }, - private val canInviteResult: (UserId) -> Result = { lambdaError() }, - private val canKickResult: (UserId) -> Result = { lambdaError() }, - private val canBanResult: (UserId) -> Result = { lambdaError() }, - private val canRedactOwnResult: (UserId) -> Result = { lambdaError() }, - private val canRedactOtherResult: (UserId) -> Result = { lambdaError() }, - private val canSendStateResult: (UserId, StateEventType) -> Result = { _, _ -> lambdaError() }, - private val canUserSendMessageResult: (UserId, MessageEventType) -> Result = { _, _ -> lambdaError() }, - private val canUserTriggerRoomNotificationResult: (UserId) -> Result = { lambdaError() }, - private val canUserJoinCallResult: (UserId) -> Result = { lambdaError() }, - private val canUserPinUnpinResult: (UserId) -> Result = { lambdaError() }, private val setIsFavoriteResult: (Boolean) -> Result = { lambdaError() }, private val markAsReadResult: (ReceiptType) -> Result = { Result.success(Unit) }, private val powerLevelsResult: () -> Result = { lambdaError() }, @@ -129,6 +120,10 @@ class FakeBaseRoom( return userRoleResult() } + override suspend fun roomPermissions(): Result { + return Result.success(roomPermissions) + } + override suspend fun getPermalink(): Result { return roomPermalinkResult() } @@ -153,46 +148,6 @@ class FakeBaseRoom( return forgetResult() } - override suspend fun canUserBan(userId: UserId): Result { - return canBanResult(userId) - } - - override suspend fun canUserKick(userId: UserId): Result { - return canKickResult(userId) - } - - override suspend fun canUserInvite(userId: UserId): Result { - return canInviteResult(userId) - } - - override suspend fun canUserRedactOwn(userId: UserId): Result { - return canRedactOwnResult(userId) - } - - override suspend fun canUserRedactOther(userId: UserId): Result { - return canRedactOtherResult(userId) - } - - override suspend fun canUserSendState(userId: UserId, type: StateEventType): Result { - return canSendStateResult(userId, type) - } - - override suspend fun canUserSendMessage(userId: UserId, type: MessageEventType): Result { - return canUserSendMessageResult(userId, type) - } - - override suspend fun canUserTriggerRoomNotification(userId: UserId): Result { - return canUserTriggerRoomNotificationResult(userId) - } - - override suspend fun canUserJoinCall(userId: UserId): Result { - return canUserJoinCallResult(userId) - } - - override suspend fun canUserPinUnpin(userId: UserId): Result { - return canUserPinUnpinResult(userId) - } - override suspend fun setIsFavorite(isFavorite: Boolean): Result { return setIsFavoriteResult(isFavorite) } @@ -256,10 +211,11 @@ fun defaultRoomPowerLevelValues() = RoomPowerLevelsValues( ban = 50, invite = 0, kick = 50, - sendEvents = 0, + eventsDefault = 0, + stateDefault = 50, redactEvents = 50, - roomName = 100, - roomAvatar = 100, - roomTopic = 100, - spaceChild = 100, + roomName = 50, + roomAvatar = 50, + roomTopic = 50, + spaceChild = 50, ) diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeJoinedRoom.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeJoinedRoom.kt index d2af1daac06..9016372603c 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeJoinedRoom.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeJoinedRoom.kt @@ -24,6 +24,7 @@ import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.room.RoomInfo import io.element.android.libraries.matrix.api.room.RoomMembersState import io.element.android.libraries.matrix.api.room.RoomNotificationSettingsState +import io.element.android.libraries.matrix.api.room.SendQueueUpdate import io.element.android.libraries.matrix.api.room.history.RoomHistoryVisibility import io.element.android.libraries.matrix.api.room.join.JoinRule import io.element.android.libraries.matrix.api.room.knock.KnockRequest @@ -40,6 +41,7 @@ import io.element.android.tests.testutils.simulateLongTask import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.test.TestScope @@ -86,6 +88,8 @@ class FakeJoinedRoom( private val setSendQueueEnabledResult: (Boolean) -> Unit = { _: Boolean -> }, private val setAccessRuleResult: (RoomAccessRules) -> Result = { lambdaError() }, ) : JoinedRoom, BaseRoom by baseRoom { + private val sendQueueUpdates = MutableSharedFlow(extraBufferCapacity = 10) + fun givenRoomMembersState(state: RoomMembersState) { baseRoom.givenRoomMembersState(state) } @@ -222,10 +226,15 @@ class FakeJoinedRoom( withdrawVerificationAndResendResult(userIds, sendHandle) } + // TCHAP access rule override suspend fun setAccessRule(rule: RoomAccessRules): Result = simulateLongTask { setAccessRuleResult(rule) } + override fun subscribeToSendQueueUpdates(): Flow { + return sendQueueUpdates + } + private suspend fun simulateSendMediaProgress(progressCallback: ProgressCallback?) { progressCallbackValues.forEach { (current, total) -> progressCallback?.onProgress(current, total) @@ -236,4 +245,8 @@ class FakeJoinedRoom( fun emitSyncUpdate() { (syncUpdateFlow as MutableStateFlow).value = syncUpdateFlow.value + 1 } + + suspend fun givenSendQueueUpdate(sendQueueUpdate: SendQueueUpdate) { + sendQueueUpdates.emit(sendQueueUpdate) + } } diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomInfoFixture.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomInfoFixture.kt index 1137f799f48..7aedfc9422e 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomInfoFixture.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomInfoFixture.kt @@ -68,6 +68,7 @@ fun aRoomInfo( historyVisibility: RoomHistoryVisibility = RoomHistoryVisibility.Joined, roomVersion: String? = "11", privilegedCreatorRole: Boolean = false, + // TCHAP external user isOpenToExternalUsers: Boolean = false, ) = RoomInfo( id = id, @@ -105,5 +106,6 @@ fun aRoomInfo( historyVisibility = historyVisibility, roomVersion = roomVersion, privilegedCreatorRole = privilegedCreatorRole, + // TCHAP external user isOpenToExternalUsers = isOpenToExternalUsers, ) diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomSummaryFixture.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomSummaryFixture.kt index 6714941ef0f..5375a2c18ab 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomSummaryFixture.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/RoomSummaryFixture.kt @@ -78,6 +78,7 @@ fun aRoomSummary( latestEvent: LatestEventValue = aRemoteLatestEvent(), roomVersion: String? = "11", privilegedCreatorRole: Boolean = false, + // TCHAP external user isOpenToExternalUsers: Boolean = false, ) = RoomSummary( info = RoomInfo( @@ -116,6 +117,7 @@ fun aRoomSummary( historyVisibility = historyVisibility, roomVersion = roomVersion, privilegedCreatorRole = privilegedCreatorRole, + // TCHAP external user isOpenToExternalUsers = isOpenToExternalUsers, ), latestEvent = latestEvent, diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/powerlevels/FakeRoomPermissions.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/powerlevels/FakeRoomPermissions.kt new file mode 100644 index 00000000000..b78dc9ba1b3 --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/powerlevels/FakeRoomPermissions.kt @@ -0,0 +1,58 @@ +/* + * Copyright (c) 2025 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.room.powerlevels + +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.room.MessageEventType +import io.element.android.libraries.matrix.api.room.StateEventType +import io.element.android.libraries.matrix.api.room.powerlevels.RoomPermissions + +data class FakeRoomPermissions( + private val canBan: Boolean = false, + private val canInvite: Boolean = false, + private val canKick: Boolean = false, + private val canPinUnpin: Boolean = false, + private val canRedactOther: Boolean = false, + private val canRedactOwn: Boolean = false, + private val canTriggerRoomNotification: Boolean = false, + private val canSendMessage: (MessageEventType) -> Boolean = { false }, + private val canSendState: (StateEventType) -> Boolean = { false }, + private val canUserBan: (UserId) -> Boolean = { false }, + private val canUserInvite: (UserId) -> Boolean = { false }, + private val canUserKick: (UserId) -> Boolean = { false }, + private val canUserPinUnpin: (UserId) -> Boolean = { false }, + private val canUserRedactOther: (UserId) -> Boolean = { false }, + private val canUserRedactOwn: (UserId) -> Boolean = { false }, + private val canUserTriggerRoomNotification: (UserId) -> Boolean = { false }, + private val canUserSendMessage: (UserId, MessageEventType) -> Boolean = { _, _ -> false }, + private val canUserSendState: (UserId, StateEventType) -> Boolean = { _, _ -> false }, +) : RoomPermissions { + override fun canOwnUserBan(): Boolean = canBan + override fun canOwnUserInvite(): Boolean = canInvite + override fun canOwnUserKick(): Boolean = canKick + override fun canOwnUserPinUnpin(): Boolean = canPinUnpin + override fun canOwnUserRedactOther(): Boolean = canRedactOther + override fun canOwnUserRedactOwn(): Boolean = canRedactOwn + override fun canOwnUserSendMessage(message: MessageEventType): Boolean = canSendMessage(message) + override fun canOwnUserSendState(stateEvent: StateEventType): Boolean = canSendState(stateEvent) + + override fun canOwnUserTriggerRoomNotification(): Boolean = canTriggerRoomNotification + override fun canUserBan(userId: UserId): Boolean = canUserBan(userId) + override fun canUserInvite(userId: UserId): Boolean = canUserInvite(userId) + override fun canUserKick(userId: UserId): Boolean = canUserKick(userId) + override fun canUserPinUnpin(userId: UserId): Boolean = canUserPinUnpin(userId) + override fun canUserRedactOther(userId: UserId): Boolean = canUserRedactOther(userId) + override fun canUserRedactOwn(userId: UserId): Boolean = canUserRedactOwn(userId) + override fun canUserSendMessage(userId: UserId, message: MessageEventType): Boolean = canUserSendMessage(userId, message) + override fun canUserSendState(userId: UserId, stateEvent: StateEventType): Boolean = canUserSendState(userId, stateEvent) + override fun canUserTriggerRoomNotification(userId: UserId): Boolean = canUserTriggerRoomNotification(userId) + + override fun close() { + // no-op for the fake + } +} diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/spaces/FakeSpaceService.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/spaces/FakeSpaceService.kt index eaa36ee750d..0cd9907db3e 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/spaces/FakeSpaceService.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/spaces/FakeSpaceService.kt @@ -23,6 +23,9 @@ class FakeSpaceService( private val joinedSpacesResult: () -> Result> = { lambdaError() }, private val spaceRoomListResult: (RoomId) -> SpaceRoomList = { lambdaError() }, private val leaveSpaceHandleResult: (RoomId) -> LeaveSpaceHandle = { lambdaError() }, + private val removeChildFromSpaceResult: (RoomId, RoomId) -> Result = { _, _ -> lambdaError() }, + private val joinedParentsResult: (RoomId) -> Result> = { lambdaError() }, + private val getSpaceRoomResult: (RoomId) -> SpaceRoom? = { lambdaError() }, ) : SpaceService { private val _spaceRoomsFlow = MutableSharedFlow>() override val spaceRoomsFlow: SharedFlow> @@ -36,6 +39,14 @@ class FakeSpaceService( return joinedSpacesResult() } + override suspend fun joinedParents(spaceId: RoomId): Result> { + return joinedParentsResult(spaceId) + } + + override suspend fun getSpaceRoom(spaceId: RoomId): SpaceRoom? { + return getSpaceRoomResult(spaceId) + } + override fun spaceRoomList(id: RoomId): SpaceRoomList { return spaceRoomListResult(id) } @@ -43,4 +54,8 @@ class FakeSpaceService( override fun getLeaveSpaceHandle(spaceId: RoomId): LeaveSpaceHandle { return leaveSpaceHandleResult(spaceId) } + + override suspend fun removeChildFromSpace(spaceId: RoomId, childId: RoomId): Result = simulateLongTask { + removeChildFromSpaceResult(spaceId, childId) + } } 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 6115dd2345d..4451de6276f 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 @@ -158,7 +158,7 @@ class FakeTimeline( imageInfo: ImageInfo, body: String?, formattedBody: String?, - inReplyToEventId: EventId??, + inReplyToEventId: EventId?, ) -> Result = { _, _, _, _, _, _ -> Result.success(FakeMediaUploadHandler()) } @@ -169,7 +169,7 @@ class FakeTimeline( imageInfo: ImageInfo, caption: String?, formattedCaption: String?, - inReplyToEventId: EventId??, + inReplyToEventId: EventId?, ): Result = simulateLongTask { sendImageLambda( file, @@ -187,7 +187,7 @@ class FakeTimeline( videoInfo: VideoInfo, body: String?, formattedBody: String?, - inReplyToEventId: EventId??, + inReplyToEventId: EventId?, ) -> Result = { _, _, _, _, _, _ -> Result.success(FakeMediaUploadHandler()) } @@ -198,7 +198,7 @@ class FakeTimeline( videoInfo: VideoInfo, caption: String?, formattedCaption: String?, - inReplyToEventId: EventId??, + inReplyToEventId: EventId?, ): Result = simulateLongTask { sendVideoLambda( file, @@ -215,7 +215,7 @@ class FakeTimeline( audioInfo: AudioInfo, caption: String?, formattedCaption: String?, - inReplyToEventId: EventId??, + inReplyToEventId: EventId?, ) -> Result = { _, _, _, _, _ -> Result.success(FakeMediaUploadHandler()) } @@ -225,7 +225,7 @@ class FakeTimeline( audioInfo: AudioInfo, caption: String?, formattedCaption: String?, - inReplyToEventId: EventId??, + inReplyToEventId: EventId?, ): Result = simulateLongTask { sendAudioLambda( file, @@ -241,7 +241,7 @@ class FakeTimeline( fileInfo: FileInfo, caption: String?, formattedCaption: String?, - inReplyToEventId: EventId??, + inReplyToEventId: EventId?, ) -> Result = { _, _, _, _, _ -> Result.success(FakeMediaUploadHandler()) } @@ -251,7 +251,7 @@ class FakeTimeline( fileInfo: FileInfo, caption: String?, formattedCaption: String?, - inReplyToEventId: EventId??, + inReplyToEventId: EventId?, ): Result = simulateLongTask { sendFileLambda( file, @@ -266,7 +266,7 @@ class FakeTimeline( file: File, audioInfo: AudioInfo, waveform: List, - inReplyToEventId: EventId??, + inReplyToEventId: EventId?, ) -> Result = { _, _, _, _ -> Result.success(FakeMediaUploadHandler()) } @@ -275,7 +275,7 @@ class FakeTimeline( file: File, audioInfo: AudioInfo, waveform: List, - inReplyToEventId: EventId??, + inReplyToEventId: EventId?, ): Result = simulateLongTask { sendVoiceMessageLambda( file, @@ -291,7 +291,7 @@ class FakeTimeline( description: String?, zoomLevel: Int?, assetType: AssetType?, - inReplyToEventId: EventId??, + inReplyToEventId: EventId?, ) -> Result = { _, _, _, _, _, _ -> lambdaError() } @@ -302,7 +302,7 @@ class FakeTimeline( description: String?, zoomLevel: Int?, assetType: AssetType?, - inReplyToEventId: EventId??, + inReplyToEventId: EventId?, ): Result = simulateLongTask { sendLocationLambda( body, 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 c8d8ff60153..24c26a67348 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 @@ -123,11 +123,13 @@ fun aStickerContent( info: ImageInfo, mediaSource: MediaSource, body: String? = null, + threadInfo: EventThreadInfo? = null, ) = StickerContent( filename = filename, body = body, info = info, source = mediaSource, + threadInfo = threadInfo, ) fun aTimelineItemDebugInfo( @@ -148,6 +150,7 @@ fun aPollContent( votes: ImmutableMap> = persistentMapOf(), endTime: ULong? = null, isEdited: Boolean = false, + threadInfo: EventThreadInfo? = null, ) = PollContent( question = question, kind = kind, @@ -156,4 +159,5 @@ fun aPollContent( votes = votes, endTime = endTime, isEdited = isEdited, + threadInfo = threadInfo, ) 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/matrix/ui/media/AvatarDataFetcherFactory.kt index b6093cd3277..9d7767e42d0 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/matrix/ui/media/AvatarDataFetcherFactory.kt @@ -11,6 +11,7 @@ package io.element.android.libraries.matrix.ui.media import coil3.ImageLoader import coil3.fetch.Fetcher import coil3.request.Options +import coil3.toUri import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.matrix.api.media.MatrixMediaLoader @@ -21,10 +22,19 @@ internal class AvatarDataFetcherFactory( data: AvatarData, options: Options, imageLoader: ImageLoader - ): Fetcher { - return CoilMediaFetcher( - mediaLoader = matrixMediaLoader, - mediaData = data.toMediaRequestData(), - ) + ): Fetcher? { + return when { + data.url == null -> null + data.url?.startsWith("mxc") == true -> CoilMediaFetcher( + mediaLoader = matrixMediaLoader, + mediaData = data.toMediaRequestData(), + ) + else -> { + // If the URL does not use the mxc scheme, it might be a local one using `content://`, try using a fallback fetcher + data.url?.toUri()?.let { uri -> + imageLoader.components.newFetcher(uri, options, imageLoader) + }?.first + } + } } } 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/matrix/ui/media/AvatarDataFetcherFactoryTest.kt new file mode 100644 index 00000000000..eefacb3065a --- /dev/null +++ b/libraries/matrixmedia/impl/src/test/kotlin/io/element/android/libraries/matrix/ui/media/AvatarDataFetcherFactoryTest.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.libraries.matrix.ui.media + +import android.graphics.Bitmap +import coil3.ComponentRegistry +import coil3.ImageLoader +import coil3.asImage +import coil3.disk.DiskCache +import coil3.memory.MemoryCache +import coil3.request.Disposable +import coil3.request.ImageRequest +import coil3.request.ImageResult +import coil3.request.Options +import coil3.request.SuccessResult +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.designsystem.components.avatar.anAvatarData +import io.element.android.libraries.matrix.test.media.FakeMatrixMediaLoader +import io.mockk.mockk +import org.junit.Test + +class AvatarDataFetcherFactoryTest { + @Test + fun `create - with mxc returns CoilMediaFetcher`() { + val factory = AvatarDataFetcherFactory(matrixMediaLoader = FakeMatrixMediaLoader()) + + val fetcher = factory.create(anAvatarData(url = "mxc://test"), Options(mockk()), imageLoader = FakeImageLoader()) + assertThat(fetcher).isInstanceOf(CoilMediaFetcher::class.java) + } + + @Test + fun `create - with http or https returns null, which means fallback default fetcher will be used`() { + val factory = AvatarDataFetcherFactory(matrixMediaLoader = FakeMatrixMediaLoader()) + + val fetcherHttp = factory.create(anAvatarData(url = "http://test"), Options(mockk()), imageLoader = FakeImageLoader()) + assertThat(fetcherHttp).isNull() + + val fetcherHttps = factory.create(anAvatarData(url = "https://test"), Options(mockk()), imageLoader = FakeImageLoader()) + assertThat(fetcherHttps).isNull() + } + + @Test + fun `create - with content scheme returns null, which means fallback default fetcher will be used`() { + val factory = AvatarDataFetcherFactory(matrixMediaLoader = FakeMatrixMediaLoader()) + + val fetcher = factory.create(anAvatarData(url = "content://test"), Options(mockk()), imageLoader = FakeImageLoader()) + assertThat(fetcher).isNull() + } +} + +private class FakeImageLoader : ImageLoader { + override val defaults: ImageRequest.Defaults = ImageRequest.Defaults.DEFAULT + override val components: ComponentRegistry = ComponentRegistry.Builder().build() + override val memoryCache: MemoryCache? = null + override val diskCache: DiskCache? = null + + override fun enqueue(request: ImageRequest): Disposable { + return mockk() + } + + override suspend fun execute(request: ImageRequest): ImageResult { + return SuccessResult( + image = Bitmap.createBitmap(1, 1, Bitmap.Config.ALPHA_8).asImage(), + request = request, + ) + } + + override fun shutdown() {} + + override fun newBuilder(): ImageLoader.Builder { + return ImageLoader.Builder(mockk()) + } +} diff --git a/libraries/matrixui/build.gradle.kts b/libraries/matrixui/build.gradle.kts index 9e9fd38b495..1c17b479276 100644 --- a/libraries/matrixui/build.gradle.kts +++ b/libraries/matrixui/build.gradle.kts @@ -37,7 +37,7 @@ dependencies { implementation(projects.libraries.tchaputils) implementation(projects.libraries.testtags) implementation(libs.coil.compose) - implementation(libs.jsoup) + implementation(libs.matrix.richtexteditor) implementation(projects.libraries.previewutils) testCommonDependencies(libs, true) diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/AvatarPickerView.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/AvatarPickerView.kt new file mode 100644 index 00000000000..1c904e891bc --- /dev/null +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/AvatarPickerView.kt @@ -0,0 +1,436 @@ +/* + * 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 + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.offset +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.ripple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.Immutable +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.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.LocalLayoutDirection +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.clearAndSetSemantics +import androidx.compose.ui.semantics.contentDescription +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.compound.tokens.generated.CompoundIcons +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 +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.Icon +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.testtags.TestTags +import io.element.android.libraries.testtags.testTag +import io.element.android.libraries.ui.strings.CommonStrings + +/** + * Avatar picker view, based on https://www.figma.com/design/kcnHxunG1LDWXsJhaNuiHz/ER-145--Spaces-on-Element-X?node-id=5918-97417&t=JYDQysgjS33AZb74-4 + * + * It takes a [state], which can be [AvatarPickerState.Pick] for displaying the 'pick avatar' button, or [AvatarPickerState.Selected] when an avatar has + * already been selected. + * + * Note: this function contains lots of 'magic numbers', but those are just the fractions used to scale the different dimensions based on the Figma design. + */ +@Composable +fun AvatarPickerView( + state: AvatarPickerState, + modifier: Modifier = Modifier, + onClick: (() -> Unit) = {}, + onClickLabel: String? = stringResource(CommonStrings.a11y_edit_avatar), + enabled: Boolean = true, +) { + val a11yAvatar = stringResource(CommonStrings.a11y_avatar) + + val clickableModifier = Modifier.clickable( + enabled = enabled, + interactionSource = remember { MutableInteractionSource() }, + onClickLabel = onClickLabel, + onClick = onClick, + indication = ripple(bounded = false), + ) + .testTag(TestTags.editAvatar) + .clearAndSetSemantics { + contentDescription = a11yAvatar + } + + val layoutDirection = LocalLayoutDirection.current + + fun eraseBackgroundModifier( + parentWidth: Dp, + editIconRadius: Dp, + ) = Modifier + .graphicsLayer { + compositingStrategy = CompositingStrategy.Offscreen + } + .drawWithContent { + drawContent() + drawCircle( + color = Color.Black, + center = Offset( + x = if (layoutDirection == LayoutDirection.Ltr) { + parentWidth.toPx() - editIconRadius.toPx() * 0.48f + } else { + editIconRadius.toPx() * 0.48f + }, + y = size.height - editIconRadius.toPx(), + ), + radius = editIconRadius.toPx() * 1.2f, + blendMode = BlendMode.Clear, + ) + } + + when (state) { + is AvatarPickerState.Pick -> { + PickButton( + buttonSize = state.buttonSize, + iconSize = state.iconSize, + iconId = state.iconId, + modifier = modifier.padding(state.externalPadding).then(clickableModifier), + ) + } + is AvatarPickerState.Selected -> { + Box(modifier = modifier) { + Avatar( + avatarData = state.avatarData, + avatarType = state.type, + modifier = clickableModifier.then(eraseBackgroundModifier(state.avatarData.size.dp, state.avatarData.size.dp * 0.225f)), + ) + + OverlayEditButton(editButtonSize = state.avatarData.size.dp * 0.44f) + } + } + } +} + +@Composable +private fun PickButton( + buttonSize: Dp, + iconSize: Dp, + @DrawableRes iconId: Int, + modifier: Modifier = Modifier, +) { + Box( + modifier = modifier + .size(buttonSize) + .clip(CircleShape) + .border(BorderStroke(1.dp, ElementTheme.colors.borderInteractiveSecondary), shape = CircleShape) + ) { + Icon( + resourceId = iconId, + contentDescription = null, + modifier = Modifier + .align(Alignment.Center) + .size(iconSize), + tint = ElementTheme.colors.iconPrimary, + ) + } +} + +@Composable +private fun BoxScope.OverlayEditButton(editButtonSize: Dp) { + Box( + modifier = Modifier.align(Alignment.BottomEnd) + .size(editButtonSize) + .offset(x = editButtonSize * 0.266f) + .clip(CircleShape) + .background(ElementTheme.colors.bgCanvasDefault) + .border(BorderStroke(1.dp, ElementTheme.colors.borderInteractiveSecondary), shape = CircleShape), + contentAlignment = Alignment.Center, + ) { + Icon( + modifier = Modifier.size(editButtonSize * 0.66f), + imageVector = CompoundIcons.Edit(), + contentDescription = null, + ) + } +} + +@Immutable +sealed interface AvatarPickerState { + data class Pick( + val buttonSize: Dp, + val iconSize: Dp = buttonSize / 2, + val externalPadding: PaddingValues = PaddingValues.Zero, + @DrawableRes val iconId: Int = CompoundDrawables.ic_compound_take_photo, + ) : AvatarPickerState + + data class Selected( + val avatarData: AvatarData, + val type: AvatarType, + ) : AvatarPickerState +} + +@PreviewsDayNight +@Composable +internal fun AvatarPickerViewPreview() = ElementPreview { + PreviewContent() +} + +@PreviewsDayNight +@Composable +internal fun AvatarPickerViewRtlPreview() = CompositionLocalProvider( + LocalLayoutDirection provides LayoutDirection.Rtl, +) { + ElementPreview { PreviewContent() } +} + +@PreviewsDayNight +@Composable +internal fun AvatarPickerSizesPreview() = ElementPreview { + Column { + Row { + AvatarPickerView(AvatarPickerState.Pick(buttonSize = 24.dp, externalPadding = PaddingValues(6.dp)), onClick = {}) + AvatarPickerView(AvatarPickerState.Pick(buttonSize = 32.dp, externalPadding = PaddingValues(6.dp)), onClick = {}) + AvatarPickerView(AvatarPickerState.Pick(buttonSize = 48.dp, externalPadding = PaddingValues(6.dp)), onClick = {}) + AvatarPickerView(AvatarPickerState.Pick(buttonSize = 64.dp, externalPadding = PaddingValues(6.dp)), onClick = {}) + AvatarPickerView(AvatarPickerState.Pick(buttonSize = 96.dp, externalPadding = PaddingValues(6.dp)), onClick = {}) + } + Row { + AvatarPickerView( + AvatarPickerState.Selected( + avatarData = AvatarData("@user:example.com", "User", "content://test", size = AvatarSize.TimelineThreadLatestEventSender), + type = AvatarType.User + ), + onClick = {}, + modifier = Modifier.padding(6.dp) + ) + AvatarPickerView( + AvatarPickerState.Selected( + avatarData = AvatarData("@user:example.com", "User", "content://test", size = AvatarSize.ReadReceiptList), + type = AvatarType.User + ), + onClick = {}, + modifier = Modifier.padding(6.dp) + ) + AvatarPickerView( + AvatarPickerState.Selected( + avatarData = AvatarData("@user:example.com", "User", "content://test", size = AvatarSize.SelectedUser), + type = AvatarType.User + ), + onClick = {}, + modifier = Modifier.padding(6.dp) + ) + AvatarPickerView( + AvatarPickerState.Selected( + avatarData = AvatarData("@user:example.com", "User", "content://test", size = AvatarSize.EditRoomDetails), + type = AvatarType.User + ), + onClick = {}, + modifier = Modifier.padding(6.dp) + ) + AvatarPickerView( + AvatarPickerState.Selected( + avatarData = AvatarData("@user:example.com", "User", "content://test", size = AvatarSize.RoomListManageUser), + type = AvatarType.User + ), + onClick = {}, + modifier = Modifier.padding(6.dp) + ) + } + Row { + AvatarPickerView( + AvatarPickerState.Selected( + avatarData = AvatarData("@user:example.com", "User", "content://test", size = AvatarSize.TimelineThreadLatestEventSender), + type = AvatarType.Space() + ), + onClick = {}, + modifier = Modifier.padding(6.dp) + ) + AvatarPickerView( + AvatarPickerState.Selected( + avatarData = AvatarData("@user:example.com", "User", "content://test", size = AvatarSize.ReadReceiptList), + type = AvatarType.Space() + ), + onClick = {}, + modifier = Modifier.padding(6.dp) + ) + AvatarPickerView( + AvatarPickerState.Selected( + avatarData = AvatarData("@user:example.com", "User", "content://test", size = AvatarSize.SelectedUser), + type = AvatarType.Space() + ), + onClick = {}, + modifier = Modifier.padding(6.dp) + ) + AvatarPickerView( + AvatarPickerState.Selected( + avatarData = AvatarData("@user:example.com", "User", "content://test", size = AvatarSize.EditRoomDetails), + type = AvatarType.Space() + ), + onClick = {}, + modifier = Modifier.padding(6.dp) + ) + AvatarPickerView( + AvatarPickerState.Selected( + avatarData = AvatarData("@user:example.com", "User", "content://test", size = AvatarSize.RoomListManageUser), + type = AvatarType.Space() + ), + onClick = {}, + modifier = Modifier.padding(6.dp) + ) + } + } +} + +@Composable +private fun PreviewContent() { + Column( + modifier = Modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally + ) { + Text("Pick image") + AvatarPickerView(AvatarPickerState.Pick(buttonSize = 48.dp, externalPadding = PaddingValues(6.dp)), onClick = {}) + HorizontalDivider() + + Text("User avatar") + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text("No url") + AvatarPickerView( + AvatarPickerState.Selected( + avatarData = AvatarData("@user:example.com", "User", null, size = AvatarSize.EditRoomDetails), + type = AvatarType.User + ), + onClick = {}, + modifier = Modifier.padding(10.dp) + ) + } + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text("Local") + AvatarPickerView( + AvatarPickerState.Selected( + avatarData = AvatarData("@user:example.com", "User", "content://test", size = AvatarSize.EditRoomDetails), + type = AvatarType.User + ), + onClick = {}, + modifier = Modifier.padding(10.dp) + ) + } + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text("MXC") + AvatarPickerView( + AvatarPickerState.Selected( + avatarData = AvatarData("@user:example.com", "User", "mxc://test", size = AvatarSize.EditRoomDetails), + type = AvatarType.User + ), + onClick = {}, + modifier = Modifier.padding(10.dp) + ) + } + } + HorizontalDivider() + + Text("Room avatar") + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text("No url") + AvatarPickerView( + AvatarPickerState.Selected( + avatarData = AvatarData("!room:example.com", "Room", null, size = AvatarSize.EditRoomDetails), + type = AvatarType.Room() + ), + onClick = {}, + modifier = Modifier.padding(10.dp) + ) + } + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text("Local") + AvatarPickerView( + AvatarPickerState.Selected( + avatarData = AvatarData("!room:example.com", "Room", "content://test", size = AvatarSize.EditRoomDetails), + type = AvatarType.Room() + ), + onClick = {}, + modifier = Modifier.padding(10.dp) + ) + } + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text("MXC") + AvatarPickerView( + AvatarPickerState.Selected( + avatarData = AvatarData("!room:example.com", "Room", "mxc://test", size = AvatarSize.EditRoomDetails), + type = AvatarType.Room() + ), + onClick = {}, + modifier = Modifier.padding(10.dp) + ) + } + } + HorizontalDivider() + + Text("Space avatar") + Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceEvenly) { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text("No url") + AvatarPickerView( + AvatarPickerState.Selected( + avatarData = AvatarData("!room:example.com", "Space", null, size = AvatarSize.EditRoomDetails), + type = AvatarType.Space() + ), + onClick = {}, + modifier = Modifier.padding(10.dp) + ) + } + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text("Local") + AvatarPickerView( + AvatarPickerState.Selected( + avatarData = AvatarData("!room:example.com", "Space", "content://test", size = AvatarSize.EditRoomDetails), + type = AvatarType.Space() + ), + onClick = {}, + modifier = Modifier.padding(10.dp) + ) + } + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text("MXC") + AvatarPickerView( + AvatarPickerState.Selected( + avatarData = AvatarData("!room:example.com", "Space", "mxc://test", size = AvatarSize.EditRoomDetails), + type = AvatarType.Space() + ), + onClick = {}, + modifier = Modifier.padding(10.dp) + ) + } + } + } +} diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/CheckableUserRow.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/CheckableUserRow.kt index 7a4ed6f9115..c0df3bb54c1 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/CheckableUserRow.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/CheckableUserRow.kt @@ -11,8 +11,9 @@ package io.element.android.libraries.matrix.ui.components import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable import androidx.compose.runtime.Immutable import androidx.compose.ui.Alignment @@ -21,10 +22,10 @@ import androidx.compose.ui.semantics.Role import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import fr.gouv.tchap.libraries.tchaputils.TchapPatterns.isExternalTchapUser -import io.element.android.libraries.designsystem.atomic.atoms.SelectedIndicatorAtom 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.ElementThemedPreview +import io.element.android.libraries.designsystem.theme.components.Checkbox import io.element.android.libraries.designsystem.theme.components.HorizontalDivider import io.element.android.libraries.matrix.ui.model.getAvatarData @@ -65,11 +66,12 @@ fun CheckableUserRow( ) } } - SelectedIndicatorAtom( - modifier = Modifier.padding(end = 24.dp), + Checkbox( + onCheckedChange = onCheckedChange, checked = checked, enabled = enabled, ) + Spacer(modifier = Modifier.width(4.dp)) } } diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/EditableAvatarView.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/EditableAvatarView.kt deleted file mode 100644 index 889c9775520..00000000000 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/EditableAvatarView.kt +++ /dev/null @@ -1,137 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.libraries.matrix.ui.components - -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.ripple -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.res.stringResource -import androidx.compose.ui.semantics.clearAndSetSemantics -import androidx.compose.ui.semantics.contentDescription -import androidx.compose.ui.tooling.preview.PreviewParameter -import androidx.compose.ui.tooling.preview.PreviewParameterProvider -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.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 -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.utils.CommonDrawables -import io.element.android.libraries.testtags.TestTags -import io.element.android.libraries.testtags.testTag -import io.element.android.libraries.ui.strings.CommonStrings - -@Composable -fun EditableAvatarView( - matrixId: String, - displayName: String?, - avatarUrl: String?, - avatarSize: AvatarSize, - avatarType: AvatarType, - onAvatarClick: () -> Unit, - modifier: Modifier = Modifier, -) { - Column( - modifier = modifier.fillMaxWidth(), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - val a11yAvatar = stringResource(CommonStrings.a11y_avatar) - Box( - modifier = Modifier - .clickable( - interactionSource = remember { MutableInteractionSource() }, - onClickLabel = stringResource(CommonStrings.a11y_edit_avatar), - onClick = onAvatarClick, - indication = ripple(bounded = false), - ) - .testTag(TestTags.editAvatar) - .clearAndSetSemantics { - contentDescription = a11yAvatar - }, - ) { - when { - avatarUrl == null || avatarUrl.startsWith("mxc://") -> { - Avatar( - avatarData = AvatarData( - id = matrixId, - name = displayName, - url = avatarUrl, - size = avatarSize, - ), - avatarType = avatarType, - ) - } - else -> { - UnsavedAvatar( - avatarUri = avatarUrl, - avatarSize = avatarSize, - avatarType = avatarType, - ) - } - } - - Box( - modifier = Modifier - .align(Alignment.BottomEnd) - .clip(CircleShape) - .background(ElementTheme.colors.iconPrimary) - .size(24.dp), - contentAlignment = Alignment.Center, - ) { - Icon( - modifier = Modifier.size(16.dp), - imageVector = CompoundIcons.EditSolid(), - contentDescription = null, - tint = ElementTheme.colors.iconOnSolidPrimary, - ) - } - } - } -} - -@PreviewsDayNight -@Composable -internal fun EditableAvatarViewPreview( - @PreviewParameter(EditableAvatarViewUriProvider::class) uri: String? -) = ElementPreview( - drawableFallbackForImages = CommonDrawables.sample_avatar, -) { - EditableAvatarView( - matrixId = "id", - displayName = "A room", - avatarUrl = uri, - avatarSize = AvatarSize.EditRoomDetails, - avatarType = AvatarType.User, - onAvatarClick = {}, - ) -} - -open class EditableAvatarViewUriProvider : PreviewParameterProvider { - override val values: Sequence - get() = sequenceOf( - null, - "mxc://matrix.org/123456", - "https://example.com/avatar.jpg", - ) -} 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 53bd843346c..80b73a9ff48 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 @@ -164,6 +164,8 @@ fun SelectedItem( imageVector = CompoundIcons.Close(), // Note: keep the context description for the test contentDescription = stringResource(id = CommonStrings.action_remove), + // TCHAP theme : color used when background is blue Tchap +// tint = ElementTheme.colors.iconOnSolidPrimary, tint = ElementTheme.iconOnSolidBlueTchap, modifier = Modifier.padding(2.dp) ) diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/UnresolvedUserRow.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/UnresolvedUserRow.kt index 190f87a47fb..c16d5052241 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/UnresolvedUserRow.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/UnresolvedUserRow.kt @@ -12,7 +12,6 @@ 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.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.runtime.Composable @@ -46,8 +45,7 @@ fun UnresolvedUserRow( Row( modifier = modifier .fillMaxWidth() - .heightIn(min = 56.dp) - .padding(start = 16.dp, top = 8.dp, end = 16.dp, bottom = 8.dp), + .padding(start = 16.dp, top = 12.dp, end = 16.dp, bottom = 12.dp), verticalAlignment = Alignment.CenterVertically ) { Avatar( diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/UnsavedAvatar.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/UnsavedAvatar.kt deleted file mode 100644 index 104a418360c..00000000000 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/UnsavedAvatar.kt +++ /dev/null @@ -1,93 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.libraries.matrix.ui.components - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.outlined.AddAPhoto -import androidx.compose.material3.MaterialTheme -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.painter.ColorPainter -import androidx.compose.ui.layout.ContentScale -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.unit.dp -import coil3.compose.AsyncImage -import coil3.request.ImageRequest -import io.element.android.compound.theme.ElementTheme -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.avatar.avatarShape -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.temporaryColorBgSpecial - -/** - * An avatar that the user has selected, but which has not yet been uploaded to Matrix. - * - * The image is loaded from a local resource instead of from a MXC URI. - */ -@Composable -fun UnsavedAvatar( - avatarUri: String?, - avatarSize: AvatarSize, - avatarType: AvatarType, - modifier: Modifier = Modifier, -) { - val commonModifier = modifier - .size(avatarSize.dp) - .clip(avatarType.avatarShape(avatarSize.dp)) - - if (avatarUri != null) { - val context = LocalContext.current - val model = ImageRequest.Builder(context) - .data(avatarUri) - .build() - AsyncImage( - modifier = commonModifier, - model = model, - placeholder = ColorPainter(MaterialTheme.colorScheme.surfaceVariant), - contentScale = ContentScale.Crop, - contentDescription = null, - ) - } else { - Box(modifier = commonModifier.background(ElementTheme.colors.temporaryColorBgSpecial)) { - Icon( - imageVector = Icons.Outlined.AddAPhoto, - contentDescription = null, - modifier = Modifier - .align(Alignment.Center) - .size(avatarSize.dp * 4 / 7), - tint = ElementTheme.colors.iconSecondary, - ) - } - } -} - -@PreviewsDayNight -@Composable -internal fun UnsavedAvatarPreview() = ElementPreview { - Row( - modifier = Modifier.padding(8.dp), - horizontalArrangement = Arrangement.spacedBy(8.dp), - ) { - UnsavedAvatar(null, AvatarSize.EditRoomDetails, AvatarType.User) - UnsavedAvatar("", AvatarSize.EditRoomDetails, AvatarType.User) - UnsavedAvatar(null, AvatarSize.EditRoomDetails, AvatarType.Space()) - UnsavedAvatar("", AvatarSize.EditRoomDetails, AvatarType.Space()) - } -} diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/UserRow.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/UserRow.kt index 51a5ad60d2a..5d27cb11458 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/UserRow.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/UserRow.kt @@ -11,7 +11,6 @@ package io.element.android.libraries.matrix.ui.components import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -42,8 +41,7 @@ internal fun UserRow( Row( modifier = modifier .fillMaxWidth() - .heightIn(min = 56.dp) - .padding(start = 16.dp, top = 4.dp, end = 16.dp, bottom = 4.dp), + .padding(start = 16.dp, top = 12.dp, end = 16.dp, bottom = 12.dp), verticalAlignment = Alignment.CenterVertically ) { Avatar( diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/ToHtmlDocument.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/ToHtmlDocument.kt index 5a2297ec1fd..ee9de516817 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/ToHtmlDocument.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/ToHtmlDocument.kt @@ -12,7 +12,7 @@ import io.element.android.libraries.matrix.api.permalink.PermalinkData import io.element.android.libraries.matrix.api.permalink.PermalinkParser import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody import io.element.android.libraries.matrix.api.timeline.item.event.MessageFormat -import org.jsoup.Jsoup +import io.element.android.wysiwyg.utils.HtmlToDomParser import org.jsoup.nodes.Document /** @@ -34,9 +34,9 @@ fun FormattedBody.toHtmlDocument( ?.trimEnd() ?.let { formattedBody -> val dom = if (prefix != null) { - Jsoup.parse("$prefix $formattedBody") + HtmlToDomParser.document("$prefix $formattedBody") } else { - Jsoup.parse(formattedBody) + HtmlToDomParser.document(formattedBody) } // Prepend `@` to mentions diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/ToPlainText.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/ToPlainText.kt index 2fc371c71b4..d58d12b7858 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/ToPlainText.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/ToPlainText.kt @@ -55,8 +55,15 @@ private class PlainTextNodeVisitor : NodeVisitor { private val builder = StringBuilder() override fun head(node: Node, depth: Int) { - if (node is TextNode && node.text().isNotBlank()) { - builder.append(node.text()) + if (node is TextNode) { + // If the text node is blank, only add a single whitespace char if there wasn't already one + if (node.text().isBlank()) { + if (builder.lastOrNull()?.isWhitespace() == false) { + builder.append(" ") + } + } else { + builder.append(node.text()) + } } else if (node is Element && node.tagName() == "li") { val index = node.elementSiblingIndex() + 1 val isOrdered = node.parent()?.nodeName()?.lowercase() == "ol" diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToDetailsProvider.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToDetailsProvider.kt index ac545ed8ea1..0727b0b7ec7 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToDetailsProvider.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToDetailsProvider.kt @@ -89,6 +89,7 @@ open class InReplyToDetailsProvider : PreviewParameterProvider votes = persistentMapOf(), endTime = null, isEdited = false, + threadInfo = null, ), ).map { aInReplyToDetails( @@ -116,7 +117,7 @@ class InReplyToDetailsInformativeProvider : InReplyToDetailsProvider() { override val values: Sequence get() = sequenceOf( RedactedContent, - UnableToDecryptContent(UnableToDecryptContent.Data.Unknown), + UnableToDecryptContent(data = UnableToDecryptContent.Data.Unknown, threadInfo = null), ).map { aInReplyToDetails( eventContent = it, diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/model/InviteSender.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/model/InviteSender.kt index fa4a51aac8e..9dd9e0b827b 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/model/InviteSender.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/model/InviteSender.kt @@ -9,7 +9,6 @@ package io.element.android.libraries.matrix.ui.model import androidx.compose.runtime.Composable -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle @@ -31,7 +30,7 @@ data class InviteSender( fun annotatedString(): AnnotatedString { // TCHAP TODO should be changed to hide the user id return stringResource(R.string.screen_invites_invited_you, displayName, userId.value).let { text -> - val senderNameStart = LocalContext.current.getString(R.string.screen_invites_invited_you).indexOf("%1\$s") + val senderNameStart = stringResource(R.string.screen_invites_invited_you).indexOf($$"%1$s") AnnotatedString( text = text, spanStyles = listOf( diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/model/RoomInfoExtension.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/model/RoomInfoExtension.kt index f9a86c9bd71..3a03d8329b8 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/model/RoomInfoExtension.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/model/RoomInfoExtension.kt @@ -21,6 +21,20 @@ fun RoomInfo.getAvatarData(size: AvatarSize) = AvatarData( size = size, ) +/** + * Returns the power level of the user in the room. + * If the user is a creator and [RoomInfo.privilegedCreatorRole] is true, returns the power level of [RoomMember.Role.Owner]. + * Otherwise, checks the room's power levels for the user's power level. + * If no specific power level is set for the user, defaults to 0. + */ +fun RoomInfo.powerLevelOf(userId: UserId): Long { + return if (privilegedCreatorRole && creators.contains(userId)) { + RoomMember.Role.Owner(isCreator = true).powerLevel + } else { + roomPowerLevels?.powerLevelOf(userId = userId) ?: 0L + } +} + /** * Returns the role of the user in the room. * If the user is a creator and [RoomInfo.privilegedCreatorRole] is true, returns [RoomMember.Role.Owner]. @@ -28,9 +42,6 @@ fun RoomInfo.getAvatarData(size: AvatarSize) = AvatarData( * If no specific power level is set for the user, defaults to [RoomMember.Role.User]. */ fun RoomInfo.roleOf(userId: UserId): RoomMember.Role { - return if (privilegedCreatorRole && creators.contains(userId)) { - RoomMember.Role.Owner(isCreator = true) - } else { - roomPowerLevels?.roleOf(userId) ?: RoomMember.Role.User - } + val powerLevel = powerLevelOf(userId = userId) + return RoomMember.Role.forPowerLevel(powerLevel) } diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/room/MatrixRoomState.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/room/MatrixRoomState.kt deleted file mode 100644 index e9a33282c0b..00000000000 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/room/MatrixRoomState.kt +++ /dev/null @@ -1,131 +0,0 @@ -/* - * Copyright (c) 2025 Element Creations Ltd. - * Copyright 2023-2025 New Vector Ltd. - * - * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial. - * Please see LICENSE files in the repository root for full details. - */ - -package io.element.android.libraries.matrix.ui.room - -import androidx.compose.runtime.Composable -import androidx.compose.runtime.State -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.produceState -import io.element.android.libraries.matrix.api.room.BaseRoom -import io.element.android.libraries.matrix.api.room.MessageEventType -import io.element.android.libraries.matrix.api.room.RoomMember -import io.element.android.libraries.matrix.api.room.isDm -import io.element.android.libraries.matrix.api.room.powerlevels.canBan -import io.element.android.libraries.matrix.api.room.powerlevels.canHandleKnockRequests -import io.element.android.libraries.matrix.api.room.powerlevels.canInvite -import io.element.android.libraries.matrix.api.room.powerlevels.canKick -import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOther -import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOwn -import io.element.android.libraries.matrix.api.room.powerlevels.canSendMessage -import io.element.android.libraries.matrix.ui.model.roleOf - -@Composable -fun BaseRoom.canSendMessageAsState(type: MessageEventType, updateKey: Long): State { - return produceState(initialValue = true, key1 = updateKey) { - value = canSendMessage(type).getOrElse { true } - } -} - -@Composable -fun BaseRoom.canInviteAsState(updateKey: Long): State { - return produceState(initialValue = false, key1 = updateKey) { - value = canInvite().getOrElse { false } - } -} - -@Composable -fun BaseRoom.canRedactOwnAsState(updateKey: Long): State { - return produceState(initialValue = false, key1 = updateKey) { - value = canRedactOwn().getOrElse { false } - } -} - -@Composable -fun BaseRoom.canRedactOtherAsState(updateKey: Long): State { - return produceState(initialValue = false, key1 = updateKey) { - value = canRedactOther().getOrElse { false } - } -} - -@Composable -fun BaseRoom.canCall(updateKey: Long): State { - return produceState(initialValue = false, key1 = updateKey) { - value = canUserJoinCall(sessionId).getOrElse { false } - } -} - -@Composable -fun BaseRoom.canPinUnpin(updateKey: Long): State { - return produceState(initialValue = false, key1 = updateKey) { - value = canUserPinUnpin(sessionId).getOrElse { false } - } -} - -@Composable -fun BaseRoom.isDmAsState(): State { - return produceState(initialValue = false) { - roomInfoFlow.collect { value = it.isDm } - } -} - -@Composable -fun BaseRoom.canKickAsState(updateKey: Long): State { - return produceState(initialValue = false, key1 = updateKey) { - value = canKick().getOrElse { false } - } -} - -@Composable -fun BaseRoom.canBanAsState(updateKey: Long): State { - return produceState(initialValue = false, key1 = updateKey) { - value = canBan().getOrElse { false } - } -} - -@Composable -fun BaseRoom.canHandleKnockRequestsAsState(updateKey: Long): State { - return produceState(initialValue = false, key1 = updateKey) { - value = canHandleKnockRequests().getOrElse { false } - } -} - -@Composable -fun BaseRoom.userPowerLevelAsState(updateKey: Long): State { - return produceState(initialValue = 0, key1 = updateKey) { - value = userRole(sessionId) - .getOrDefault(RoomMember.Role.User) - .powerLevel - } -} - -@Composable -fun BaseRoom.isOwnUserAdmin(): Boolean { - val roomInfo by roomInfoFlow.collectAsState() - val role = roomInfo.roleOf(sessionId) - return role == RoomMember.Role.Admin || role is RoomMember.Role.Owner -} - -@Composable -fun BaseRoom.rawName(): String? { - val roomInfo by roomInfoFlow.collectAsState() - return roomInfo.rawName -} - -@Composable -fun BaseRoom.topic(): String? { - val roomInfo by roomInfoFlow.collectAsState() - return roomInfo.topic -} - -@Composable -fun BaseRoom.avatarUrl(): String? { - val roomInfo by roomInfoFlow.collectAsState() - return roomInfo.avatarUrl -} diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/room/address/RoomAddressField.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/room/address/RoomAddressField.kt index 4166f37cd7b..ec494c28885 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/room/address/RoomAddressField.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/room/address/RoomAddressField.kt @@ -27,7 +27,7 @@ fun RoomAddressField( homeserverName: String, addressValidity: RoomAddressValidity, onAddressChange: (String) -> Unit, - label: String, + label: String?, supportingText: String, modifier: Modifier = Modifier, ) { diff --git a/libraries/matrixui/src/main/res/values-hr/translations.xml b/libraries/matrixui/src/main/res/values-hr/translations.xml new file mode 100644 index 00000000000..333d8fd7b77 --- /dev/null +++ b/libraries/matrixui/src/main/res/values-hr/translations.xml @@ -0,0 +1,7 @@ + + + "Pošalji pozivnicu" + "Želite li započeti razgovor s korisnikom %1$s?" + "Želite li poslati pozivnicu?" + "Pozvao vas je korisnik %1$s (%2$s)" + diff --git a/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/messages/ToPlainTextTest.kt b/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/messages/ToPlainTextTest.kt index 5345113dc2d..607f8254014 100644 --- a/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/messages/ToPlainTextTest.kt +++ b/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/messages/ToPlainTextTest.kt @@ -45,7 +45,7 @@ class ToPlainTextTest { val formattedBody = FormattedBody( format = MessageFormat.HTML, body = """ - Hello world + Hello formatted world
  • This is an unordered list.
  1. This is an ordered list.

@@ -53,7 +53,7 @@ class ToPlainTextTest { ) assertThat(formattedBody.toPlainText(permalinkParser = FakePermalinkParser())).isEqualTo( """ - Hello world + Hello formatted world • This is an unordered list. 1. This is an ordered list. """.trimIndent() diff --git a/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToMetadataKtTest.kt b/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToMetadataKtTest.kt index e7cae1151b4..004e622211d 100644 --- a/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToMetadataKtTest.kt +++ b/libraries/matrixui/src/test/kotlin/io/element/android/libraries/matrix/ui/messages/reply/InReplyToMetadataKtTest.kt @@ -134,7 +134,8 @@ class InReplyToMetadataKtTest { filename = "filename", body = "body", info = anImageInfo(), - source = aMediaSource(url = "url") + source = aMediaSource(url = "url"), + threadInfo = null, ) ).metadata(hideImage = false) }.test { @@ -161,7 +162,8 @@ class InReplyToMetadataKtTest { filename = "filename", body = "body", info = anImageInfo(), - source = aMediaSource(url = "url") + source = aMediaSource(url = "url"), + threadInfo = null, ) ).metadata(hideImage = true) }.test { @@ -445,7 +447,10 @@ class InReplyToMetadataKtTest { fun `unable to decrypt content`() = runTest { moleculeFlow(RecompositionMode.Immediate) { anInReplyToDetailsReady( - eventContent = UnableToDecryptContent(UnableToDecryptContent.Data.Unknown) + eventContent = UnableToDecryptContent( + data = UnableToDecryptContent.Data.Unknown, + threadInfo = null, + ), ).metadata(hideImage = false) }.test { awaitItem().let { diff --git a/libraries/mediaplayer/api/src/main/kotlin/io/element/android/libraries/mediaplayer/api/MediaPlayer.kt b/libraries/mediaplayer/api/src/main/kotlin/io/element/android/libraries/mediaplayer/api/MediaPlayer.kt index bfbd2bb35e5..7087235fecb 100644 --- a/libraries/mediaplayer/api/src/main/kotlin/io/element/android/libraries/mediaplayer/api/MediaPlayer.kt +++ b/libraries/mediaplayer/api/src/main/kotlin/io/element/android/libraries/mediaplayer/api/MediaPlayer.kt @@ -47,6 +47,12 @@ interface MediaPlayer : AutoCloseable { */ fun seekTo(positionMs: Long) + /** + * Sets the playback speed. + * @param speed The playback speed (e.g., 0.5f for half speed, 1.0f for normal, 2.0f for double speed) + */ + fun setPlaybackSpeed(speed: Float) + /** * Releases any resources associated with this player. */ diff --git a/libraries/mediaplayer/impl/src/main/kotlin/io/element/android/libraries/mediaplayer/impl/DefaultMediaPlayer.kt b/libraries/mediaplayer/impl/src/main/kotlin/io/element/android/libraries/mediaplayer/impl/DefaultMediaPlayer.kt index 098e7e4d3a7..12b36207a6a 100644 --- a/libraries/mediaplayer/impl/src/main/kotlin/io/element/android/libraries/mediaplayer/impl/DefaultMediaPlayer.kt +++ b/libraries/mediaplayer/impl/src/main/kotlin/io/element/android/libraries/mediaplayer/impl/DefaultMediaPlayer.kt @@ -159,6 +159,10 @@ class DefaultMediaPlayer( } } + override fun setPlaybackSpeed(speed: Float) { + player.setPlaybackSpeed(speed) + } + override fun close() { player.release() } diff --git a/libraries/mediaplayer/impl/src/main/kotlin/io/element/android/libraries/mediaplayer/impl/SimplePlayer.kt b/libraries/mediaplayer/impl/src/main/kotlin/io/element/android/libraries/mediaplayer/impl/SimplePlayer.kt index f0b8746042f..17bfe682e32 100644 --- a/libraries/mediaplayer/impl/src/main/kotlin/io/element/android/libraries/mediaplayer/impl/SimplePlayer.kt +++ b/libraries/mediaplayer/impl/src/main/kotlin/io/element/android/libraries/mediaplayer/impl/SimplePlayer.kt @@ -34,6 +34,7 @@ interface SimplePlayer { fun isPlaying(): Boolean fun pause() fun seekTo(positionMs: Long) + fun setPlaybackSpeed(speed: Float) fun release() interface Listener { fun onIsPlayingChanged(isPlaying: Boolean) @@ -88,5 +89,9 @@ class DefaultSimplePlayer( override fun seekTo(positionMs: Long) = p.seekTo(positionMs) + override fun setPlaybackSpeed(speed: Float) { + p.setPlaybackParameters(p.playbackParameters.withSpeed(speed)) + } + override fun release() = p.release() } diff --git a/libraries/mediaplayer/impl/src/test/kotlin/io/element/android/libraries/mediaplayer/impl/FakeSimplePlayer.kt b/libraries/mediaplayer/impl/src/test/kotlin/io/element/android/libraries/mediaplayer/impl/FakeSimplePlayer.kt index 609862c3cf4..c1e2dc9763b 100644 --- a/libraries/mediaplayer/impl/src/test/kotlin/io/element/android/libraries/mediaplayer/impl/FakeSimplePlayer.kt +++ b/libraries/mediaplayer/impl/src/test/kotlin/io/element/android/libraries/mediaplayer/impl/FakeSimplePlayer.kt @@ -20,6 +20,7 @@ class FakeSimplePlayer( private val isPlayingLambda: () -> Boolean = { lambdaError() }, private val pauseLambda: () -> Unit = { lambdaError() }, private val seekToLambda: (Long) -> Unit = { lambdaError() }, + private val setPlaybackSpeedLambda: (Float) -> Unit = { lambdaError() }, private val releaseLambda: () -> Unit = { lambdaError() }, ) : SimplePlayer { private val listeners = mutableListOf() @@ -45,6 +46,7 @@ class FakeSimplePlayer( override fun isPlaying() = isPlayingLambda() override fun pause() = pauseLambda() override fun seekTo(positionMs: Long) = seekToLambda(positionMs) + override fun setPlaybackSpeed(speed: Float) = setPlaybackSpeedLambda(speed) override fun release() = releaseLambda() fun simulateIsPlayingChanged(isPlaying: Boolean) { diff --git a/libraries/mediaplayer/test/src/main/kotlin/io/element/android/libraries/mediaplayer/test/FakeMediaPlayer.kt b/libraries/mediaplayer/test/src/main/kotlin/io/element/android/libraries/mediaplayer/test/FakeMediaPlayer.kt index 54fed2be11d..1d1fd07ebdb 100644 --- a/libraries/mediaplayer/test/src/main/kotlin/io/element/android/libraries/mediaplayer/test/FakeMediaPlayer.kt +++ b/libraries/mediaplayer/test/src/main/kotlin/io/element/android/libraries/mediaplayer/test/FakeMediaPlayer.kt @@ -96,6 +96,10 @@ class FakeMediaPlayer( } } + override fun setPlaybackSpeed(speed: Float) { + // no-op + } + override fun close() { // no-op } diff --git a/libraries/mediaupload/api/build.gradle.kts b/libraries/mediaupload/api/build.gradle.kts index 1f2d844a9a2..a9f876c67ff 100644 --- a/libraries/mediaupload/api/build.gradle.kts +++ b/libraries/mediaupload/api/build.gradle.kts @@ -1,5 +1,3 @@ -import extension.testCommonDependencies - /* * Copyright (c) 2025 Element Creations Ltd. * Copyright 2023, 2024 New Vector Ltd. diff --git a/libraries/mediaupload/impl/build.gradle.kts b/libraries/mediaupload/impl/build.gradle.kts index dd73164eb17..827366f556e 100644 --- a/libraries/mediaupload/impl/build.gradle.kts +++ b/libraries/mediaupload/impl/build.gradle.kts @@ -31,6 +31,7 @@ dependencies { implementation(projects.libraries.androidutils) implementation(projects.libraries.core) implementation(projects.libraries.di) + implementation(projects.libraries.featureflag.api) implementation(projects.libraries.matrix.api) implementation(projects.services.toolbox.api) implementation(libs.androidx.exifinterface) diff --git a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/DefaultMediaOptimizationConfigProvider.kt b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/DefaultMediaOptimizationConfigProvider.kt index 4b6d2986668..363263e5bf7 100644 --- a/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/DefaultMediaOptimizationConfigProvider.kt +++ b/libraries/mediaupload/impl/src/main/kotlin/io/element/android/libraries/mediaupload/impl/DefaultMediaOptimizationConfigProvider.kt @@ -10,17 +10,28 @@ package io.element.android.libraries.mediaupload.impl import dev.zacsweers.metro.ContributesBinding import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.featureflag.api.FeatureFlagService +import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.mediaupload.api.MediaOptimizationConfig import io.element.android.libraries.mediaupload.api.MediaOptimizationConfigProvider import io.element.android.libraries.preferences.api.store.SessionPreferencesStore +import io.element.android.libraries.preferences.api.store.VideoCompressionPreset import kotlinx.coroutines.flow.first @ContributesBinding(SessionScope::class) class DefaultMediaOptimizationConfigProvider( private val sessionPreferencesStore: SessionPreferencesStore, + private val featureFlagsService: FeatureFlagService, ) : MediaOptimizationConfigProvider { - override suspend fun get(): MediaOptimizationConfig = MediaOptimizationConfig( - compressImages = sessionPreferencesStore.doesOptimizeImages().first(), - videoCompressionPreset = sessionPreferencesStore.getVideoCompressionPreset().first(), - ) + override suspend fun get(): MediaOptimizationConfig { + val compressImages = sessionPreferencesStore.doesOptimizeImages().first() + return MediaOptimizationConfig( + compressImages = compressImages, + videoCompressionPreset = if (featureFlagsService.isFeatureEnabled(FeatureFlags.SelectableMediaQuality)) { + sessionPreferencesStore.getVideoCompressionPreset().first() + } else { + if (compressImages) VideoCompressionPreset.STANDARD else VideoCompressionPreset.HIGH + }, + ) + } } diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenter.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenter.kt index aee9af82b7d..cc26e69c33a 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenter.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenter.kt @@ -30,8 +30,7 @@ import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarM import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.media.MatrixMediaLoader import io.element.android.libraries.matrix.api.room.BaseRoom -import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOther -import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOwn +import io.element.android.libraries.matrix.api.room.powerlevels.permissionsAsState import io.element.android.libraries.mediaviewer.api.local.LocalMedia import io.element.android.libraries.mediaviewer.api.local.LocalMediaFactory import io.element.android.libraries.mediaviewer.impl.datasource.MediaGalleryDataSource @@ -39,8 +38,10 @@ import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetSta import io.element.android.libraries.mediaviewer.impl.local.LocalMediaActions import io.element.android.libraries.mediaviewer.impl.model.GroupedMediaItems import io.element.android.libraries.mediaviewer.impl.model.MediaItem +import io.element.android.libraries.mediaviewer.impl.model.MediaPermissions import io.element.android.libraries.mediaviewer.impl.model.eventId import io.element.android.libraries.mediaviewer.impl.model.mediaInfo +import io.element.android.libraries.mediaviewer.impl.model.mediaPermissions import io.element.android.libraries.mediaviewer.impl.model.mediaSource import io.element.android.libraries.ui.strings.CommonStrings import kotlinx.coroutines.launch @@ -80,6 +81,10 @@ class MediaGalleryPresenter( mediaGalleryDataSource.start() } + val permissions by room.permissionsAsState(MediaPermissions.DEFAULT) { perms -> + perms.mediaPermissions() + } + val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState() localMediaActions.Configure() @@ -119,8 +124,8 @@ class MediaGalleryPresenter( eventId = event.mediaItem.eventId(), canDelete = when (event.mediaItem.mediaInfo().senderId) { null -> false - room.sessionId -> room.canRedactOwn().getOrElse { false } && event.mediaItem.eventId() != null - else -> room.canRedactOther().getOrElse { false } && event.mediaItem.eventId() != null + room.sessionId -> permissions.canRedactOwn && event.mediaItem.eventId() != null + else -> permissions.canRedactOther && event.mediaItem.eventId() != null }, mediaInfo = event.mediaItem.mediaInfo(), thumbnailSource = when (event.mediaItem) { diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/VoiceItemView.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/VoiceItemView.kt index 9e62451dbae..dbbeb28b80d 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/VoiceItemView.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/ui/VoiceItemView.kt @@ -11,6 +11,7 @@ package io.element.android.libraries.mediaviewer.impl.gallery.ui import androidx.compose.foundation.background import androidx.compose.foundation.border import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer @@ -38,6 +39,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.libraries.designsystem.atomic.atoms.PlaybackSpeedButton import io.element.android.libraries.designsystem.components.media.WaveformPlaybackView import io.element.android.libraries.designsystem.modifiers.onKeyboardContextMenuAction import io.element.android.libraries.designsystem.preview.ElementPreview @@ -50,7 +52,7 @@ import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.mediaviewer.impl.model.MediaItem import io.element.android.libraries.mediaviewer.impl.model.aMediaItemVoice import io.element.android.libraries.ui.strings.CommonStrings -import io.element.android.libraries.voiceplayer.api.VoiceMessageEvents +import io.element.android.libraries.voiceplayer.api.VoiceMessageEvent import io.element.android.libraries.voiceplayer.api.VoiceMessageState import io.element.android.libraries.voiceplayer.api.VoiceMessageStateProvider import io.element.android.libraries.voiceplayer.api.aVoiceMessageState @@ -92,7 +94,7 @@ private fun VoiceInfoRow( onLongClick: () -> Unit, ) { fun playPause() { - state.eventSink(VoiceMessageEvents.PlayPause) + state.eventSink(VoiceMessageEvent.PlayPause) } Row( @@ -112,21 +114,30 @@ private fun VoiceInfoRow( .padding(start = 12.dp, end = 36.dp, top = 8.dp, bottom = 8.dp), verticalAlignment = Alignment.CenterVertically, ) { - when (state.button) { - VoiceMessageState.Button.Play -> PlayButton(onClick = ::playPause) - VoiceMessageState.Button.Pause -> PauseButton(onClick = ::playPause) - VoiceMessageState.Button.Downloading -> ProgressButton() - VoiceMessageState.Button.Retry -> RetryButton(onClick = ::playPause) - VoiceMessageState.Button.Disabled -> PlayButton(onClick = {}, enabled = false) + when (state.buttonType) { + VoiceMessageState.ButtonType.Play -> PlayButton(onClick = ::playPause) + VoiceMessageState.ButtonType.Pause -> PauseButton(onClick = ::playPause) + VoiceMessageState.ButtonType.Downloading -> ProgressButton() + VoiceMessageState.ButtonType.Retry -> RetryButton(onClick = ::playPause) + VoiceMessageState.ButtonType.Disabled -> PlayButton(onClick = {}, enabled = false) } Spacer(Modifier.width(8.dp)) - Text( - text = if (state.progress > 0f) state.time else voice.mediaInfo.duration ?: state.time, - color = ElementTheme.colors.textSecondary, - style = ElementTheme.typography.fontBodyMdMedium, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(2.dp), + ) { + PlaybackSpeedButton( + speed = state.playbackSpeed, + onClick = { state.eventSink(VoiceMessageEvent.ChangePlaybackSpeed) }, + ) + Text( + text = if (state.progress > 0f) state.time else voice.mediaInfo.duration ?: state.time, + color = ElementTheme.colors.textSecondary, + style = ElementTheme.typography.fontBodyMdMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } Spacer(modifier = Modifier.width(8.dp)) WaveformPlaybackView( modifier = Modifier @@ -136,7 +147,7 @@ private fun VoiceInfoRow( playbackProgress = state.progress, waveform = voice.mediaInfo.waveform.orEmpty().toImmutableList(), onSeek = { - state.eventSink(VoiceMessageEvents.Seek(it)) + state.eventSink(VoiceMessageEvent.Seek(it)) }, seekEnabled = true, ) diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/player/ExoPlayerForPreview.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/player/ExoPlayerForPreview.kt index 4cb05ee6d15..51a44d49fb1 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/player/ExoPlayerForPreview.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/local/player/ExoPlayerForPreview.kt @@ -38,6 +38,8 @@ import androidx.media3.common.VideoSize import androidx.media3.common.text.CueGroup import androidx.media3.common.util.Clock import androidx.media3.common.util.Size +import androidx.media3.exoplayer.CodecParameters +import androidx.media3.exoplayer.CodecParametersChangeListener import androidx.media3.exoplayer.DecoderCounters import androidx.media3.exoplayer.ExoPlaybackException import androidx.media3.exoplayer.ExoPlayer @@ -160,6 +162,8 @@ class ExoPlayerForPreview( override fun getAudioAttributes(): AudioAttributes = throw NotImplementedError() override fun setVolume(volume: Float) = throw NotImplementedError() override fun getVolume(): Float = throw NotImplementedError() + override fun mute() {} + override fun unmute() {} override fun clearVideoSurface() {} override fun clearVideoSurface(surface: Surface?) {} override fun setVideoSurface(surface: Surface?) {} @@ -192,6 +196,7 @@ class ExoPlayerForPreview( override fun getRendererCount(): Int = throw NotImplementedError() override fun getRendererType(index: Int): Int = throw NotImplementedError() override fun getRenderer(index: Int): Renderer = throw NotImplementedError() + override fun getSecondaryRenderer(index: Int): Renderer? = throw NotImplementedError() override fun getTrackSelector(): TrackSelector? = throw NotImplementedError() override fun getCurrentTrackGroups(): TrackGroupArray = throw NotImplementedError() override fun getCurrentTrackSelections(): TrackSelectionArray = throw NotImplementedError() @@ -216,6 +221,7 @@ class ExoPlayerForPreview( override fun setAuxEffectInfo(auxEffectInfo: AuxEffectInfo) {} override fun clearAuxEffectInfo() {} override fun setPreferredAudioDevice(audioDeviceInfo: AudioDeviceInfo?) {} + override fun setVirtualDeviceId(virtualDeviceId: Int) {} override fun setSkipSilenceEnabled(skipSilenceEnabled: Boolean) {} override fun getSkipSilenceEnabled(): Boolean = throw NotImplementedError() override fun setScrubbingModeEnabled(scrubbingModeEnabled: Boolean) {} @@ -234,6 +240,9 @@ class ExoPlayerForPreview( override fun createMessage(target: PlayerMessage.Target): PlayerMessage = throw NotImplementedError() override fun setSeekParameters(seekParameters: SeekParameters?) {} override fun getSeekParameters(): SeekParameters = throw NotImplementedError() + override fun setSeekBackIncrementMs(seekBackIncrementMs: Long) {} + override fun setSeekForwardIncrementMs(seekForwardIncrementMs: Long) {} + override fun setMaxSeekToPreviousPositionMs(maxSeekToPreviousPositionMs: Long) {} override fun setForegroundMode(foregroundMode: Boolean) {} override fun setPauseAtEndOfMediaItems(pauseAtEndOfMediaItems: Boolean) {} override fun getPauseAtEndOfMediaItems(): Boolean = throw NotImplementedError() @@ -249,4 +258,10 @@ class ExoPlayerForPreview( override fun isTunnelingEnabled(): Boolean = throw NotImplementedError() override fun isReleased(): Boolean = throw NotImplementedError() override fun setImageOutput(imageOutput: ImageOutput?) {} + override fun setAudioCodecParameters(codecParameters: CodecParameters) {} + override fun addAudioCodecParametersChangeListener(listener: CodecParametersChangeListener, keys: List) {} + override fun removeAudioCodecParametersChangeListener(listener: CodecParametersChangeListener) {} + override fun setVideoCodecParameters(codecParameters: CodecParameters) {} + override fun addVideoCodecParametersChangeListener(listener: CodecParametersChangeListener, keys: List) {} + override fun removeVideoCodecParametersChangeListener(listener: CodecParametersChangeListener) {} } diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/model/MediaPermissions.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/model/MediaPermissions.kt new file mode 100644 index 00000000000..4a111965e96 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/model/MediaPermissions.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 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.mediaviewer.impl.model + +import io.element.android.libraries.matrix.api.room.powerlevels.RoomPermissions + +data class MediaPermissions( + val canRedactOwn: Boolean, + val canRedactOther: Boolean, +) { + companion object { + val DEFAULT = MediaPermissions( + canRedactOwn = false, + canRedactOther = false, + ) + } +} + +fun RoomPermissions.mediaPermissions(): MediaPermissions { + return MediaPermissions( + canRedactOwn = canOwnUserRedactOwn(), + canRedactOther = canOwnUserRedactOther(), + ) +} diff --git a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenter.kt b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenter.kt index 4709a4ec519..dc0feb70cf0 100644 --- a/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenter.kt +++ b/libraries/mediaviewer/impl/src/main/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenter.kt @@ -32,8 +32,7 @@ import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState 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.room.powerlevels.canRedactOther -import io.element.android.libraries.matrix.api.room.powerlevels.canRedactOwn +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.event.toEventOrTransactionId import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint @@ -41,6 +40,8 @@ import io.element.android.libraries.mediaviewer.api.local.LocalMedia import io.element.android.libraries.mediaviewer.impl.R import io.element.android.libraries.mediaviewer.impl.details.MediaBottomSheetState import io.element.android.libraries.mediaviewer.impl.local.LocalMediaActions +import io.element.android.libraries.mediaviewer.impl.model.MediaPermissions +import io.element.android.libraries.mediaviewer.impl.model.mediaPermissions import io.element.android.libraries.ui.strings.CommonStrings import kotlinx.collections.immutable.ImmutableList import kotlinx.coroutines.CoroutineScope @@ -81,6 +82,9 @@ class MediaViewerPresenter( NoMoreItemsBackwardSnackBarDisplayer(currentIndex, data) NoMoreItemsForwardSnackBarDisplayer(currentIndex, data) + val permissions by room.permissionsAsState(MediaPermissions.DEFAULT) { perms -> + perms.mediaPermissions() + } var mediaBottomSheetState by remember { mutableStateOf(MediaBottomSheetState.Hidden) } DisposableEffect(Unit) { @@ -131,8 +135,8 @@ class MediaViewerPresenter( eventId = event.data.eventId, canDelete = when (event.data.mediaInfo.senderId) { null -> false - room.sessionId -> room.canRedactOwn().getOrElse { false } && event.data.eventId != null - else -> room.canRedactOther().getOrElse { false } && event.data.eventId != null + room.sessionId -> permissions.canRedactOwn && event.data.eventId != null + else -> permissions.canRedactOther && event.data.eventId != null }, mediaInfo = event.data.mediaInfo, thumbnailSource = event.data.thumbnailSource, diff --git a/libraries/mediaviewer/impl/src/main/res/values-hr/translations.xml b/libraries/mediaviewer/impl/src/main/res/values-hr/translations.xml new file mode 100644 index 00000000000..7637fbf3504 --- /dev/null +++ b/libraries/mediaviewer/impl/src/main/res/values-hr/translations.xml @@ -0,0 +1,21 @@ + + + "Ova će se datoteka ukloniti iz sobe i članovi joj neće moći pristupiti." + "Želite li izbrisati datoteku?" + "Provjerite internetsku vezu i pokušajte ponovno." + "Ovdje će se prikazati dokumenti, audiodatoteke i glasovne poruke prenesene u ovu sobu." + "Još nema prenesenih datoteka" + "Učitavanje datoteka…" + "Učitavanje medija…" + "Datoteke" + "Mediji" + "Ovdje će se prikazati slike i videozapisi preneseni u ovu sobu." + "Još nijedan medij nije prenesen" + "Mediji i datoteke" + "Oblik datoteke" + "Naziv datoteke" + "Nema više datoteka za prikaz" + "Nema više medijskih sadržaja za prikaz" + "Prenio/la" + "Preneseno na" + diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/DefaultEventItemFactoryTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/DefaultEventItemFactoryTest.kt index 68d6564d122..c70d6584187 100644 --- a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/DefaultEventItemFactoryTest.kt +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/datasource/DefaultEventItemFactoryTest.kt @@ -84,7 +84,7 @@ class DefaultEventItemFactoryTest { ), mediaSource = MediaSource("") ), - UnableToDecryptContent(UnableToDecryptContent.Data.Unknown), + UnableToDecryptContent(data = UnableToDecryptContent.Data.Unknown, threadInfo = null), UnknownContent, ) contents.forEach { @@ -397,8 +397,8 @@ class DefaultEventItemFactoryTest { height = 1L, width = 2L, blurhash = null, - ) - ) + ), + ), ) ) ) diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenterTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenterTest.kt index e890b7f0368..3069a4fd9d4 100644 --- a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenterTest.kt +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/gallery/MediaGalleryPresenterTest.kt @@ -25,6 +25,7 @@ import io.element.android.libraries.matrix.test.media.FakeMatrixMediaLoader 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.test.room.powerlevels.FakeRoomPermissions import io.element.android.libraries.matrix.test.timeline.FakeTimeline import io.element.android.libraries.mediaviewer.impl.datasource.FakeMediaGalleryDataSource import io.element.android.libraries.mediaviewer.impl.datasource.MediaGalleryDataSource @@ -109,7 +110,9 @@ class MediaGalleryPresenterTest { baseRoom = FakeBaseRoom( sessionId = A_USER_ID, initialRoomInfo = aRoomInfo(name = A_ROOM_NAME), - canRedactOwnResult = { Result.success(canDeleteOwn) } + roomPermissions = FakeRoomPermissions( + canRedactOwn = canDeleteOwn + ), ), ) ) @@ -153,7 +156,9 @@ class MediaGalleryPresenterTest { baseRoom = FakeBaseRoom( sessionId = A_USER_ID, initialRoomInfo = aRoomInfo(name = A_ROOM_NAME), - canRedactOtherResult = { Result.success(canDeleteOther) }, + roomPermissions = FakeRoomPermissions( + canRedactOther = canDeleteOther + ), ), createTimelineResult = { Result.success(FakeTimeline()) } ) @@ -355,7 +360,9 @@ class MediaGalleryPresenterTest { room = FakeJoinedRoom( createTimelineResult = { Result.success(FakeTimeline()) }, baseRoom = FakeBaseRoom( - canRedactOwnResult = { Result.success(true) }, + roomPermissions = FakeRoomPermissions( + canRedactOwn = true + ), ), ), navigator = navigator, @@ -386,7 +393,9 @@ class MediaGalleryPresenterTest { room = FakeJoinedRoom( createTimelineResult = { Result.success(FakeTimeline()) }, baseRoom = FakeBaseRoom( - canRedactOwnResult = { Result.success(true) }, + roomPermissions = FakeRoomPermissions( + canRedactOwn = true + ), ), ), navigator = navigator, diff --git a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenterTest.kt b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenterTest.kt index d9769aac13a..a9d1704bdcf 100644 --- a/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenterTest.kt +++ b/libraries/mediaviewer/impl/src/test/kotlin/io/element/android/libraries/mediaviewer/impl/viewer/MediaViewerPresenterTest.kt @@ -27,6 +27,7 @@ import io.element.android.libraries.matrix.test.media.FakeMatrixMediaLoader import io.element.android.libraries.matrix.test.media.aMediaSource 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.powerlevels.FakeRoomPermissions import io.element.android.libraries.matrix.test.timeline.FakeTimeline import io.element.android.libraries.mediaviewer.api.MediaViewerEntryPoint import io.element.android.libraries.mediaviewer.api.anApkMediaInfo @@ -83,7 +84,9 @@ class MediaViewerPresenterTest { localMediaFactory = localMediaFactory, room = FakeJoinedRoom( baseRoom = FakeBaseRoom( - canRedactOwnResult = { Result.success(true) }, + roomPermissions = FakeRoomPermissions( + canRedactOwn = true + ), ) ) ) @@ -104,7 +107,9 @@ class MediaViewerPresenterTest { canShowInfo = false, room = FakeJoinedRoom( baseRoom = FakeBaseRoom( - canRedactOwnResult = { Result.success(true) }, + roomPermissions = FakeRoomPermissions( + canRedactOwn = true + ), ) ) ) @@ -125,7 +130,9 @@ class MediaViewerPresenterTest { eventId = AN_EVENT_ID, room = FakeJoinedRoom( baseRoom = FakeBaseRoom( - canRedactOwnResult = { Result.success(true) }, + roomPermissions = FakeRoomPermissions( + canRedactOwn = true + ), ) ) ) @@ -147,7 +154,9 @@ class MediaViewerPresenterTest { room = FakeJoinedRoom( baseRoom = FakeBaseRoom( sessionId = A_SESSION_ID_2, - canRedactOtherResult = { Result.success(false) }, + roomPermissions = FakeRoomPermissions( + canRedactOther = false + ), ) ) ) @@ -236,7 +245,9 @@ class MediaViewerPresenterTest { mediaGalleryDataSource = mediaGalleryDataSource, room = FakeJoinedRoom( baseRoom = FakeBaseRoom( - canRedactOwnResult = { Result.success(true) }, + roomPermissions = FakeRoomPermissions( + canRedactOwn = true + ), ) ) ) @@ -460,7 +471,11 @@ class MediaViewerPresenterTest { localMediaFactory = localMediaFactory, room = FakeJoinedRoom( liveTimeline = timeline, - baseRoom = FakeBaseRoom(canRedactOwnResult = { Result.success(true) }), + baseRoom = FakeBaseRoom( + roomPermissions = FakeRoomPermissions( + canRedactOwn = true + ), + ), ), mediaGalleryDataSource = mediaGalleryDataSource, mediaViewerNavigator = FakeMediaViewerNavigator( @@ -769,7 +784,11 @@ class MediaViewerPresenterTest { localMediaFactory = localMediaFactory, mediaViewerNavigator = navigator, room = FakeJoinedRoom( - baseRoom = FakeBaseRoom(canRedactOwnResult = { Result.success(true) }), + baseRoom = FakeBaseRoom( + roomPermissions = FakeRoomPermissions( + canRedactOwn = true + ), + ), ) ) presenter.test { @@ -794,7 +813,11 @@ class MediaViewerPresenterTest { localMediaFactory = localMediaFactory, mediaViewerNavigator = navigator, room = FakeJoinedRoom( - baseRoom = FakeBaseRoom(canRedactOwnResult = { Result.success(true) }), + baseRoom = FakeBaseRoom( + roomPermissions = FakeRoomPermissions( + canRedactOwn = true + ), + ), ), ) presenter.test { @@ -821,7 +844,11 @@ class MediaViewerPresenterTest { localMediaFactory = localMediaFactory, mediaViewerNavigator = navigator, room = FakeJoinedRoom( - baseRoom = FakeBaseRoom(canRedactOwnResult = { Result.success(true) }), + baseRoom = FakeBaseRoom( + roomPermissions = FakeRoomPermissions( + canRedactOwn = true + ), + ), ), ) presenter.test { diff --git a/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsEvents.kt b/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsEvent.kt similarity index 71% rename from libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsEvents.kt rename to libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsEvent.kt index 2d3cb0099f9..fd01e121342 100644 --- a/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsEvents.kt +++ b/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsEvent.kt @@ -8,8 +8,8 @@ package io.element.android.libraries.permissions.api -sealed interface PermissionsEvents { - data object RequestPermissions : PermissionsEvents - data object CloseDialog : PermissionsEvents - data object OpenSystemSettingAndCloseDialog : PermissionsEvents +sealed interface PermissionsEvent { + data object RequestPermissions : PermissionsEvent + data object CloseDialog : PermissionsEvent + data object OpenSystemSettingAndCloseDialog : PermissionsEvent } diff --git a/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsState.kt b/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsState.kt index 28c34e6deec..3cbdfea50de 100644 --- a/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsState.kt +++ b/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsState.kt @@ -17,5 +17,5 @@ data class PermissionsState( val permissionAlreadyAsked: Boolean, // If true, there is no need to ask again, the system dialog will not be displayed val permissionAlreadyDenied: Boolean, - val eventSink: (PermissionsEvents) -> Unit + val eventSink: (PermissionsEvent) -> Unit ) diff --git a/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsView.kt b/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsView.kt index ef686149ee7..af0f3dd5e80 100644 --- a/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsView.kt +++ b/libraries/permissions/api/src/main/kotlin/io/element/android/libraries/permissions/api/PermissionsView.kt @@ -35,9 +35,9 @@ fun PermissionsView( content = content ?: state.permission.toDialogContent(), submitText = stringResource(id = CommonStrings.action_open_settings), onSubmitClick = { - state.eventSink.invoke(PermissionsEvents.OpenSystemSettingAndCloseDialog) + state.eventSink.invoke(PermissionsEvent.OpenSystemSettingAndCloseDialog) }, - onDismiss = { state.eventSink.invoke(PermissionsEvents.CloseDialog) }, + onDismiss = { state.eventSink.invoke(PermissionsEvent.CloseDialog) }, icon = icon, ) } diff --git a/libraries/permissions/api/src/main/res/values-hr/translations.xml b/libraries/permissions/api/src/main/res/values-hr/translations.xml new file mode 100644 index 00000000000..e300660d593 --- /dev/null +++ b/libraries/permissions/api/src/main/res/values-hr/translations.xml @@ -0,0 +1,7 @@ + + + "Kako biste aplikaciji omogućili korištenje kamere, dajte dopuštenje u postavkama sustava." + "Dajte dopuštenje u postavkama sustava." + "Kako biste aplikaciji omogućili korištenje mikrofona, dajte dopuštenje u postavkama sustava." + "Kako biste aplikaciji omogućili prikaz obavijesti, dajte dopuštenje u postavkama sustava." + diff --git a/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionsPresenter.kt b/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionsPresenter.kt index 76e288013b7..42331e001a4 100644 --- a/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionsPresenter.kt +++ b/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionsPresenter.kt @@ -28,7 +28,7 @@ import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedInject import dev.zacsweers.metro.ContributesBinding import io.element.android.libraries.core.log.logger.LoggerTag -import io.element.android.libraries.permissions.api.PermissionsEvents +import io.element.android.libraries.permissions.api.PermissionsEvent import io.element.android.libraries.permissions.api.PermissionsPresenter import io.element.android.libraries.permissions.api.PermissionsState import io.element.android.libraries.permissions.api.PermissionsStore @@ -100,20 +100,20 @@ class DefaultPermissionsPresenter( val showDialog = rememberSaveable { mutableStateOf(false) } - fun handleEvent(event: PermissionsEvents) { + fun handleEvent(event: PermissionsEvent) { when (event) { - PermissionsEvents.CloseDialog -> { + PermissionsEvent.CloseDialog -> { showDialog.value = false } - PermissionsEvents.RequestPermissions -> { + PermissionsEvent.RequestPermissions -> { if (permissionState.status !is PermissionStatus.Granted && isAlreadyDenied) { showDialog.value = true } else { permissionState.launchPermissionRequest() } } - PermissionsEvents.OpenSystemSettingAndCloseDialog -> { - permissionActions.openSettings() + PermissionsEvent.OpenSystemSettingAndCloseDialog -> { + permissionActions.openSettings(permission) showDialog.value = false } } diff --git a/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/action/AndroidPermissionActions.kt b/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/action/AndroidPermissionActions.kt index 59ebfc6deb7..1be84288901 100644 --- a/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/action/AndroidPermissionActions.kt +++ b/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/action/AndroidPermissionActions.kt @@ -8,9 +8,11 @@ package io.element.android.libraries.permissions.impl.action +import android.Manifest import android.content.Context import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.androidutils.system.openAppSettingsPage import io.element.android.libraries.androidutils.system.startNotificationSettingsIntent import io.element.android.libraries.di.annotations.ApplicationContext @@ -18,7 +20,10 @@ import io.element.android.libraries.di.annotations.ApplicationContext class AndroidPermissionActions( @ApplicationContext private val context: Context ) : PermissionActions { - override fun openSettings() { - context.startNotificationSettingsIntent() + override fun openSettings(permission: String) { + when (permission) { + Manifest.permission.POST_NOTIFICATIONS -> context.startNotificationSettingsIntent() + else -> context.openAppSettingsPage() + } } } diff --git a/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/action/PermissionActions.kt b/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/action/PermissionActions.kt index 5c496bf4c72..2b1249b8236 100644 --- a/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/action/PermissionActions.kt +++ b/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/action/PermissionActions.kt @@ -9,5 +9,5 @@ package io.element.android.libraries.permissions.impl.action interface PermissionActions { - fun openSettings() + fun openSettings(permission: String) } diff --git a/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/troubleshoot/NotificationTroubleshootCheckPermissionTest.kt b/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/troubleshoot/NotificationTroubleshootCheckPermissionTest.kt index f5ff79e9101..eff20fabecf 100644 --- a/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/troubleshoot/NotificationTroubleshootCheckPermissionTest.kt +++ b/libraries/permissions/impl/src/main/kotlin/io/element/android/libraries/permissions/impl/troubleshoot/NotificationTroubleshootCheckPermissionTest.kt @@ -61,6 +61,6 @@ class NotificationTroubleshootCheckPermissionTest( navigator: NotificationTroubleshootNavigator, ) { // Do not bother about asking the permission inline, just lead the user to the settings - permissionActions.openSettings() + permissionActions.openSettings(Manifest.permission.POST_NOTIFICATIONS) } } diff --git a/libraries/permissions/impl/src/main/res/values-hr/translations.xml b/libraries/permissions/impl/src/main/res/values-hr/translations.xml new file mode 100644 index 00000000000..e0efeca8b20 --- /dev/null +++ b/libraries/permissions/impl/src/main/res/values-hr/translations.xml @@ -0,0 +1,5 @@ + + + "Provjerite može li aplikacija prikazivati ​​obavijesti." + "Provjeri dopuštenja" + diff --git a/libraries/permissions/impl/src/test/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionsPresenterTest.kt b/libraries/permissions/impl/src/test/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionsPresenterTest.kt index 069a632807d..c7a10ca8d17 100644 --- a/libraries/permissions/impl/src/test/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionsPresenterTest.kt +++ b/libraries/permissions/impl/src/test/kotlin/io/element/android/libraries/permissions/impl/DefaultPermissionsPresenterTest.kt @@ -10,21 +10,23 @@ package io.element.android.libraries.permissions.impl -import app.cash.molecule.RecompositionMode -import app.cash.molecule.moleculeFlow -import app.cash.turbine.test import com.google.accompanist.permissions.ExperimentalPermissionsApi import com.google.accompanist.permissions.PermissionStatus import com.google.common.truth.Truth.assertThat -import io.element.android.libraries.permissions.api.PermissionsEvents +import io.element.android.libraries.permissions.api.PermissionsEvent +import io.element.android.libraries.permissions.api.PermissionsStore import io.element.android.libraries.permissions.impl.action.FakePermissionActions +import io.element.android.libraries.permissions.impl.action.PermissionActions import io.element.android.libraries.permissions.test.InMemoryPermissionsStore 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.test.runTest import org.junit.Rule import org.junit.Test -const val A_PERMISSION = "A_PERMISSION" +private const val A_PERMISSION = "A_PERMISSION" class DefaultPermissionsPresenterTest { @get:Rule @@ -32,24 +34,8 @@ class DefaultPermissionsPresenterTest { @Test fun `present - initial state`() = runTest { - val permissionsStore = InMemoryPermissionsStore() - val permissionState = FakePermissionState( - A_PERMISSION, - PermissionStatus.Granted - ) - val permissionStateProvider = - FakeComposablePermissionStateProvider( - permissionState - ) - val presenter = DefaultPermissionsPresenter( - A_PERMISSION, - permissionsStore, - permissionStateProvider, - FakePermissionActions(), - ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { + val presenter = createPresenter() + presenter.test { val initialState = awaitItem() assertThat(initialState.permission).isEqualTo(A_PERMISSION) assertThat(initialState.permissionGranted).isTrue() @@ -66,29 +52,22 @@ class DefaultPermissionsPresenterTest { permissionDenied = true, permissionAsked = true ) - val permissionState = FakePermissionState( - A_PERMISSION, - PermissionStatus.Denied(shouldShowRationale = false) + val permissionStateProvider = FakeComposablePermissionStateProvider( + permissionState = aFakePermissionState( + initialStatus = PermissionStatus.Denied(shouldShowRationale = false) + ), ) - val permissionStateProvider = - FakeComposablePermissionStateProvider( - permissionState - ) - val presenter = DefaultPermissionsPresenter( - A_PERMISSION, - permissionsStore, - permissionStateProvider, - FakePermissionActions(), + val presenter = createPresenter( + permissionsStore = permissionsStore, + permissionStateProvider = permissionStateProvider, ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { + presenter.test { skipItems(1) val initialState = awaitItem() - initialState.eventSink.invoke(PermissionsEvents.RequestPermissions) + initialState.eventSink.invoke(PermissionsEvent.RequestPermissions) val withDialogState = awaitItem() assertThat(withDialogState.showDialog).isTrue() - withDialogState.eventSink.invoke(PermissionsEvents.CloseDialog) + withDialogState.eventSink.invoke(PermissionsEvent.CloseDialog) assertThat(awaitItem().showDialog).isFalse() } } @@ -99,59 +78,48 @@ class DefaultPermissionsPresenterTest { permissionDenied = true, permissionAsked = true ) - val permissionState = FakePermissionState( - A_PERMISSION, - PermissionStatus.Denied(shouldShowRationale = false) + val permissionStateProvider = FakeComposablePermissionStateProvider( + permissionState = aFakePermissionState( + initialStatus = PermissionStatus.Denied(shouldShowRationale = false), + ), + ) + val openSettingsAction = lambdaRecorder { } + val permissionActions = FakePermissionActions( + openSettingsAction = openSettingsAction, ) - val permissionStateProvider = - FakeComposablePermissionStateProvider( - permissionState - ) - val permissionActions = FakePermissionActions() - val presenter = DefaultPermissionsPresenter( - A_PERMISSION, - permissionsStore, - permissionStateProvider, - permissionActions, + val presenter = createPresenter( + permissionsStore = permissionsStore, + permissionStateProvider = permissionStateProvider, + permissionActions = permissionActions, ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { + presenter.test { skipItems(1) val initialState = awaitItem() - initialState.eventSink.invoke(PermissionsEvents.RequestPermissions) + initialState.eventSink.invoke(PermissionsEvent.RequestPermissions) val withDialogState = awaitItem() assertThat(withDialogState.showDialog).isTrue() - assertThat(permissionActions.openSettingsCalled).isFalse() - withDialogState.eventSink.invoke(PermissionsEvents.OpenSystemSettingAndCloseDialog) + openSettingsAction.assertions().isNeverCalled() + withDialogState.eventSink.invoke(PermissionsEvent.OpenSystemSettingAndCloseDialog) assertThat(awaitItem().showDialog).isFalse() - assertThat(permissionActions.openSettingsCalled).isTrue() + openSettingsAction.assertions().isCalledOnce().with(value(A_PERMISSION)) } } @Test fun `present - user does not grant permission`() = runTest { - val permissionsStore = InMemoryPermissionsStore() - val permissionState = FakePermissionState( - A_PERMISSION, - PermissionStatus.Denied(shouldShowRationale = false) + val permissionState = aFakePermissionState( + initialStatus = PermissionStatus.Denied(shouldShowRationale = false) ) - val permissionStateProvider = - FakeComposablePermissionStateProvider( - permissionState - ) - val presenter = DefaultPermissionsPresenter( - A_PERMISSION, - permissionsStore, - permissionStateProvider, - FakePermissionActions(), + val permissionStateProvider = FakeComposablePermissionStateProvider( + permissionState = permissionState, ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { + val presenter = createPresenter( + permissionStateProvider = permissionStateProvider, + ) + presenter.test { val initialState = awaitItem() assertThat(initialState.showDialog).isFalse() - initialState.eventSink.invoke(PermissionsEvents.RequestPermissions) + initialState.eventSink.invoke(PermissionsEvent.RequestPermissions) assertThat(permissionState.launchPermissionRequestCalled).isTrue() // User does not grant permission permissionStateProvider.userGiveAnswer(answer = false, firstTime = true) @@ -166,27 +134,19 @@ class DefaultPermissionsPresenterTest { @Test fun `present - user does not grant permission second time`() = runTest { - val permissionsStore = InMemoryPermissionsStore() - val permissionState = FakePermissionState( - A_PERMISSION, - PermissionStatus.Denied(shouldShowRationale = true) + val permissionState = aFakePermissionState( + initialStatus = PermissionStatus.Denied(shouldShowRationale = true) + ) + val permissionStateProvider = FakeComposablePermissionStateProvider( + permissionState = permissionState, ) - val permissionStateProvider = - FakeComposablePermissionStateProvider( - permissionState - ) - val presenter = DefaultPermissionsPresenter( - A_PERMISSION, - permissionsStore, - permissionStateProvider, - FakePermissionActions(), + val presenter = createPresenter( + permissionStateProvider = permissionStateProvider, ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { + presenter.test { val initialState = awaitItem() assertThat(initialState.showDialog).isFalse() - initialState.eventSink.invoke(PermissionsEvents.RequestPermissions) + initialState.eventSink.invoke(PermissionsEvent.RequestPermissions) assertThat(permissionState.launchPermissionRequestCalled).isTrue() // User does not grant permission permissionStateProvider.userGiveAnswer(answer = false, firstTime = false) @@ -201,31 +161,24 @@ class DefaultPermissionsPresenterTest { @Test fun `present - user does not grant permission third time`() = runTest { - val permissionsStore = - InMemoryPermissionsStore( - permissionDenied = true, - permissionAsked = true - ) - val permissionState = FakePermissionState( - A_PERMISSION, - PermissionStatus.Denied(shouldShowRationale = false) + val permissionsStore = InMemoryPermissionsStore( + permissionDenied = true, + permissionAsked = true, + ) + val permissionState = aFakePermissionState( + initialStatus = PermissionStatus.Denied(shouldShowRationale = false), + ) + val permissionStateProvider = FakeComposablePermissionStateProvider( + permissionState = permissionState, ) - val permissionStateProvider = - FakeComposablePermissionStateProvider( - permissionState - ) - val presenter = DefaultPermissionsPresenter( - A_PERMISSION, - permissionsStore, - permissionStateProvider, - FakePermissionActions(), + val presenter = createPresenter( + permissionsStore = permissionsStore, + permissionStateProvider = permissionStateProvider, ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { + presenter.test { skipItems(1) val initialState = awaitItem() - initialState.eventSink.invoke(PermissionsEvents.RequestPermissions) + initialState.eventSink.invoke(PermissionsEvent.RequestPermissions) val withDialogState = awaitItem() assertThat(withDialogState.showDialog).isTrue() assertThat(withDialogState.permissionGranted).isFalse() @@ -236,27 +189,19 @@ class DefaultPermissionsPresenterTest { @Test fun `present - user grants permission`() = runTest { - val permissionsStore = InMemoryPermissionsStore() - val permissionState = FakePermissionState( - A_PERMISSION, - PermissionStatus.Denied(shouldShowRationale = false) + val permissionState = aFakePermissionState( + initialStatus = PermissionStatus.Denied(shouldShowRationale = false) ) - val permissionStateProvider = - FakeComposablePermissionStateProvider( - permissionState - ) - val presenter = DefaultPermissionsPresenter( - A_PERMISSION, - permissionsStore, - permissionStateProvider, - FakePermissionActions(), + val permissionStateProvider = FakeComposablePermissionStateProvider( + permissionState = permissionState, ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { + val presenter = createPresenter( + permissionStateProvider = permissionStateProvider, + ) + presenter.test { val initialState = awaitItem() assertThat(initialState.showDialog).isFalse() - initialState.eventSink.invoke(PermissionsEvents.RequestPermissions) + initialState.eventSink.invoke(PermissionsEvent.RequestPermissions) assertThat(permissionState.launchPermissionRequestCalled).isTrue() // User grants permission permissionStateProvider.userGiveAnswer(answer = true, firstTime = true) @@ -269,3 +214,25 @@ class DefaultPermissionsPresenterTest { } } } + +private fun createPresenter( + permission: String = A_PERMISSION, + permissionsStore: PermissionsStore = InMemoryPermissionsStore(), + permissionStateProvider: ComposablePermissionStateProvider = FakeComposablePermissionStateProvider( + permissionState = aFakePermissionState(), + ), + permissionActions: PermissionActions = FakePermissionActions(), +) = DefaultPermissionsPresenter( + permission = permission, + permissionsStore = permissionsStore, + composablePermissionStateProvider = permissionStateProvider, + permissionActions = permissionActions, +) + +private fun aFakePermissionState( + permission: String = A_PERMISSION, + initialStatus: PermissionStatus = PermissionStatus.Granted, +) = FakePermissionState( + permission = permission, + initialStatus = initialStatus, +) diff --git a/libraries/permissions/impl/src/test/kotlin/io/element/android/libraries/permissions/impl/action/FakePermissionActions.kt b/libraries/permissions/impl/src/test/kotlin/io/element/android/libraries/permissions/impl/action/FakePermissionActions.kt index 4299672d8db..df66dee3775 100644 --- a/libraries/permissions/impl/src/test/kotlin/io/element/android/libraries/permissions/impl/action/FakePermissionActions.kt +++ b/libraries/permissions/impl/src/test/kotlin/io/element/android/libraries/permissions/impl/action/FakePermissionActions.kt @@ -8,14 +8,12 @@ package io.element.android.libraries.permissions.impl.action +import io.element.android.tests.testutils.lambda.lambdaError + class FakePermissionActions( - val openSettingsAction: () -> Unit = {} + val openSettingsAction: (String) -> Unit = { lambdaError() } ) : PermissionActions { - var openSettingsCalled = false - private set - - override fun openSettings() { - openSettingsAction() - openSettingsCalled = true + override fun openSettings(permission: String) { + openSettingsAction(permission) } } diff --git a/libraries/permissions/test/src/main/kotlin/io/element/android/libraries/permissions/test/FakePermissionsPresenter.kt b/libraries/permissions/test/src/main/kotlin/io/element/android/libraries/permissions/test/FakePermissionsPresenter.kt index b15f4db3eea..efd584585ff 100644 --- a/libraries/permissions/test/src/main/kotlin/io/element/android/libraries/permissions/test/FakePermissionsPresenter.kt +++ b/libraries/permissions/test/src/main/kotlin/io/element/android/libraries/permissions/test/FakePermissionsPresenter.kt @@ -10,7 +10,7 @@ package io.element.android.libraries.permissions.test import androidx.compose.runtime.Composable import androidx.compose.runtime.mutableStateOf -import io.element.android.libraries.permissions.api.PermissionsEvents +import io.element.android.libraries.permissions.api.PermissionsEvent import io.element.android.libraries.permissions.api.PermissionsPresenter import io.element.android.libraries.permissions.api.PermissionsState import io.element.android.libraries.permissions.api.aPermissionsState @@ -18,11 +18,11 @@ import io.element.android.libraries.permissions.api.aPermissionsState class FakePermissionsPresenter( private val initialState: PermissionsState = aPermissionsState(showDialog = false), ) : PermissionsPresenter { - private fun handleEvent(event: PermissionsEvents) { + private fun handleEvent(event: PermissionsEvent) { when (event) { - PermissionsEvents.RequestPermissions -> state.value = state.value.copy(showDialog = true, permissionAlreadyAsked = true) - PermissionsEvents.CloseDialog -> state.value = state.value.copy(showDialog = false) - PermissionsEvents.OpenSystemSettingAndCloseDialog -> state.value = state.value.copy(showDialog = false) + PermissionsEvent.RequestPermissions -> state.value = state.value.copy(showDialog = true, permissionAlreadyAsked = true) + PermissionsEvent.CloseDialog -> state.value = state.value.copy(showDialog = false) + PermissionsEvent.OpenSystemSettingAndCloseDialog -> state.value = state.value.copy(showDialog = false) } } diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolver.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolver.kt index 867336c721b..5c4f5bb0819 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolver.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolver.kt @@ -99,7 +99,7 @@ class DefaultNotifiableEventResolver( ): ResolvePushEventsResult { Timber.d("Queueing notifications: $notificationEventRequests") val client = matrixClientProvider.getOrRestore(sessionId).getOrElse { - return Result.failure(IllegalStateException("Couldn't get or restore client for session $sessionId")) + return Result.failure(it) } val ids = notificationEventRequests.groupBy { it.roomId } .mapValues { (_, requests) -> @@ -183,7 +183,11 @@ class DefaultNotifiableEventResolver( soundName = null, isRedacted = false, isUpdated = false, - description = descriptionFromRoomMembershipInvite(senderDisambiguatedDisplayName, isDirect), + description = descriptionFromRoomMembershipInvite( + senderDisambiguatedDisplayName = senderDisambiguatedDisplayName, + isDirectRoom = isDirect, + isSpace = isSpace + ), // TODO check if type is needed anymore type = null, // TODO check if title is needed anymore @@ -333,12 +337,19 @@ class DefaultNotifiableEventResolver( private fun descriptionFromRoomMembershipInvite( senderDisambiguatedDisplayName: String, - isDirectRoom: Boolean + isDirectRoom: Boolean, + isSpace: Boolean, ): String { - return if (isDirectRoom) { - stringProvider.getString(R.string.notification_invite_body_with_sender, senderDisambiguatedDisplayName) - } else { - stringProvider.getString(R.string.notification_room_invite_body_with_sender, senderDisambiguatedDisplayName) + return when { + isDirectRoom -> { + stringProvider.getString(R.string.notification_invite_body_with_sender, senderDisambiguatedDisplayName) + } + isSpace -> { + stringProvider.getString(R.string.notification_space_invite_body_with_sender, senderDisambiguatedDisplayName) + } + else -> { + stringProvider.getString(R.string.notification_room_invite_body_with_sender, senderDisambiguatedDisplayName) + } } } diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationResolverQueue.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationResolverQueue.kt index 0d1478cb9ed..b40b3fe79f9 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationResolverQueue.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationResolverQueue.kt @@ -17,7 +17,7 @@ import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.push.api.push.NotificationEventRequest import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEvent import io.element.android.libraries.push.impl.workmanager.SyncNotificationWorkManagerRequest -import io.element.android.libraries.push.impl.workmanager.WorkerDataConverter +import io.element.android.libraries.push.impl.workmanager.SyncNotificationsWorkerDataConverter import io.element.android.libraries.workmanager.api.WorkManagerScheduler import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider import kotlinx.coroutines.CoroutineScope @@ -50,7 +50,7 @@ class DefaultNotificationResolverQueue( private val appCoroutineScope: CoroutineScope, private val workManagerScheduler: WorkManagerScheduler, private val featureFlagService: FeatureFlagService, - private val workerDataConverter: WorkerDataConverter, + private val workerDataConverter: SyncNotificationsWorkerDataConverter, private val buildVersionSdkIntProvider: BuildVersionSdkIntProvider, ) : NotificationResolverQueue { companion object { diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/RoomGroupMessageCreator.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/RoomGroupMessageCreator.kt index 8683b584932..150f4a9a2df 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/RoomGroupMessageCreator.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/RoomGroupMessageCreator.kt @@ -75,7 +75,7 @@ class DefaultRoomGroupMessageCreator( hasSmartReplyError = smartReplyErrors.isNotEmpty(), shouldBing = events.any { it.noisy }, customSound = events.last().soundName, - isUpdated = events.last().isUpdated, + isUpdated = events.last().let { it.isUpdated || it.outGoingMessage }, ), threadId = threadId, largeIcon = largeBitmap, diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/troubleshoot/PushLoopbackTest.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/troubleshoot/PushLoopbackTest.kt index 64b8f98f5b3..91ac1ca0ee5 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/troubleshoot/PushLoopbackTest.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/troubleshoot/PushLoopbackTest.kt @@ -56,7 +56,7 @@ class PushLoopbackTest( } val testPushResult = try { pushService.testPush(sessionId) - } catch (pusherRejected: PushGatewayFailure.PusherRejected) { + } catch (_: PushGatewayFailure.PusherRejected) { val hasQuickFix = pushService.getCurrentPushProvider(sessionId)?.canRotateToken() == true delegate.updateState( description = stringProvider.getString(R.string.troubleshoot_notifications_test_push_loop_back_failure_1), diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/FetchNotificationsWorker.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/FetchNotificationsWorker.kt index be8db1a11e2..eeac5ff66fa 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/FetchNotificationsWorker.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/FetchNotificationsWorker.kt @@ -22,6 +22,7 @@ import io.element.android.features.networkmonitor.api.NetworkStatus 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.auth.SessionRestorationException import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.push.api.push.NotificationEventRequest import io.element.android.libraries.push.api.push.SyncOnNotifiableEvent @@ -48,7 +49,7 @@ class FetchNotificationsWorker( private val workManagerScheduler: WorkManagerScheduler, private val syncOnNotifiableEvent: SyncOnNotifiableEvent, private val coroutineDispatchers: CoroutineDispatchers, - private val workerDataConverter: WorkerDataConverter, + private val workerDataConverter: SyncNotificationsWorkerDataConverter, private val buildVersionSdkIntProvider: BuildVersionSdkIntProvider, ) : CoroutineWorker(context, workerParams) { override suspend fun doWork(): Result = withContext(coroutineDispatchers.io) { @@ -63,9 +64,9 @@ class FetchNotificationsWorker( return@withContext Result.retry() } - val failedSyncForSessions = mutableSetOf() + val failedSyncForSessions = mutableMapOf() - val groupedRequests = requests.groupBy { it.sessionId } + val groupedRequests = requests.groupBy { it.sessionId }.toMutableMap() for ((sessionId, notificationRequests) in groupedRequests) { Timber.d("Processing notification requests for session $sessionId") eventResolver.resolveEvents(sessionId, notificationRequests) @@ -75,7 +76,7 @@ class FetchNotificationsWorker( (queue.results as MutableSharedFlow).emit(requests to result) }, onFailure = { - failedSyncForSessions += sessionId + failedSyncForSessions[sessionId] = it Timber.e(it, "Failed to resolve notification events for session $sessionId") } ) @@ -83,7 +84,13 @@ class FetchNotificationsWorker( // If there were failures for whole sessions, we retry all their requests if (failedSyncForSessions.isNotEmpty()) { - for (failedSessionId in failedSyncForSessions) { + @Suppress("LoopWithTooManyJumpStatements") + for ((failedSessionId, exception) in failedSyncForSessions) { + if (exception.cause is SessionRestorationException) { + Timber.e(exception, "Session $failedSessionId could not be restored, not retrying notification fetching") + groupedRequests.remove(failedSessionId) + continue + } val requestsToRetry = groupedRequests[failedSessionId] ?: continue Timber.d("Re-scheduling ${requestsToRetry.size} failed notification requests for session $failedSessionId") workManagerScheduler.submit( diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/SyncNotificationWorkManagerRequest.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/SyncNotificationWorkManagerRequest.kt index b11b83d6e40..50ef28903cf 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/SyncNotificationWorkManagerRequest.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/SyncNotificationWorkManagerRequest.kt @@ -26,7 +26,7 @@ import java.security.InvalidParameterException class SyncNotificationWorkManagerRequest( private val sessionId: SessionId, private val notificationEventRequests: List, - private val workerDataConverter: WorkerDataConverter, + private val workerDataConverter: SyncNotificationsWorkerDataConverter, private val buildVersionSdkIntProvider: BuildVersionSdkIntProvider, ) : WorkManagerRequest { override fun build(): Result> { diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/WorkerDataConverter.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/SyncNotificationsWorkerDataConverter.kt similarity index 99% rename from libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/WorkerDataConverter.kt rename to libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/SyncNotificationsWorkerDataConverter.kt index 23e66396c6a..46b7d760c0a 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/WorkerDataConverter.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/workmanager/SyncNotificationsWorkerDataConverter.kt @@ -21,7 +21,7 @@ import io.element.android.libraries.push.api.push.NotificationEventRequest import timber.log.Timber @Inject -class WorkerDataConverter( +class SyncNotificationsWorkerDataConverter( private val json: JsonProvider, ) { fun serialize(notificationEventRequests: List): Result> { diff --git a/libraries/push/impl/src/main/res/values-be/translations.xml b/libraries/push/impl/src/main/res/values-be/translations.xml index 7a30be6f011..aec22f99105 100644 --- a/libraries/push/impl/src/main/res/values-be/translations.xml +++ b/libraries/push/impl/src/main/res/values-be/translations.xml @@ -59,6 +59,7 @@ "Фонавая сінхранізацыя" "Сэрвісы Google" "Службы Google Play не знойдзены. Апавяшчэнні могуць не працаваць належным чынам." + "Заблакіраваныя карыстальнікі" "Атрымаць назву бягучага пастаўшчыка." "Пастаўшчыкі push-апавяшчэнняў не выбраны." "Бягучы пастаўшчык push-апавяшчэнняў: %1$s." 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 667f181536f..0231af68d94 100644 --- a/libraries/push/impl/src/main/res/values-cs/translations.xml +++ b/libraries/push/impl/src/main/res/values-cs/translations.xml @@ -42,6 +42,8 @@ "%1$s vás pozval(a) do místnosti" "Já" "%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ě!" "Vlákno v %1$s" "%1$s: %2$s" @@ -99,5 +101,5 @@ "Chyba, nelze otestovat push." "Chyba, časový limit čekání na push." "Push zpětná smyčka trvala %1$d ms." - "Otestovat push zpětnou smyčku" + "Otestovat push pomocí zpětného volání"
diff --git a/libraries/push/impl/src/main/res/values-da/translations.xml b/libraries/push/impl/src/main/res/values-da/translations.xml index a5b23fc7bac..5f16a8cf8ad 100644 --- a/libraries/push/impl/src/main/res/values-da/translations.xml +++ b/libraries/push/impl/src/main/res/values-da/translations.xml @@ -13,6 +13,7 @@ "%d notifikation" "%d notifikationer"
+ "UnifiedPush-push notification-distributøren kunne ikke registreres, så du vil ikke længere modtage notifikationer. Kontrollér appens notifikationsindstillinger og push-distributørens status." "Du har nye beskeder." "📹 Indgående opkald" "** Kunne ikke sende - åbn venligst rummet" diff --git a/libraries/push/impl/src/main/res/values-de/translations.xml b/libraries/push/impl/src/main/res/values-de/translations.xml index 1a9e2f21c4b..6c0e51564a6 100644 --- a/libraries/push/impl/src/main/res/values-de/translations.xml +++ b/libraries/push/impl/src/main/res/values-de/translations.xml @@ -13,6 +13,7 @@ "%d Mitteilung" "%d Mitteilungen"
+ "Der Dienst für UnifiedPush Benachrichtigungen konnte nicht registriert werden. Daher können aktuell keine Push-Benachrichtigungen erhalten werden. Bitte überprüfe die Einstellungen der Benachrichtigungen in der App und den Status des Push-Dienstes." "Du hast neue Nachrichten." "Eingehender Anruf" "** Fehler beim Senden - bitte Chat öffnen" @@ -37,6 +38,8 @@ "%1$s hat dich eingeladen, dem Chat beizutreten" "Ich" "%1$s hat Dich erwähnt oder geantwortet" + "Einladung zum Space" + "%1$s hat dich eingeladen, dem Space beizutreten" "Du siehst dir die Benachrichtigung an! Klicke hier!" "Thread in %1$s" "%1$s: %2$s" @@ -62,7 +65,7 @@ "Du hast %1$d Nutzer gesperrt. Du wirst für diesen Nutzer keine Benachrichtigungen erhalten." "Du hast %1$d Nutzer gesperrt. Du wirst für diese Nutzer keine Benachrichtigungen erhalten."
- "Gesperrte Nutzer" + "Blockierte Nutzer" "Ermittele den Namen des aktuellen Anbieters." "Kein Dienst für Push-Benachrichtigungen ausgewählt." "Aktueller Push-Dienst: %1$s und aktueller UnifiedPush-Distributor: %2$s. Aber der Distributor %3$s kann nicht gefunden werden. Vielleicht wurde die App deinstalliert?" diff --git a/libraries/push/impl/src/main/res/values-el/translations.xml b/libraries/push/impl/src/main/res/values-el/translations.xml index da21ccfce72..1229218f094 100644 --- a/libraries/push/impl/src/main/res/values-el/translations.xml +++ b/libraries/push/impl/src/main/res/values-el/translations.xml @@ -54,6 +54,7 @@ "Συγχρονισμός στο παρασκήνιο" "Υπηρεσίες Google" "Δεν βρέθηκαν έγκυρες υπηρεσίες Google Play. Οι ειδοποιήσεις ενδέχεται να μην λειτουργούν σωστά." + "Αποκλεισμένοι χρήστες" "Λάβε το όνομα του τρέχοντος παρόχου." "Δεν έχουν επιλεγεί πάροχοι push." "Τρέχων πάροχος push: %1$s." diff --git a/libraries/push/impl/src/main/res/values-en-rUS/translations.xml b/libraries/push/impl/src/main/res/values-en-rUS/translations.xml new file mode 100644 index 00000000000..95c732ea034 --- /dev/null +++ b/libraries/push/impl/src/main/res/values-en-rUS/translations.xml @@ -0,0 +1,4 @@ + + + "Background synchronization" + diff --git a/libraries/push/impl/src/main/res/values-es/translations.xml b/libraries/push/impl/src/main/res/values-es/translations.xml index 4200cb9baf7..cee68d6072f 100644 --- a/libraries/push/impl/src/main/res/values-es/translations.xml +++ b/libraries/push/impl/src/main/res/values-es/translations.xml @@ -53,6 +53,7 @@ "Sincronización en segundo plano" "Servicios de Google" "No se han encontrado Servicios de Google Play válidos. Es posible que las notificaciones no funcionen correctamente." + "Usuarios bloqueados" "Obtener el nombre del proveedor actual." "No se ha seleccionado ningún proveedor de push." "Proveedor de push actual: %1$s." diff --git a/libraries/push/impl/src/main/res/values-et/translations.xml b/libraries/push/impl/src/main/res/values-et/translations.xml index 24a355ed1e5..980129d7489 100644 --- a/libraries/push/impl/src/main/res/values-et/translations.xml +++ b/libraries/push/impl/src/main/res/values-et/translations.xml @@ -38,6 +38,8 @@ "%1$s saatis sulle kutse jututoaga liitumiseks" "Mina" "%1$s mainis või vastas" + "Kutsus sind liituma kogukonnaga" + "%1$s kutsus sind liituma kogukonnaga" "See ongi teavitus! Klõpsi mind!" "Jutulõng „%1$s“ jututoas" "%1$s: %2$s" diff --git a/libraries/push/impl/src/main/res/values-eu/translations.xml b/libraries/push/impl/src/main/res/values-eu/translations.xml index c72badbb525..826b9a762f3 100644 --- a/libraries/push/impl/src/main/res/values-eu/translations.xml +++ b/libraries/push/impl/src/main/res/values-eu/translations.xml @@ -53,6 +53,7 @@ "Atzeko planoko sinkronizazioa" "Google Services" "Ez da baliozko Google Play Servicerik aurkitu. Litekeena da jakinarazpenak behar bezala ez ibiltzea." + "Blokeatutako erabiltzaileak" "Lortu uneko hornitzailearen izena." "Ez da push hornitzailerik hautatu." "Uneko push hornitzailea: %1$s." diff --git a/libraries/push/impl/src/main/res/values-fr/translations.xml b/libraries/push/impl/src/main/res/values-fr/translations.xml index 8927f9ecccb..85146e259d6 100644 --- a/libraries/push/impl/src/main/res/values-fr/translations.xml +++ b/libraries/push/impl/src/main/res/values-fr/translations.xml @@ -38,6 +38,8 @@ "%1$s vous a invité à rejoindre le salon" "Moi" "%1$s mentionné ou en réponse" + "Vous a invité à rejoindre l’espace" + "%1$s vous a invité à rejoindre l’espace" "Vous êtes en train de voir la notification ! Cliquez-moi !" "Discussion dans %1$s" "%1$s : %2$s" diff --git a/libraries/push/impl/src/main/res/values-hr/translations.xml b/libraries/push/impl/src/main/res/values-hr/translations.xml new file mode 100644 index 00000000000..86c458dbf9e --- /dev/null +++ b/libraries/push/impl/src/main/res/values-hr/translations.xml @@ -0,0 +1,105 @@ + + + "Poziv" + "Osluškivanje događaja" + "Glasne obavijesti" + "Pozivi (telefon zvoni)" + "Tihe obavijesti" + + "%1$s: %2$d poruka" + "%1$s: %2$d poruke" + "%1$s: %2$d poruka" + + + "%d obavijest" + "%d obavijesti" + "%d obavijesti" + + "Distributer obavijesti UnifiedPush nije mogao biti registriran, tako da više nećete primati obavijesti. Provjerite postavke obavijesti u aplikaciji i status distributera push obavijesti." + "Imate nove poruke." + "📹 Dolazni poziv" + "** Slanje nije uspjelo – otvorite sobu" + "Pridruži se" + "Odbij" + + "%d pozivnica" + "%d pozivnice" + "%d pozivnica" + + "Pozvao/la te na razgovor" + "%1$s pozvao/la te na razgovor" + "Korisnik vas je spomenuo: %1$s" + "Nove poruke" + + "%d nova poruka" + "%d nove poruke" + "%d novih poruka" + + "Reagirao/la je s %1$s" + "Označi kao pročitano" + "Brzi odgovor" + "Pozvao/la te je da se pridružiš sobi" + "%1$s pozvao/la te da se pridružiš sobi" + "Ja" + "%1$s je spomenuo/la ili odgovorio/la" + "Pozvao/la vas je da se pridružite prostoru" + "%1$s pozvao/la vas je da se pridružite prostoru" + "Gledate obavijest! Kliknite me!" + "Nit u %1$s" + "%1$s: %2$s" + "%1$s: %2$s %3$s" + + "%d nepročitana poruka s obavijesti" + "%d nepročitane poruke s obavijesti" + "%d nepročitanih poruka s obavijesti" + + "%1$s i %2$s" + "%1$s u %2$s" + "%1$s u %2$s i %3$s" + + "%d soba" + "%d sobe" + "%d soba" + + "Sinkronizacija u pozadini" + "Googleove usluge" + "Nisu pronađene valjane usluge Google Play. Obavijesti možda neće ispravno funkcionirati." + "Provjera blokiranih korisnika" + "Prikaži blokirane korisnike" + "Nijedan korisnik nije blokiran." + + "Blokirali ste %1$d korisnika. Nećete primati obavijesti za ovog korisnika." + "Blokirali ste %1$d korisnika. Nećete primati obavijesti za te korisnike." + "Blokirali ste %1$d korisnika. Nećete primati obavijesti za te korisnike." + + "Blokirani korisnici" + "Dohvatite naziv trenutačnog pružatelja." + "Nije odabran nijedan pružatelj push obavijesti." + "Trenutačni pružatelj push obavijesti je %1$s i trenutačni distributer je %2$s. Ali distributer %3$s nije pronađen. Možda je aplikacija deinstalirana?" + "Trenutačni pružatelj push obavijesti je %1$s, ali nijedan distributer nije konfiguriran." + "Trenutačni pružatelj push obavijesti: %1$s ." + "Trenutačni pružatelj push obavijesti: %1$s (%2$s)" + "Trenutačni pružatelj push obavijesti" + "Provjerite podržava li aplikacija barem jednog pružatelja push obavijesti" + "Nije pronađena podrška za pružatelja push obavijesti." + + "Pronađen je %1$d pružatelj push obavijesti: %2$s" + "Pronađena su %1$d pružatelja push obavijesti: %2$s" + "Pronađeno je %1$d pružatelja push obavijesti: %2$s" + + "Aplikacija je razvijena s podrškom za: %1$s" + "Podrška za pružatelja push obavijesti" + "Provjerite može li aplikacija prikazivati ​​obavijesti." + "Obavijest nije kliknuta." + "Obavijest nije moguće prikazati." + "Obavijest je kliknuta!" + "Prikaz obavijesti" + "Kliknite na obavijest za nastavak testa." + "Provjerite prima li aplikacija push obavijesti." + "Pogreška: sustav za push obavijesti odbio je zahtjev." + "Pogreška: %1$s ." + "Pogreška, nije moguće testirati push obavijesti." + "Pogreška, isteklo je vrijeme čekanja na push obavijest." + "Povratna push obavijest trajala je %1$d ms." + "Testiraj povratnu push obavijest" + 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 760d507e08e..4baa46d5a9d 100644 --- a/libraries/push/impl/src/main/res/values-in/translations.xml +++ b/libraries/push/impl/src/main/res/values-in/translations.xml @@ -48,6 +48,7 @@ "Sinkronisasi latar belakang" "Layanan Google" "Tidak ditemukan Layanan Google Play yang valid. Pemberitahuan mungkin tidak berfungsi dengan baik." + "Pengguna yang diblokir" "Dapatkan nama penyedia saat ini." "Tidak ada penyedia notifikasi dorongan yang dipilih." "Penyedia notifikasi dorongan saat ini: %1$s." diff --git a/libraries/push/impl/src/main/res/values-ka/translations.xml b/libraries/push/impl/src/main/res/values-ka/translations.xml index 3a0d6260168..9cf2ddcab29 100644 --- a/libraries/push/impl/src/main/res/values-ka/translations.xml +++ b/libraries/push/impl/src/main/res/values-ka/translations.xml @@ -47,6 +47,7 @@ "ფონის სინქრონიზაცია" "Google სერვისები" "მოქმედი Google Play სერვისები ვერ მოიძებნა. შეტყობინებები შეიძლება ვერ იმუშაოს სწორად." + "დაბლოკილი მომხმარებლები" "მიმდინარე პროვაიდერის სახელის გაგება" "push პროვაიდერები არაა არჩეული." "მიმდინარე push პროვაიდერი: %1$s." diff --git a/libraries/push/impl/src/main/res/values-ko/translations.xml b/libraries/push/impl/src/main/res/values-ko/translations.xml index 9c2aa8c710b..c65b6c3388e 100644 --- a/libraries/push/impl/src/main/res/values-ko/translations.xml +++ b/libraries/push/impl/src/main/res/values-ko/translations.xml @@ -48,6 +48,7 @@ "백그라운드 동기화" "Google 서비스" "유효한 Google Play 서비스를 찾지 못했습니다. 알림이 정상적으로 동작하지 않을 수 있습니다." + "차단한 사용자" "현재 제공자의 이름을 가져옵니다." "푸시 제공자가 선택되지 않았습니다." "현재 푸시 제공자: %1$s." diff --git a/libraries/push/impl/src/main/res/values-nb/translations.xml b/libraries/push/impl/src/main/res/values-nb/translations.xml index c7114551d3c..933b5b886b2 100644 --- a/libraries/push/impl/src/main/res/values-nb/translations.xml +++ b/libraries/push/impl/src/main/res/values-nb/translations.xml @@ -38,6 +38,7 @@ "Meg" "%1$s nevnt eller besvart" "Du ser på varselet! Klikk på meg!" + "Tråd i %1$s" "%1$s: %2$s" "%1$s: %2$s %3$s" diff --git a/libraries/push/impl/src/main/res/values-nl/translations.xml b/libraries/push/impl/src/main/res/values-nl/translations.xml index 833c6e7107c..79244669e65 100644 --- a/libraries/push/impl/src/main/res/values-nl/translations.xml +++ b/libraries/push/impl/src/main/res/values-nl/translations.xml @@ -53,6 +53,7 @@ "Achtergrondsynchronisatie" "Google-services" "Geen geldige Google Play-services gevonden. Meldingen werken mogelijk niet goed." + "Geblokkeerde gebruikers" "Naam van de huidige provider aan het ophalen." "Er zijn geen push-providers geselecteerd." "Huidige push-provider: %1$s." diff --git a/libraries/push/impl/src/main/res/values-pt-rBR/translations.xml b/libraries/push/impl/src/main/res/values-pt-rBR/translations.xml index 178823e629e..0e01d67105e 100644 --- a/libraries/push/impl/src/main/res/values-pt-rBR/translations.xml +++ b/libraries/push/impl/src/main/res/values-pt-rBR/translations.xml @@ -38,6 +38,8 @@ "%1$s te convidou para entrar na sala" "Eu" "%1$s mencionado ou respondido" + "Te convidou para entrar no espaço" + "%1$s te convidou para entrar no espaço" "Você está visualizando a notificação! Clique em mim!" "Tópico em %1$s" "%1$s: %2$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 7a5bcf2f066..0a83e6afad0 100644 --- a/libraries/push/impl/src/main/res/values-ro/translations.xml +++ b/libraries/push/impl/src/main/res/values-ro/translations.xml @@ -13,6 +13,7 @@ "%d notificare" "%d 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" "Apel primit" "** Trimiterea eșuată - vă rugăm să deschideți camera" @@ -37,7 +38,10 @@ "%1$s v-a invitat să vă alăturați camerei" "Eu" "%1$s v-a menționat sau 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" "%1$s: %2$s" "%1$s: %2$s %3$s" diff --git a/libraries/push/impl/src/main/res/values-sk/translations.xml b/libraries/push/impl/src/main/res/values-sk/translations.xml index a4deefba78a..764dbefa70c 100644 --- a/libraries/push/impl/src/main/res/values-sk/translations.xml +++ b/libraries/push/impl/src/main/res/values-sk/translations.xml @@ -15,6 +15,7 @@ "%d oznámenia" "%d oznámení" + "Distribútora oznámení UnifiedPush sa nepodarilo zaregistrovať, takže už nebudete dostávať oznámenia. Skontrolujte nastavenia oznámení v aplikácii a stav distribútora push oznámení." "Máte nové správy." "📹 Prichádzajúci hovor" "** Nepodarilo sa odoslať - prosím otvorte miestnosť" diff --git a/libraries/push/impl/src/main/res/values-sv/translations.xml b/libraries/push/impl/src/main/res/values-sv/translations.xml index aff078b2c02..9f978c253f0 100644 --- a/libraries/push/impl/src/main/res/values-sv/translations.xml +++ b/libraries/push/impl/src/main/res/values-sv/translations.xml @@ -54,6 +54,7 @@ "Bakgrundssynkronisering" "Google-tjänster" "Inga giltiga Google Play-tjänster hittades. Aviseringar kanske inte fungerar korrekt." + "Blockerade användare" "Hämta namnet på den nuvarande leverantören." "Inga push-leverantörer valda." "Nuvarande push-leverantör: %1$s." diff --git a/libraries/push/impl/src/main/res/values-tr/translations.xml b/libraries/push/impl/src/main/res/values-tr/translations.xml index a3c415a2105..558c2067496 100644 --- a/libraries/push/impl/src/main/res/values-tr/translations.xml +++ b/libraries/push/impl/src/main/res/values-tr/translations.xml @@ -54,6 +54,7 @@ "Arkaplan senkronizasyonu" "Google Hizmetleri" "Geçerli bir Google Play Hizmeti bulunamadı. Bildirimler düzgün çalışmayabilir." + "Engellenen kullanıcılar" "Geçerli sağlayıcının adını al." "Hiçbir gönderme sağlayıcısı seçilmedi." "Geçerli gönderme sağlayıcısı: %1$s." diff --git a/libraries/push/impl/src/main/res/values-uk/translations.xml b/libraries/push/impl/src/main/res/values-uk/translations.xml index 1aa535b1e27..43ca0faaca1 100644 --- a/libraries/push/impl/src/main/res/values-uk/translations.xml +++ b/libraries/push/impl/src/main/res/values-uk/translations.xml @@ -60,6 +60,7 @@ "Фонова синхронізація" "Сервіси Google" "Не знайдено дійсних сервісів Google Play. Сповіщення можуть не працювати належним чином." + "Заблоковані користувачі" "Отримує назву поточного постачальника." "Постачальників push-сповіщень не вибрано." "Поточний постачальник: %1$s." diff --git a/libraries/push/impl/src/main/res/values-ur/translations.xml b/libraries/push/impl/src/main/res/values-ur/translations.xml index 40a54ab3138..8f358b16164 100644 --- a/libraries/push/impl/src/main/res/values-ur/translations.xml +++ b/libraries/push/impl/src/main/res/values-ur/translations.xml @@ -54,6 +54,7 @@ "پس منظر مطابقت پذیری" "گوگل سروسز" "کوئی درست گوگل پلے سروسز نہیں ملی۔ ہو سکتا ہے اطلاعات ٹھیک سے کام نہ کریں۔" + "مسدود صارفین" "موجودہ فراہم کنندہ کا نام حاصل کریں۔" "کوئی دھکا فراہم کنندہ منتخب نہیں کیا گیا" "موجودہ دھکا فراہم کنندہ: %1$s۔" diff --git a/libraries/push/impl/src/main/res/values-uz/translations.xml b/libraries/push/impl/src/main/res/values-uz/translations.xml index 7ba9e6319ce..e967b47d344 100644 --- a/libraries/push/impl/src/main/res/values-uz/translations.xml +++ b/libraries/push/impl/src/main/res/values-uz/translations.xml @@ -54,6 +54,7 @@ "Orqa Fon sinxronizatsiyasi" "Google xizmatlari" "Yaroqli Google Play xizmatlari topilmadi. Bildirishnomalar to\'g\'ri ishlamasligi mumkin." + "Bloklangan foydalanuvchilar" "Joriy provayder nomini oling." "Hech qanday push-provayder tanlanmagan." "Joriy push provider: %1$s." diff --git a/libraries/push/impl/src/main/res/values-zh-rTW/translations.xml b/libraries/push/impl/src/main/res/values-zh-rTW/translations.xml index f0c1f1046f1..2d289e9a9ee 100644 --- a/libraries/push/impl/src/main/res/values-zh-rTW/translations.xml +++ b/libraries/push/impl/src/main/res/values-zh-rTW/translations.xml @@ -56,7 +56,7 @@ "您已封鎖 %1$d 個使用者。您將不會收到來自這些使用者的通知。" - "已封鎖使用者" + "封鎖的使用者" "取得目前提供者的名稱。" "未選取推播提供者。" "目前的推播提供者 %1$s 與目前的散佈者 %2$s。但找不到散佈者 %3$s。可能已解除安裝應用程式?" diff --git a/libraries/push/impl/src/main/res/values/localazy.xml b/libraries/push/impl/src/main/res/values/localazy.xml index 07648516477..aac5c6c72bf 100644 --- a/libraries/push/impl/src/main/res/values/localazy.xml +++ b/libraries/push/impl/src/main/res/values/localazy.xml @@ -38,6 +38,8 @@ "%1$s invited you to join the room" "Me" "%1$s mentioned or replied" + "Invited you to join the space" + "%1$s invited you to join the space" "You are viewing the notification! Click me!" "Thread in %1$s" "%1$s: %2$s" @@ -53,7 +55,7 @@ "%d room" "%d rooms"
- "Background synchronization" + "Background synchronisation" "Google Services" "No valid Google Play Services found. Notifications may not work properly." "Checking blocked users" diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolverTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolverTest.kt index d625c7804bc..58f97e55cbd 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolverTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolverTest.kt @@ -38,6 +38,7 @@ import io.element.android.libraries.matrix.test.A_REDACTION_REASON import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_ROOM_NAME import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.matrix.test.A_SPACE_NAME import io.element.android.libraries.matrix.test.A_TIMESTAMP import io.element.android.libraries.matrix.test.A_USER_ID_2 import io.element.android.libraries.matrix.test.A_USER_NAME_2 @@ -474,6 +475,45 @@ class DefaultNotifiableEventResolverTest { assertThat(result.getEvent(request)).isEqualTo(Result.success(expectedResult)) } + @Test + fun `resolve invite space`() = runTest { + val sut = createDefaultNotifiableEventResolver( + notificationResult = Result.success( + mapOf( + AN_EVENT_ID to Result.success(aNotificationData( + content = NotificationContent.Invite( + senderId = A_USER_ID_2, + ), + roomDisplayName = A_SPACE_NAME, + isDirect = false, + isSpace = true, + )) + ) + ) + ) + val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, "firebase") + val result = sut.resolveEvents(A_SESSION_ID, listOf(request)) + val expectedResult = ResolvedPushEvent.Event( + InviteNotifiableEvent( + sessionId = A_SESSION_ID, + roomId = A_ROOM_ID, + eventId = AN_EVENT_ID, + editedEventId = null, + canBeReplaced = true, + roomName = A_SPACE_NAME, + noisy = false, + title = null, + description = "Bob invited you to join the space", + type = null, + timestamp = A_TIMESTAMP, + soundName = null, + isRedacted = false, + isUpdated = false, + ) + ) + assertThat(result.getEvent(request)).isEqualTo(Result.success(expectedResult)) + } + @Test fun `resolve invite direct`() = runTest { val sut = createDefaultNotifiableEventResolver( diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandlerTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandlerTest.kt index 89b8c0d3199..7c950349857 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandlerTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandlerTest.kt @@ -47,7 +47,7 @@ import io.element.android.libraries.push.impl.notifications.model.NotifiableEven import io.element.android.libraries.push.impl.notifications.model.ResolvedPushEvent import io.element.android.libraries.push.impl.test.DefaultTestPush import io.element.android.libraries.push.impl.troubleshoot.DiagnosticPushHandler -import io.element.android.libraries.push.impl.workmanager.WorkerDataConverter +import io.element.android.libraries.push.impl.workmanager.SyncNotificationsWorkerDataConverter import io.element.android.libraries.pushproviders.api.PushData import io.element.android.libraries.pushstore.api.UserPushStore import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecret @@ -718,7 +718,7 @@ class DefaultPushHandlerTest { appCoroutineScope = backgroundScope, workManagerScheduler = workManagerScheduler, featureFlagService = featureFlagService, - workerDataConverter = WorkerDataConverter(DefaultJsonProvider()), + workerDataConverter = SyncNotificationsWorkerDataConverter(DefaultJsonProvider()), buildVersionSdkIntProvider = FakeBuildVersionSdkIntProvider(33), ), appCoroutineScope = backgroundScope, diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/workmanager/FetchNotificationWorkerTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/workmanager/FetchNotificationWorkerTest.kt index 23a38db66c1..d40ef17b53e 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/workmanager/FetchNotificationWorkerTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/workmanager/FetchNotificationWorkerTest.kt @@ -177,7 +177,7 @@ class FetchNotificationWorkerTest { workManagerScheduler = workManagerScheduler, syncOnNotifiableEvent = syncOnNotifiableEvent, coroutineDispatchers = testCoroutineDispatchers(), - workerDataConverter = WorkerDataConverter(DefaultJsonProvider()), + workerDataConverter = SyncNotificationsWorkerDataConverter(DefaultJsonProvider()), buildVersionSdkIntProvider = FakeBuildVersionSdkIntProvider(33), ) diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/workmanager/SyncNotificationWorkManagerRequestTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/workmanager/SyncNotificationWorkManagerRequestTest.kt index 9dae435a9a3..1f8d646e2b0 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/workmanager/SyncNotificationWorkManagerRequestTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/workmanager/SyncNotificationWorkManagerRequestTest.kt @@ -78,7 +78,7 @@ class SyncNotificationWorkManagerRequestTest { val request = createSyncNotificationWorkManagerRequest( sessionId = A_SESSION_ID, notificationEventRequests = listOf(aNotificationEventRequest()), - workerDataConverter = WorkerDataConverter({ error("error during serialization") }) + workerDataConverter = SyncNotificationsWorkerDataConverter({ error("error during serialization") }) ) val result = request.build() assertThat(result.isFailure).isTrue() @@ -88,7 +88,7 @@ class SyncNotificationWorkManagerRequestTest { private fun createSyncNotificationWorkManagerRequest( sessionId: SessionId, notificationEventRequests: List, - workerDataConverter: WorkerDataConverter = WorkerDataConverter(DefaultJsonProvider()), + workerDataConverter: SyncNotificationsWorkerDataConverter = SyncNotificationsWorkerDataConverter(DefaultJsonProvider()), sdkVersion: Int = 33, ) = SyncNotificationWorkManagerRequest( sessionId = sessionId, diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/workmanager/WorkerDataConverterTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/workmanager/WorkerDataConverterTest.kt index 6c6998cb35b..85b55e0d628 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/workmanager/WorkerDataConverterTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/workmanager/WorkerDataConverterTest.kt @@ -57,10 +57,10 @@ class WorkerDataConverterTest { providerInfo = "info$it", ) } - val sut = WorkerDataConverter(DefaultJsonProvider()) + val sut = SyncNotificationsWorkerDataConverter(DefaultJsonProvider()) val serialized = sut.serialize(data) assertThat(serialized.getOrNull()?.size).isGreaterThan(1) - assertThat(serialized.getOrNull()?.size).isEqualTo(100 / WorkerDataConverter.CHUNK_SIZE) + assertThat(serialized.getOrNull()?.size).isEqualTo(100 / SyncNotificationsWorkerDataConverter.CHUNK_SIZE) // All the items are present val deserialized = serialized.getOrNull()?.flatMap { sut.deserialize(it)!! } assertThat(deserialized).containsExactlyElementsIn(data) @@ -76,10 +76,10 @@ class WorkerDataConverterTest { providerInfo = "info$it", ) } - val sut = WorkerDataConverter(DefaultJsonProvider()) + val sut = SyncNotificationsWorkerDataConverter(DefaultJsonProvider()) val serialized = sut.serialize(data) assertThat(serialized.getOrNull()?.size).isGreaterThan(1) - assertThat(serialized.getOrNull()?.size).isEqualTo(100 / WorkerDataConverter.CHUNK_SIZE + 1) + assertThat(serialized.getOrNull()?.size).isEqualTo(100 / SyncNotificationsWorkerDataConverter.CHUNK_SIZE + 1) // All the items are present val deserialized = serialized.getOrNull()?.flatMap { sut.deserialize(it)!! } assertThat(deserialized).containsExactlyElementsIn(data) @@ -112,7 +112,7 @@ class WorkerDataConverterTest { ) } val data = (data1 + data2 + data3).shuffled() - val sut = WorkerDataConverter(DefaultJsonProvider()) + val sut = SyncNotificationsWorkerDataConverter(DefaultJsonProvider()) val serialized = sut.serialize(data) assertThat(serialized.getOrNull()?.size).isEqualTo(2) // All the items are present @@ -133,7 +133,7 @@ class WorkerDataConverterTest { } private fun testIdentity(data: List) { - val sut = WorkerDataConverter(DefaultJsonProvider()) + val sut = SyncNotificationsWorkerDataConverter(DefaultJsonProvider()) val serialized = sut.serialize(data).getOrThrow() val result = sut.deserialize(serialized.first()) assertThat(result).isEqualTo(data) diff --git a/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebaseStore.kt b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebaseStore.kt index 2d3f9dce396..1f6a4709ccf 100644 --- a/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebaseStore.kt +++ b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebaseStore.kt @@ -40,7 +40,7 @@ class SharedPreferencesFirebaseStore( if (k == PREFS_KEY_FCM_TOKEN) { try { flow.value = getFcmToken() - } catch (e: Exception) { + } catch (_: Exception) { flow.value = null } } diff --git a/libraries/pushproviders/firebase/src/main/res/values-hr/translations.xml b/libraries/pushproviders/firebase/src/main/res/values-hr/translations.xml new file mode 100644 index 00000000000..026949086d3 --- /dev/null +++ b/libraries/pushproviders/firebase/src/main/res/values-hr/translations.xml @@ -0,0 +1,11 @@ + + + "Provjerite je li Firebase dostupan." + "Firebase nije dostupan." + "Firebase je dostupan." + "Provjeri Firebase" + "Provjerite je li Firebaseov token dostupan." + "Firebaseov token nije poznat." + "Firebaseov token: %1$s." + "Provjeri Firebaseov token" + diff --git a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/VectorUnifiedPushMessagingReceiverBindings.kt b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/VectorUnifiedPushMessagingReceiverBindings.kt index da13aa93d1b..1d227b3b831 100644 --- a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/VectorUnifiedPushMessagingReceiverBindings.kt +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/VectorUnifiedPushMessagingReceiverBindings.kt @@ -9,14 +9,9 @@ package io.element.android.libraries.pushproviders.unifiedpush import dev.zacsweers.metro.AppScope -import dev.zacsweers.metro.Binds import dev.zacsweers.metro.ContributesTo -import org.unifiedpush.android.connector.MessagingReceiver @ContributesTo(AppScope::class) interface VectorUnifiedPushMessagingReceiverBindings { fun inject(receiver: VectorUnifiedPushMessagingReceiver) - - @Binds - fun bindsMessagingReceiver(vectorUnifiedPushMessagingReceiver: VectorUnifiedPushMessagingReceiver): MessagingReceiver } diff --git a/libraries/pushproviders/unifiedpush/src/main/res/values-hr/translations.xml b/libraries/pushproviders/unifiedpush/src/main/res/values-hr/translations.xml new file mode 100644 index 00000000000..a77fcce68c0 --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/main/res/values-hr/translations.xml @@ -0,0 +1,11 @@ + + + "Provjerite jesu li UnifiedPush distributeri dostupni." + "Nisu pronađeni distributeri push obavijesti." + + "Pronađen je %1$d distributer: %2$s." + "Pronađena su %1$d distributera: %2$s." + "Pronađeno je %1$d distributera: %2$s." + + "Provjeri UnifiedPush" + diff --git a/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/DefaultRegisterUnifiedPushUseCaseTest.kt b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/DefaultRegisterUnifiedPushUseCaseTest.kt index a2304bfd24c..1e77df79a25 100644 --- a/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/DefaultRegisterUnifiedPushUseCaseTest.kt +++ b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/DefaultRegisterUnifiedPushUseCaseTest.kt @@ -15,16 +15,23 @@ import io.element.android.libraries.matrix.test.A_SECRET import io.element.android.libraries.pushproviders.api.Distributor import io.element.android.libraries.pushproviders.unifiedpush.registration.EndpointRegistrationHandler import io.element.android.libraries.pushproviders.unifiedpush.registration.RegistrationResult +import io.element.android.tests.testutils.fake.FakeAndroidKeyStore import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest +import org.junit.Before import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner @RunWith(RobolectricTestRunner::class) class DefaultRegisterUnifiedPushUseCaseTest { + @Before + fun setup() { + FakeAndroidKeyStore.setup + } + @Test fun `test registration successful`() = runTest { val endpointRegistrationHandler = EndpointRegistrationHandler() diff --git a/libraries/qrcode/build.gradle.kts b/libraries/qrcode/build.gradle.kts index cbf4c2de784..cf76e117c0c 100644 --- a/libraries/qrcode/build.gradle.kts +++ b/libraries/qrcode/build.gradle.kts @@ -19,4 +19,5 @@ dependencies { implementation(libs.androidx.camera.view) implementation(libs.androidx.camera.camera2) implementation(libs.zxing.cpp) + implementation(libs.google.zxing) } diff --git a/libraries/qrcode/src/main/kotlin/io/element/android/libraries/qrcode/QRCodeAnalyzer.kt b/libraries/qrcode/src/main/kotlin/io/element/android/libraries/qrcode/QRCodeAnalyzer.kt index ab2ee2ce7b2..168e8989650 100644 --- a/libraries/qrcode/src/main/kotlin/io/element/android/libraries/qrcode/QRCodeAnalyzer.kt +++ b/libraries/qrcode/src/main/kotlin/io/element/android/libraries/qrcode/QRCodeAnalyzer.kt @@ -15,19 +15,24 @@ import timber.log.Timber import zxingcpp.BarcodeReader internal class QRCodeAnalyzer( - private val onScanQrCode: (result: ByteArray?) -> Unit + private val onScanQrCode: (data: ByteArray) -> Unit ) : ImageAnalysis.Analyzer { private val reader by lazy { BarcodeReader() } override fun analyze(image: ImageProxy) { - if (image.format in SUPPORTED_IMAGE_FORMATS) { - try { - val bytes = reader.read(image).firstNotNullOfOrNull { it.bytes } - bytes?.let { onScanQrCode(it) } - } catch (e: Exception) { - Timber.w(e, "Error decoding QR code") - } finally { - image.close() + image.use { + if (image.format in SUPPORTED_IMAGE_FORMATS) { + try { + val bytes = reader.read(image).firstNotNullOfOrNull { it.bytes } + if (bytes != null) { + Timber.d("QR code scanned!") + onScanQrCode(bytes) + } + } catch (e: Exception) { + Timber.w(e, "Error decoding QR code") + } + } else { + Timber.w("Unsupported image format: ${image.format}") } } } diff --git a/libraries/qrcode/src/main/kotlin/io/element/android/libraries/qrcode/QrCodeCameraView.kt b/libraries/qrcode/src/main/kotlin/io/element/android/libraries/qrcode/QrCodeCameraView.kt index a0d6613a3fe..18bd1cff100 100644 --- a/libraries/qrcode/src/main/kotlin/io/element/android/libraries/qrcode/QrCodeCameraView.kt +++ b/libraries/qrcode/src/main/kotlin/io/element/android/libraries/qrcode/QrCodeCameraView.kt @@ -31,6 +31,7 @@ import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.graphics.asImageBitmap import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.viewinterop.AndroidView import androidx.core.content.ContextCompat import androidx.lifecycle.compose.LocalLifecycleOwner @@ -45,99 +46,102 @@ import kotlin.coroutines.suspendCoroutine @Composable fun QrCodeCameraView( onScanQrCode: (ByteArray) -> Unit, - renderPreview: Boolean, + isScanning: Boolean, modifier: Modifier = Modifier, ) { - if (LocalInspectionMode.current) { - Box( - modifier = modifier - .background(color = ElementTheme.colors.bgSubtlePrimary), - contentAlignment = Alignment.Center, - ) { - Text("CameraView") - } - } else { - val coroutineScope = rememberCoroutineScope() - val localContext = LocalContext.current - val lifecycleOwner = LocalLifecycleOwner.current - var cameraProvider by remember { mutableStateOf(null) } - val previewUseCase = remember { Preview.Builder().build() } - var lastFrame by remember { mutableStateOf(null) } - val imageAnalysis = remember { - ImageAnalysis.Builder() - .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) - .build() - } + val coroutineScope = rememberCoroutineScope() + val localContext = LocalContext.current + val lifecycleOwner = LocalLifecycleOwner.current + var cameraProvider by remember { mutableStateOf(null) } + val previewUseCase = remember { Preview.Builder().build() } + var lastFrame by remember { mutableStateOf(null) } + val imageAnalysis = remember { + ImageAnalysis.Builder() + .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) + .build() + } - LaunchedEffect(Unit) { - cameraProvider = localContext.getCameraProvider() - } + LaunchedEffect(Unit) { + cameraProvider = localContext.getCameraProvider() + } - suspend fun startQRCodeAnalysis(cameraProvider: ProcessCameraProvider, previewView: PreviewView, attempt: Int = 1) { - lastFrame = null - val cameraSelector = CameraSelector.Builder() - .requireLensFacing(CameraSelector.LENS_FACING_BACK) - .build() - imageAnalysis.setAnalyzer( - ContextCompat.getMainExecutor(previewView.context), - QRCodeAnalyzer { result -> - result?.let { - Timber.d("QR code scanned!") - onScanQrCode(it) - } - } - ) - try { - // Make sure we unbind all use cases before binding them again - cameraProvider.unbindAll() + suspend fun startQRCodeAnalysis(cameraProvider: ProcessCameraProvider, attempt: Int = 1) { + lastFrame = null + val cameraSelector = CameraSelector.Builder() + .requireLensFacing(CameraSelector.LENS_FACING_BACK) + .build() + imageAnalysis.setAnalyzer( + ContextCompat.getMainExecutor(localContext), + QRCodeAnalyzer(onScanQrCode) + ) + try { + // Make sure we unbind all use cases before binding them again + cameraProvider.unbindAll() - cameraProvider.bindToLifecycle( - lifecycleOwner, - cameraSelector, - previewUseCase, - imageAnalysis - ) - lastFrame = null - } catch (e: Exception) { - val maxAttempts = 3 - if (attempt > maxAttempts) { - Timber.e(e, "Use case binding failed after $maxAttempts attempts. Giving up.") - } else { - Timber.e(e, "Use case binding failed (attempt #$attempt). Retrying after a delay...") - delay(100) - startQRCodeAnalysis(cameraProvider, previewView, attempt + 1) - } + cameraProvider.bindToLifecycle( + lifecycleOwner, + cameraSelector, + previewUseCase, + imageAnalysis, + ) + lastFrame = null + } catch (e: Exception) { + val maxAttempts = 3 + if (attempt > maxAttempts) { + Timber.e(e, "Use case binding failed after $maxAttempts attempts. Giving up.") + } else { + Timber.e(e, "Use case binding failed (attempt #$attempt). Retrying after a delay...") + delay(100) + startQRCodeAnalysis(cameraProvider, attempt + 1) } } + } - fun stopQRCodeAnalysis(previewView: PreviewView) { - // Stop analyzer - imageAnalysis.clearAnalyzer() - - // Save last frame to display it as the 'frozen' preview - if (lastFrame == null) { - lastFrame = previewView.bitmap - Timber.d("Saving last frame for frozen preview.") - } + fun stopQRCodeAnalysis(previewView: PreviewView) { + // Stop analyzer + imageAnalysis.clearAnalyzer() - // Unbind preview use case - cameraProvider?.unbindAll() + // Save last frame to display it as the 'frozen' preview + if (lastFrame == null) { + lastFrame = previewView.bitmap + Timber.d("Saving last frame for frozen preview.") } - Box(modifier.clipToBounds()) { + // Unbind preview use case + cameraProvider?.unbindAll() + } + + Box(modifier.clipToBounds()) { + if (LocalInspectionMode.current) { + Box( + modifier = modifier + .background(color = ElementTheme.colors.bgSubtlePrimary), + contentAlignment = Alignment.Center, + ) { + Text( + text = buildString { + append("CameraView\n") + append(if (isScanning) "scanning" else "frozen") + }, + textAlign = TextAlign.Center, + ) + } + } else { AndroidView( factory = { context -> val previewView = PreviewView(context) - previewUseCase.setSurfaceProvider(previewView.surfaceProvider) + previewUseCase.surfaceProvider = previewView.surfaceProvider previewView.previewStreamState.observe(lifecycleOwner) { state -> previewView.alpha = if (state == PreviewView.StreamState.STREAMING) 1f else 0f } previewView }, update = { previewView -> - if (renderPreview) { + if (isScanning) { cameraProvider?.let { provider -> - coroutineScope.launch { startQRCodeAnalysis(provider, previewView) } + coroutineScope.launch { + startQRCodeAnalysis(provider) + } } } else { stopQRCodeAnalysis(previewView) @@ -148,19 +152,21 @@ fun QrCodeCameraView( cameraProvider = null }, ) - lastFrame?.let { - Image(bitmap = it.asImageBitmap(), contentDescription = null) - } + } + lastFrame?.let { + Image(bitmap = it.asImageBitmap(), contentDescription = null) } } } -@Suppress("BlockingMethodInNonBlockingContext") private suspend fun Context.getCameraProvider(): ProcessCameraProvider = suspendCoroutine { continuation -> ProcessCameraProvider.getInstance(this).also { cameraProvider -> - cameraProvider.addListener({ - continuation.resume(cameraProvider.get()) - }, ContextCompat.getMainExecutor(this)) + cameraProvider.addListener( + { + continuation.resume(cameraProvider.get()) + }, + ContextCompat.getMainExecutor(this), + ) } } diff --git a/libraries/qrcode/src/main/kotlin/io/element/android/libraries/qrcode/QrCodeImage.kt b/libraries/qrcode/src/main/kotlin/io/element/android/libraries/qrcode/QrCodeImage.kt new file mode 100644 index 00000000000..e045e42f17b --- /dev/null +++ b/libraries/qrcode/src/main/kotlin/io/element/android/libraries/qrcode/QrCodeImage.kt @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2025 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.qrcode + +import android.graphics.Bitmap +import android.graphics.Color +import androidx.annotation.ColorInt +import androidx.compose.foundation.Image +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxHeight +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.graphics.asImageBitmap +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.IntSize +import com.google.zxing.BarcodeFormat +import com.google.zxing.common.BitMatrix +import com.google.zxing.qrcode.QRCodeWriter +import io.element.android.libraries.designsystem.modifiers.squareSize +import io.element.android.libraries.designsystem.utils.ForceMaxBrightness + +private fun String.toBitMatrix(size: Int): BitMatrix { + return QRCodeWriter().encode( + this, + BarcodeFormat.QR_CODE, + size, + size, + ) +} + +private fun BitMatrix.toBitmap( + @ColorInt backgroundColor: Int = Color.WHITE, + @ColorInt foregroundColor: Int = Color.BLACK, +): Bitmap { + val colorBuffer = IntArray(width * height) + var rowOffset = 0 + for (y in 0 until height) { + for (x in 0 until width) { + val arrayIndex = x + rowOffset + colorBuffer[arrayIndex] = if (get(x, y)) foregroundColor else backgroundColor + } + rowOffset += width + } + return Bitmap.createBitmap(colorBuffer, width, height, Bitmap.Config.ARGB_8888) +} + +@Composable +fun QrCodeImage( + data: String, + forceMaxBrightness: Boolean = true, + modifier: Modifier = Modifier, +) { + if (forceMaxBrightness) { + ForceMaxBrightness() + } + var size by remember { mutableStateOf(IntSize.Zero) } + Box( + modifier = modifier + .squareSize() + .onSizeChanged { + size = it + }, + ) { + val image = remember(data, size) { + val sideSide = maxOf(size.width, size.height).coerceAtLeast(128) + data.toBitMatrix(sideSide).toBitmap().asImageBitmap() + } + Image( + contentDescription = null, + bitmap = image, + ) + } +} + +@Composable +@Preview +internal fun QrCodeViewPreview() { + QrCodeImage( + modifier = Modifier.fillMaxHeight(), + data = "RANDOM_QRCODE_DATA", + ) +} diff --git a/libraries/rustsdk/matrix-rust-sdk.aar b/libraries/rustsdk/matrix-rust-sdk.aar index e7ae97c55a7..bdb67f0adb7 100644 --- a/libraries/rustsdk/matrix-rust-sdk.aar +++ b/libraries/rustsdk/matrix-rust-sdk.aar @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f8ddb13d3a817a0705d232211801c679bec096b353568ee78163b5b82195b8a4 -size 129979529 +oid sha256:831b374c6ac6b6e9bfaa3a234e2fede587f1126a960bdbe67d1447830dffbf11 +size 130306979 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 6cab993da1a..05e58ac623d 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 @@ -81,8 +81,6 @@ class InMemorySessionStore( } override suspend fun removeSession(sessionId: String) { - val currentList = sessionDataListFlow.value.toMutableList() - currentList.removeAll { it.userId == sessionId } - sessionDataListFlow.value = currentList + sessionDataListFlow.value = sessionDataListFlow.value.filter { it.userId != sessionId } } } diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt index f2070c96101..5f3e9ec54be 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt @@ -27,9 +27,9 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.requiredHeightIn import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.ReadOnlyComposable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -39,9 +39,10 @@ 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.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.platform.LocalView import androidx.compose.ui.res.stringResource -import androidx.compose.ui.semantics.SemanticsPropertyReceiver import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.hideFromAccessibility @@ -61,6 +62,7 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator import io.element.android.libraries.designsystem.theme.components.HorizontalDivider 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.IconColorButton import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.matrix.api.core.EventId @@ -70,11 +72,11 @@ import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetails import io.element.android.libraries.matrix.ui.messages.reply.InReplyToDetailsProvider import io.element.android.libraries.testtags.TestTags import io.element.android.libraries.testtags.testTag -import io.element.android.libraries.textcomposer.components.SendButton +import io.element.android.libraries.textcomposer.components.SendButtonIcon import io.element.android.libraries.textcomposer.components.TextFormatting -import io.element.android.libraries.textcomposer.components.VoiceMessageDeleteButton +import io.element.android.libraries.textcomposer.components.VoiceMessageDeleteButtonIcon import io.element.android.libraries.textcomposer.components.VoiceMessagePreview -import io.element.android.libraries.textcomposer.components.VoiceMessageRecorderButton +import io.element.android.libraries.textcomposer.components.VoiceMessageRecorderButtonIcon import io.element.android.libraries.textcomposer.components.VoiceMessageRecording import io.element.android.libraries.textcomposer.components.markdown.MarkdownTextInput import io.element.android.libraries.textcomposer.components.textInputRoundedCornerShape @@ -123,9 +125,6 @@ fun TextComposer( is TextEditorState.Markdown -> state.state.text.value() is TextEditorState.Rich -> state.richTextEditorState.messageMarkdown } - val onSendClick = { - onSendMessage() - } val onPlayVoiceMessageClick = { onVoicePlayerEvent(VoiceMessagePlayerEvent.Play) @@ -143,26 +142,6 @@ fun TextComposer( .fillMaxSize() .height(IntrinsicSize.Min) - val composerOptionsButton: @Composable () -> Unit = remember(composerMode) { - @Composable { - when (composerMode) { - is MessageComposerMode.Attachment -> { - Spacer(modifier = Modifier.width(9.dp)) - } - is MessageComposerMode.EditCaption -> { - Spacer(modifier = Modifier.width(16.dp)) - } - else -> { - IconColorButton( - onClick = onAddAttachment, - imageVector = CompoundIcons.Plus(), - contentDescription = stringResource(R.string.rich_text_editor_a11y_add_attachment), - ) - } - } - } - } - val placeholder = if (composerMode.inThread) { stringResource(id = CommonStrings.action_reply_in_thread) } else if (composerMode is MessageComposerMode.Attachment || composerMode is MessageComposerMode.EditCaption) { @@ -234,55 +213,137 @@ fun TextComposer( } } - val canSendMessage = markdown.isNotBlank() || composerMode is MessageComposerMode.Attachment - val sendButton = @Composable { - SendButton( - canSendMessage = canSendMessage, - onClick = onSendClick, - composerMode = composerMode, - ) - } - val recordVoiceButton = @Composable { - VoiceMessageRecorderButton( - isRecording = voiceMessageState is VoiceMessageState.Recording, - onEvent = onVoiceRecorderEvent, - ) - } - val sendVoiceButton = @Composable { - SendButton( - canSendMessage = voiceMessageState is VoiceMessageState.Preview, - onClick = onSendVoiceMessage, - composerMode = composerMode, - ) - } - val uploadVoiceProgress = @Composable { - CircularProgressIndicator( - modifier = Modifier.size(24.dp), - ) - } + val canSendTextMessage = markdown.isNotBlank() || composerMode is MessageComposerMode.Attachment val textFormattingOptions: @Composable (() -> Unit)? = (state as? TextEditorState.Rich)?.let { @Composable { TextFormatting(state = it.richTextEditorState) } } - val sendOrRecordButton = when { - !canSendMessage -> - when (voiceMessageState) { - VoiceMessageState.Idle, - is VoiceMessageState.Recording -> recordVoiceButton - is VoiceMessageState.Preview -> when (voiceMessageState.isSending) { - true -> uploadVoiceProgress - false -> sendVoiceButton + val hapticFeedback = LocalHapticFeedback.current + + fun performHapticFeedback() { + hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) + } + + @Composable + fun rememberEndButtonParams() = remember( + composerMode.isEditing, + voiceMessageState.endButtonKey(), + canSendTextMessage, + ) { + when { + !canSendTextMessage -> + when (voiceMessageState) { + VoiceMessageState.Idle -> EndButtonParams( + endButtonContentDescriptionResId = CommonStrings.a11y_voice_message_record, + endButtonClick = { + performHapticFeedback() + onVoiceRecorderEvent.invoke(VoiceMessageRecorderEvent.Start) + }, + endButtonContent = @Composable { + VoiceMessageRecorderButtonIcon( + isRecording = false, + ) + } + ) + is VoiceMessageState.Recording -> EndButtonParams( + endButtonContentDescriptionResId = CommonStrings.a11y_voice_message_stop_recording, + endButtonClick = { + performHapticFeedback() + onVoiceRecorderEvent.invoke(VoiceMessageRecorderEvent.Stop) + }, + endButtonContent = @Composable { + VoiceMessageRecorderButtonIcon( + isRecording = true, + ) + } + ) + is VoiceMessageState.Preview -> if (voiceMessageState.isSending) { + EndButtonParams( + endButtonContentDescriptionResId = CommonStrings.common_sending, + endButtonClick = {}, + endButtonContent = @Composable { + CircularProgressIndicator( + modifier = Modifier.size(24.dp), + ) + } + ) + } else { + EndButtonParams( + endButtonContentDescriptionResId = CommonStrings.action_send_voice_message, + endButtonClick = { + onSendVoiceMessage() + }, + endButtonContent = @Composable { + SendButtonIcon( + canSendMessage = true, + isEditing = composerMode.isEditing, + ) + }, + ) + } } - } - else -> sendButton + composerMode.isEditing -> EndButtonParams( + endButtonContentDescriptionResId = CommonStrings.action_send_edited_message, + endButtonClick = { + onSendMessage() + }, + endButtonContent = @Composable { + SendButtonIcon( + canSendMessage = true, + isEditing = true, + ) + }, + ) + else -> EndButtonParams( + endButtonContentDescriptionResId = CommonStrings.action_send_message, + endButtonClick = { + onSendMessage() + }, + endButtonContent = @Composable { + SendButtonIcon( + canSendMessage = true, + isEditing = false, + ) + }, + ) + } } - val endButtonA11y = endButtonA11y( - composerMode = composerMode, - voiceMessageState = voiceMessageState, - canSendMessage = canSendMessage, - ) + @Composable + fun rememberEndButtonParamsFormatting() = remember(composerMode.isEditing, canSendTextMessage) { + if (composerMode.isEditing) { + EndButtonParams( + endButtonContentDescriptionResId = CommonStrings.action_send_edited_message, + endButtonClick = { + if (canSendTextMessage) { + onSendMessage() + } + }, + endButtonContent = @Composable { + SendButtonIcon( + canSendMessage = canSendTextMessage, + isEditing = true, + ) + }, + ) + } else { + EndButtonParams( + endButtonContentDescriptionResId = CommonStrings.action_send_message, + endButtonClick = { + if (canSendTextMessage) { + onSendMessage() + } + }, + endButtonContent = @Composable { + SendButtonIcon( + canSendMessage = canSendTextMessage, + isEditing = false, + ) + }, + ) + } + } val voiceRecording = @Composable { when (voiceMessageState) { @@ -307,17 +368,8 @@ fun TextComposer( } } - val voiceDeleteButton = @Composable { - when (voiceMessageState) { - is VoiceMessageState.Preview -> - VoiceMessageDeleteButton(enabled = !voiceMessageState.isSending, onClick = onDeleteVoiceMessage) - is VoiceMessageState.Recording -> - VoiceMessageDeleteButton(enabled = true, onClick = { onVoiceRecorderEvent(VoiceMessageRecorderEvent.Cancel) }) - else -> {} - } - } - if (showTextFormatting && textFormattingOptions != null) { + val endButtonParams = rememberEndButtonParamsFormatting() TextFormattingLayout( modifier = layoutModifier, isRoomEncrypted = state.isRoomEncrypted, @@ -330,20 +382,21 @@ fun TextComposer( ) }, textFormatting = textFormattingOptions, - endButtonA11y = endButtonA11y, - sendButton = sendButton, + endButtonParams = endButtonParams, ) } else { + val endButtonParams = rememberEndButtonParams() StandardLayout( + composerMode = composerMode, voiceMessageState = voiceMessageState, isRoomEncrypted = state.isRoomEncrypted, modifier = layoutModifier, - composerOptionsButton = composerOptionsButton, textInput = textInput, - endButton = sendOrRecordButton, - endButtonA11y = endButtonA11y, + endButtonParams = endButtonParams, voiceRecording = voiceRecording, - voiceDeleteButton = voiceDeleteButton, + onAddAttachment = onAddAttachment, + onDeleteVoiceMessage = onDeleteVoiceMessage, + onVoiceRecorderEvent = onVoiceRecorderEvent, ) } @@ -367,49 +420,23 @@ fun TextComposer( } } -@ReadOnlyComposable -@Composable -private fun endButtonA11y( - composerMode: MessageComposerMode, - voiceMessageState: VoiceMessageState, - canSendMessage: Boolean, -): (SemanticsPropertyReceiver) -> Unit { - val a11ySendButtonDescription = stringResource( - id = when { - !canSendMessage -> - when (voiceMessageState) { - VoiceMessageState.Idle, - is VoiceMessageState.Recording -> if (voiceMessageState is VoiceMessageState.Recording) { - CommonStrings.a11y_voice_message_stop_recording - } else { - CommonStrings.a11y_voice_message_record - } - is VoiceMessageState.Preview -> when (voiceMessageState.isSending) { - true -> CommonStrings.common_sending - false -> CommonStrings.action_send_voice_message - } - } - composerMode.isEditing -> CommonStrings.action_send_edited_message - else -> CommonStrings.action_send_message - } - ) - val endButtonA11y: (SemanticsPropertyReceiver.() -> Unit) = { - contentDescription = a11ySendButtonDescription - onClick(null, null) - } - return endButtonA11y -} +private data class EndButtonParams( + val endButtonContentDescriptionResId: Int, + val endButtonClick: () -> Unit, + val endButtonContent: @Composable () -> Unit, +) @Composable private fun StandardLayout( + composerMode: MessageComposerMode, voiceMessageState: VoiceMessageState, isRoomEncrypted: Boolean?, textInput: @Composable () -> Unit, - composerOptionsButton: @Composable () -> Unit, voiceRecording: @Composable () -> Unit, - voiceDeleteButton: @Composable () -> Unit, - endButton: @Composable () -> Unit, - endButtonA11y: (SemanticsPropertyReceiver.() -> Unit), + endButtonParams: EndButtonParams, + onAddAttachment: () -> Unit, + onDeleteVoiceMessage: () -> Unit, + onVoiceRecorderEvent: (VoiceMessageRecorderEvent) -> Unit, modifier: Modifier = Modifier, ) { Column(modifier = modifier) { @@ -419,50 +446,80 @@ private fun StandardLayout( Spacer(Modifier.height(4.dp)) } Row(verticalAlignment = Alignment.Bottom) { - if (voiceMessageState !is VoiceMessageState.Idle) { - if (voiceMessageState is VoiceMessageState.Preview || voiceMessageState is VoiceMessageState.Recording) { - Box( + when (composerMode) { + is MessageComposerMode.Attachment -> { + Spacer(modifier = Modifier.width(12.dp)) + } + is MessageComposerMode.EditCaption -> { + Spacer(modifier = Modifier.width(19.dp)) + } + else -> { + val endPadding = if (voiceMessageState is VoiceMessageState.Idle) 0.dp else 3.dp + // To avoid loosing keyboard focus, the IconButton has to be defined here and has to be always enabled. + IconButton( modifier = Modifier - .padding(bottom = 5.dp, top = 5.dp, end = 3.dp, start = 3.dp) + .padding(top = 5.dp, bottom = 5.dp, start = 3.dp, end = endPadding) .size(48.dp), - contentAlignment = Alignment.Center, + onClick = { + if (voiceMessageState is VoiceMessageState.Idle) { + onAddAttachment() + } else { + when (voiceMessageState) { + is VoiceMessageState.Preview -> if (!voiceMessageState.isSending) { + onDeleteVoiceMessage() + } + is VoiceMessageState.Recording -> + onVoiceRecorderEvent(VoiceMessageRecorderEvent.Cancel) + } + } + }, ) { - voiceDeleteButton() + if (voiceMessageState is VoiceMessageState.Idle) { + Icon( + modifier = Modifier + .clip(CircleShape) + .size(30.dp) + .background(ElementTheme.colors.iconPrimary) + .padding(3.dp), + imageVector = CompoundIcons.Plus(), + contentDescription = stringResource(R.string.rich_text_editor_a11y_add_attachment), + tint = ElementTheme.colors.iconOnSolidPrimary + ) + } else { + when (voiceMessageState) { + is VoiceMessageState.Preview -> + VoiceMessageDeleteButtonIcon(enabled = !voiceMessageState.isSending) + is VoiceMessageState.Recording -> + VoiceMessageDeleteButtonIcon(enabled = true) + } + } } - } else { - Spacer(modifier = Modifier.width(16.dp)) - } - Box( - modifier = Modifier - .padding(bottom = 8.dp, top = 8.dp) - .weight(1f) - ) { - voiceRecording() - } - } else { - Box( - Modifier - .padding(bottom = 5.dp, top = 5.dp, start = 3.dp) - ) { - composerOptionsButton() } - Box( - modifier = Modifier - .padding(bottom = 8.dp, top = 8.dp) - .weight(1f) - ) { + } + Box( + modifier = Modifier + .padding(bottom = 8.dp, top = 8.dp) + .weight(1f) + ) { + if (voiceMessageState is VoiceMessageState.Idle) { textInput() + } else { + voiceRecording() } } - Box( - Modifier + // To avoid loosing keyboard focus, the IconButton has to be defined here and has to be always enabled. + val endButtonContentDescription = stringResource(endButtonParams.endButtonContentDescriptionResId) + IconButton( + modifier = Modifier .padding(bottom = 5.dp, top = 5.dp, end = 6.dp, start = 6.dp) .size(48.dp) - .clearAndSetSemantics(endButtonA11y), - contentAlignment = Alignment.Center, - ) { - endButton() - } + .clearAndSetSemantics { + contentDescription = endButtonContentDescription + onClick(null, null) + }, + onClick = endButtonParams.endButtonClick, + content = endButtonParams.endButtonContent, + ) } } } @@ -495,8 +552,7 @@ private fun TextFormattingLayout( textInput: @Composable () -> Unit, dismissTextFormattingButton: @Composable () -> Unit, textFormatting: @Composable () -> Unit, - sendButton: @Composable () -> Unit, - endButtonA11y: (SemanticsPropertyReceiver.() -> Unit), + endButtonParams: EndButtonParams, modifier: Modifier = Modifier ) { Column( @@ -527,16 +583,22 @@ private fun TextFormattingLayout( Box(modifier = Modifier.weight(1f)) { textFormatting() } - Box( + // To avoid loosing keyboard focus, the IconButton has to be defined here and has to be always enabled. + val endButtonContentDescription = stringResource(endButtonParams.endButtonContentDescriptionResId) + IconButton( modifier = Modifier .padding( start = 14.dp, end = 6.dp, ) - .clearAndSetSemantics(endButtonA11y) - ) { - sendButton() - } + .size(48.dp) + .clearAndSetSemantics { + contentDescription = endButtonContentDescription + onClick(null, null) + }, + onClick = endButtonParams.endButtonClick, + content = endButtonParams.endButtonContent, + ) } } } @@ -596,6 +658,12 @@ private fun TextInputBox( } } +private fun VoiceMessageState.endButtonKey() = when (this) { + is VoiceMessageState.Idle -> "Idle" + is VoiceMessageState.Preview -> "Preview_$isSending" + is VoiceMessageState.Recording -> "Recording" +} + private fun aTextEditorStateMarkdownList(isRoomEncrypted: Boolean? = null) = persistentListOf( aTextEditorStateMarkdown(initialText = "", initialFocus = true, isRoomEncrypted = isRoomEncrypted), aTextEditorStateMarkdown(initialText = "A message", initialFocus = true, isRoomEncrypted = isRoomEncrypted), diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/SendButton.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/SendButtonIcon.kt similarity index 59% rename from libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/SendButton.kt rename to libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/SendButtonIcon.kt index 1e8938f7d1e..d2d11c321b9 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/SendButton.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/SendButtonIcon.kt @@ -29,9 +29,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.IconButton -import io.element.android.libraries.matrix.api.core.EventId -import io.element.android.libraries.matrix.api.timeline.item.event.toEventOrTransactionId -import io.element.android.libraries.textcomposer.model.MessageComposerMode /** * Send button for the message composer. @@ -39,46 +36,42 @@ import io.element.android.libraries.textcomposer.model.MessageComposerMode * Temporary Figma : https://www.figma.com/design/Ni6Ii8YKtmXCKYNE90cC67/Timeline-(new)?node-id=2274-39944&m=dev */ @Composable -internal fun SendButton( +internal fun SendButtonIcon( canSendMessage: Boolean, - onClick: () -> Unit, - composerMode: MessageComposerMode, + isEditing: Boolean, modifier: Modifier = Modifier, ) { - IconButton( + val iconVector = when { + isEditing -> CompoundIcons.Check() + else -> CompoundIcons.SendSolid() + } + val iconStartPadding = when { + isEditing -> 0.dp + else -> 2.dp + } + Box( modifier = modifier - .size(48.dp), - onClick = onClick, - enabled = canSendMessage, + .clip(CircleShape) + .size(36.dp) + .buttonBackgroundModifier(canSendMessage) ) { - val iconVector = when { - composerMode.isEditing -> CompoundIcons.Check() - else -> CompoundIcons.SendSolid() - } - val iconStartPadding = when { - composerMode.isEditing -> 0.dp - else -> 2.dp - } - Box( + Icon( modifier = Modifier - .clip(CircleShape) - .size(36.dp) - .buttonBackgroundModifier(canSendMessage) - ) { - Icon( - modifier = Modifier - .padding(start = iconStartPadding) - .align(Alignment.Center), - imageVector = iconVector, - // Note: accessibility is managed in TextComposer. - contentDescription = null, - tint = if (canSendMessage) { - ElementTheme.iconOnSolidBlueTchap + .padding(start = iconStartPadding) + .align(Alignment.Center), + imageVector = iconVector, + // Note: accessibility is managed in TextComposer. + contentDescription = null, + tint = if (canSendMessage) { + if (ElementTheme.colors.isLight) { + ElementTheme.colors.iconOnSolidPrimary } else { - ElementTheme.colors.iconQuaternary + ElementTheme.colors.iconPrimary } - ) - } + } else { + ElementTheme.colors.iconQuaternary + } + ) } } @@ -109,13 +102,19 @@ private fun Modifier.buttonBackgroundModifier( @PreviewsDayNight @Composable -internal fun SendButtonPreview() = ElementPreview { - val normalMode = MessageComposerMode.Normal - val editMode = MessageComposerMode.Edit(EventId("\$id").toEventOrTransactionId(), "") +internal fun SendButtonIconPreview() = ElementPreview { Row { - SendButton(canSendMessage = true, onClick = {}, composerMode = normalMode) - SendButton(canSendMessage = false, onClick = {}, composerMode = normalMode) - SendButton(canSendMessage = true, onClick = {}, composerMode = editMode) - SendButton(canSendMessage = false, onClick = {}, composerMode = editMode) + IconButton(onClick = {}) { + SendButtonIcon(canSendMessage = true, isEditing = false) + } + IconButton(onClick = {}) { + SendButtonIcon(canSendMessage = false, isEditing = false) + } + IconButton(onClick = {}) { + SendButtonIcon(canSendMessage = true, isEditing = true) + } + IconButton(onClick = {}) { + SendButtonIcon(canSendMessage = false, isEditing = true) + } } } diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/VoiceMessageDeleteButton.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/VoiceMessageDeleteButtonIcon.kt similarity index 58% rename from libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/VoiceMessageDeleteButton.kt rename to libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/VoiceMessageDeleteButtonIcon.kt index af5f443cc7d..182a5d5a525 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/VoiceMessageDeleteButton.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/VoiceMessageDeleteButtonIcon.kt @@ -23,41 +23,35 @@ import io.element.android.libraries.designsystem.theme.components.IconButton import io.element.android.libraries.ui.strings.CommonStrings @Composable -fun VoiceMessageDeleteButton( +fun VoiceMessageDeleteButtonIcon( enabled: Boolean, - onClick: () -> Unit, modifier: Modifier = Modifier, ) { - IconButton( - modifier = modifier - .size(48.dp), - enabled = enabled, - onClick = onClick, - ) { - Icon( - modifier = Modifier.size(24.dp), - imageVector = CompoundIcons.Delete(), - contentDescription = stringResource(CommonStrings.a11y_delete), - tint = if (enabled) { - ElementTheme.colors.iconCriticalPrimary - } else { - ElementTheme.colors.iconDisabled - }, - ) - } + Icon( + modifier = modifier.size(24.dp), + imageVector = CompoundIcons.Delete(), + contentDescription = stringResource(CommonStrings.a11y_delete), + tint = if (enabled) { + ElementTheme.colors.iconCriticalPrimary + } else { + ElementTheme.colors.iconDisabled + }, + ) } @PreviewsDayNight @Composable -internal fun VoiceMessageDeleteButtonPreview() = ElementPreview { +internal fun VoiceMessageDeleteButtonIconPreview() = ElementPreview { Row { - VoiceMessageDeleteButton( - enabled = true, - onClick = {}, - ) - VoiceMessageDeleteButton( - enabled = false, - onClick = {}, - ) + IconButton(onClick = {}) { + VoiceMessageDeleteButtonIcon( + enabled = true, + ) + } + IconButton(onClick = {}) { + VoiceMessageDeleteButtonIcon( + enabled = false, + ) + } } } diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/VoiceMessagePreview.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/VoiceMessagePreview.kt index d8939798895..8ca90843a4d 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/VoiceMessagePreview.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/VoiceMessagePreview.kt @@ -67,22 +67,12 @@ internal fun VoiceMessagePreview( .heightIn(26.dp), verticalAlignment = Alignment.CenterVertically, ) { - if (isPlaying) { - PlayerButton( - type = PlayerButtonType.Pause, - onClick = onPauseClick, - enabled = isInteractive, - ) - } else { - PlayerButton( - type = PlayerButtonType.Play, - onClick = onPlayClick, - enabled = isInteractive - ) - } - + PlayerButton( + type = if (isPlaying) PlayerButtonType.Pause else PlayerButtonType.Play, + onClick = if (isPlaying) onPauseClick else onPlayClick, + enabled = isInteractive, + ) Spacer(modifier = Modifier.width(8.dp)) - Text( text = time.formatShort(), color = ElementTheme.colors.textSecondary, @@ -90,9 +80,7 @@ internal fun VoiceMessagePreview( maxLines = 1, overflow = TextOverflow.Ellipsis, ) - Spacer(modifier = Modifier.width(12.dp)) - WaveformPlaybackView( modifier = Modifier .weight(1f) diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/VoiceMessageRecorderButton.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/VoiceMessageRecorderButtonIcon.kt similarity index 53% rename from libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/VoiceMessageRecorderButton.kt rename to libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/VoiceMessageRecorderButtonIcon.kt index 3dd23cc99a0..797d8db9976 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/VoiceMessageRecorderButton.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/VoiceMessageRecorderButtonIcon.kt @@ -14,9 +14,8 @@ import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.size import androidx.compose.foundation.shape.CircleShape import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.hapticfeedback.HapticFeedbackType -import androidx.compose.ui.platform.LocalHapticFeedback import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme import io.element.android.compound.tokens.generated.CompoundIcons @@ -25,49 +24,25 @@ 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.utils.CommonDrawables -import io.element.android.libraries.textcomposer.model.VoiceMessageRecorderEvent @Composable -internal fun VoiceMessageRecorderButton( +internal fun VoiceMessageRecorderButtonIcon( isRecording: Boolean, - onEvent: (VoiceMessageRecorderEvent) -> Unit, modifier: Modifier = Modifier, ) { - val hapticFeedback = LocalHapticFeedback.current - - val performHapticFeedback = { - hapticFeedback.performHapticFeedback(HapticFeedbackType.LongPress) - } - if (isRecording) { - StopButton( - modifier = modifier, - onClick = { - performHapticFeedback() - onEvent(VoiceMessageRecorderEvent.Stop) - } - ) + StopButton(modifier) } else { - StartButton( - modifier = modifier, - onClick = { - performHapticFeedback() - onEvent(VoiceMessageRecorderEvent.Start) - } - ) + StartButton(modifier) } } @Composable private fun StartButton( - onClick: () -> Unit, modifier: Modifier = Modifier, -) = IconButton( - modifier = modifier.size(48.dp), - onClick = onClick, ) { Icon( - modifier = Modifier.size(24.dp), + modifier = modifier.size(24.dp), imageVector = CompoundIcons.MicOn(), // Note: accessibility is managed in TextComposer. contentDescription = null, @@ -77,41 +52,42 @@ private fun StartButton( @Composable private fun StopButton( - onClick: () -> Unit, modifier: Modifier = Modifier, -) = IconButton( - modifier = modifier - .size(48.dp), - onClick = onClick, ) { Box( - Modifier + modifier .size(36.dp) .background( color = ElementTheme.colors.bgActionPrimaryRest, shape = CircleShape, - ) - ) - Icon( - modifier = Modifier.size(24.dp), - resourceId = CommonDrawables.ic_stop, - // Note: accessibility is managed in TextComposer. - contentDescription = null, - tint = ElementTheme.iconOnSolidBlueTchap, - ) + ), + contentAlignment = Alignment.Center, + ) { + Icon( + modifier = Modifier.size(24.dp), + resourceId = CommonDrawables.ic_stop, + // Note: accessibility is managed in TextComposer. + contentDescription = null, + // TCHAP theme : color used when background is blue Tchap +// tint = ElementTheme.colors.iconOnSolidPrimary, + tint = ElementTheme.iconOnSolidBlueTchap, + ) + } } @PreviewsDayNight @Composable -internal fun VoiceMessageRecorderButtonPreview() = ElementPreview { +internal fun VoiceMessageRecorderButtonIconPreview() = ElementPreview { Row { - VoiceMessageRecorderButton( - isRecording = false, - onEvent = {}, - ) - VoiceMessageRecorderButton( - isRecording = true, - onEvent = {}, - ) + IconButton(onClick = {}) { + VoiceMessageRecorderButtonIcon( + isRecording = false, + ) + } + IconButton(onClick = {}) { + VoiceMessageRecorderButtonIcon( + isRecording = true, + ) + } } } diff --git a/libraries/textcomposer/impl/src/main/res/values-hr/translations.xml b/libraries/textcomposer/impl/src/main/res/values-hr/translations.xml new file mode 100644 index 00000000000..2b116627fbc --- /dev/null +++ b/libraries/textcomposer/impl/src/main/res/values-hr/translations.xml @@ -0,0 +1,33 @@ + + + "Dodaj privitak" + "Uključi/isključi popis s grafičkim oznakama" + "Otkaži i zatvori oblikovanje teksta" + "Uključi/isključi blok koda" + "Dodaj opis" + "Šifrirana poruka…" + "Poruka…" + "Nešifrirana poruka…" + "Izradi poveznicu" + "Uredi poveznicu" + "%1$s, stanje: %2$s" + "Primijeni podebljano" + "Primijeni kurziv" + "onemogućen" + "isključen" + "uključen" + "Primijeni precrtavanje" + "Primijeni podcrtavanje" + "Uključi/isključi prikaz na cijelom zaslonu" + "Uvlaka" + "Primijeni kod u retku" + "Postavi poveznicu" + "Uključi/isključi numerirani popis" + "Otvori mogućnosti sastavljanja" + "Uključi/isključi citat" + "Ukloni poveznicu" + "Poništi uvlaku" + "Poveznica" + "Opisi možda neće biti vidljivi osobama koji se služe starijim aplikacijama." + "Držite za snimanje" + diff --git a/libraries/troubleshoot/impl/src/main/res/values-hr/translations.xml b/libraries/troubleshoot/impl/src/main/res/values-hr/translations.xml new file mode 100644 index 00000000000..a39bf5d2adf --- /dev/null +++ b/libraries/troubleshoot/impl/src/main/res/values-hr/translations.xml @@ -0,0 +1,12 @@ + + + "Povijest push obavijesti" + "Pokreni testove" + "Ponovno pokreni testove" + "Neki testovi nisu uspjeli. Provjerite pojedinosti." + "Pokrenite testove kako biste otkrili bilo kakve probleme u konfiguraciji koji bi mogli uzrokovati da se obavijesti ne ponašaju kako se očekuje." + "Pokušaj popravka" + "Svi testovi uspješno su izvedeni." + "Rješavanje problema s obavijestima" + "Neki testovi zahtijevaju vašu pažnju. Provjerite pojedinosti." + diff --git a/libraries/ui-strings/src/main/res/values-be/translations.xml b/libraries/ui-strings/src/main/res/values-be/translations.xml index 247dc9f0260..d2a1f4dea77 100644 --- a/libraries/ui-strings/src/main/res/values-be/translations.xml +++ b/libraries/ui-strings/src/main/res/values-be/translations.xml @@ -117,6 +117,7 @@ "Прапусціць" "Пачаць" "Пачаць чат" + "Пачаць спачатку" "Пачаць праверку" "Націсніце, каб загрузіць карту" "Зрабіць фота" @@ -132,6 +133,7 @@ "Палітыка дапушчальнага выкарыстання" "Пашыраныя налады" "Аналітыка" + "Вы выйшлі з пакоя" "Знешні выгляд" "Аўдыя" "Заблакіраваныя карыстальнікі" @@ -272,6 +274,7 @@ "Памылка" "Поспех" "Папярэджанне" + "У вас ёсць незахаваныя змены." "Вашы змены не былі захаваны. Вы ўпэўнены, што хочаце вярнуцца?" "Захаваць змены?" "Ваш хатні сервер неабходна абнавіць для падтрымкі Matrix Authentication Service і стварэння ўліковага запісу." diff --git a/libraries/ui-strings/src/main/res/values-bg/translations.xml b/libraries/ui-strings/src/main/res/values-bg/translations.xml index 5265110a652..d6e314044be 100644 --- a/libraries/ui-strings/src/main/res/values-bg/translations.xml +++ b/libraries/ui-strings/src/main/res/values-bg/translations.xml @@ -134,7 +134,7 @@ "Разширени настройки" "изображение" "Статистика" - "Напуснахте стаята" + "Вие напуснахте стаята" "Облик" "Аудио" "Блокирани потребители" @@ -333,7 +333,6 @@ "Споделяне на това местоположение" "%1$s пространство" "Пространства" - "Преглед на членовете" "Местоположение" "Версия: %1$s (%2$s)" "bg" 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 d4f8550f7a2..fafb59d4fb4 100644 --- a/libraries/ui-strings/src/main/res/values-cs/translations.xml +++ b/libraries/ui-strings/src/main/res/values-cs/translations.xml @@ -114,6 +114,7 @@ "Načíst více" "Spravovat účet" "Spravovat zařízení" + "Spravovat místnosti" "Zpráva" "Minimalizovat" "Další" @@ -158,10 +159,12 @@ "Přeskočit" "Začít" "Zahájit chat" + "Začít znovu" "Zahájit ověření" "Klepnutím načtete mapu" "Vyfotit" "Klepnutím zobrazíte možnosti" + "Přeložit" "Zkusit znovu" "Odepnout" "Zobrazit" @@ -236,6 +239,7 @@ Důvod: %1$s."
"Světlý" "Řádek zkopírován do schránky" "Odkaz zkopírován do schránky" + "Připojit nové zařízení" "Načítání…" "Načítání dalších…" @@ -250,10 +254,12 @@ Důvod: %1$s."
"Zpráva" "Akce zprávy" + "Zprávu se nepodařilo odeslat" "Zobrazení zpráv" "Zpráva byla odstraněna" "Moderní" "Ztlumit" + "Název" "%1$s (%2$s)" "Žádné výsledky" "Žádný název místnosti" @@ -329,6 +335,7 @@ Důvod: %1$s."
"Něco se nepovedlo" "Narazili jsme na problém. Zkuste to prosím znovu." "Prostor" + "O čem je tento prostor?" "%1$d prostor" "%1$d prostory" @@ -374,6 +381,7 @@ Důvod: %1$s."
"Čekání…" "Čekání na dešifrovací klíč" "Vy" + "Tato místnost byla nastavena tak, aby noví členové mohli číst historii. %1$s" "Identita uživatele %1$s se změnila. %2$s" "Identita uživatele %1$s %2$s se změnila. %3$s" "(%1$s)" @@ -394,6 +402,7 @@ Opravdu chcete pokračovat?"
"Chyba" "Úspěch" "Upozornění" + "Máte neuložené změny." "Vaše změny nebyly uloženy. Opravdu se chcete vrátit?" "Uložit změny?" "Maximální povolená velikost souboru je: %1$s" @@ -452,10 +461,6 @@ Opravdu chcete pokračovat?"
"Vaše zpráva nebyla odeslána, protože%1$s neověřil(a) všechna zařízení" "Jedno nebo více vašich zařízení není ověřeno. Zprávu můžete přesto odeslat, nebo ji můžete prozatím zrušit a zkusit to znovu později, až ověříte všechna svá zařízení." "Vaše zpráva nebyla odeslána, protože jste neověřili jedno nebo více zařízení" - "Změnit nastavení" - "Správa prostoru" - "Spravovat místnosti" - "Oprávnění" "Upravit správce nebo vlastníky" "Nahrání média se nezdařilo, zkuste to prosím znovu." "Nepodařilo se načíst údaje o uživateli" @@ -478,7 +483,6 @@ Opravdu chcete pokračovat?" "%1$s • %2$s" "%1$s prostor" "Prostory" - "Zobrazit členy" "Zpráva nebyla odeslána, protože ověřená identita uživatele %1$s se změnila." "Zpráva nebyla odeslána, protože%1$s neověřil(a) všechna zařízení." "Zpráva nebyla odeslána, protože jste neověřili jedno nebo více zařízení." diff --git a/libraries/ui-strings/src/main/res/values-cy/translations.xml b/libraries/ui-strings/src/main/res/values-cy/translations.xml index 11b46cb85c8..1aa932f8ed4 100644 --- a/libraries/ui-strings/src/main/res/values-cy/translations.xml +++ b/libraries/ui-strings/src/main/res/values-cy/translations.xml @@ -161,6 +161,7 @@ "Hepgor" "Cychwyn" "Dechrau sgwrs" + "Cychwyn eto" "Dechrau dilysu" "Tapio i lwytho map" "Cymryd llun" @@ -414,6 +415,7 @@ Ydych chi\'n siŵr eich bod am barhau?" "Gwall" "Llwyddiant" "Rhybudd" + "Mae gennych newidiadau heb eu cadw." "Dyw eich newidiadau heb gael eu cadw. Ydych chi\'n siŵr eich bod am fynd nôl?" "Cadw\'r newidiadau?" "Y maint ffeil mwyaf sy\'n cael ei ganiatáu yw: %1$s" diff --git a/libraries/ui-strings/src/main/res/values-da/translations.xml b/libraries/ui-strings/src/main/res/values-da/translations.xml index f8491d6f268..6dc36fac5ab 100644 --- a/libraries/ui-strings/src/main/res/values-da/translations.xml +++ b/libraries/ui-strings/src/main/res/values-da/translations.xml @@ -112,6 +112,7 @@ "Indlæs mere" "Administrer konto" "Administrer enheder" + "Administrer rum" "Besked" "Minimér" "Næste" @@ -156,6 +157,7 @@ "Spring over" "Start" "Start samtale" + "Begynd forfra" "Begynd verifikation" "Tryk for at indlæse kort" "Tag billede" @@ -387,6 +389,7 @@ Er du sikker på, at du vil fortsætte?" "Fejl" "Succes" "Advarsel" + "Du har ændringer, der ikke er gemt." "Dine ændringer er ikke blevet gemt. Er du sikker på, at du vil gå tilbage?" "Gem ændringer?" "Den maksimalt tilladte filstørrelse er: %1$s" @@ -466,7 +469,6 @@ Er du sikker på, at du vil fortsætte?" "%1$s•%2$s" "%1$s gruppe" "Grupper" - "Vis medlemmer" "Beskeden blev ikke sendt fordi %1$s s bekræftede identitet blev nulstillet." "Meddelelsen er ikke sendt, fordi %1$s ikke har bekræftet alle enheder." "Beskeden er ikke sendt, fordi du ikke har verificeret en eller flere af dine enheder." 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 f912fc4fc4d..60a77143d61 100644 --- a/libraries/ui-strings/src/main/res/values-de/translations.xml +++ b/libraries/ui-strings/src/main/res/values-de/translations.xml @@ -56,6 +56,7 @@ "Dein Avatar" "Akzeptieren" "Bildunterschrift hinzufügen" + "Bestehende Chats hinzufügen" "Zum Nachrichtenverlauf hinzufügen" "Zurück" "Anruf" @@ -75,6 +76,7 @@ "Text kopieren" "Erstellen" "Chat erstellen" + "Space erstellen" "Deaktivieren" "Nutzerkonto deaktivieren" "Ablehnen" @@ -95,6 +97,7 @@ "Passwort vergessen?" "Weiterleiten" "Zurück" + "Gehe zu Rollen & Berechtigungen" "Zu den Einstellungen" "Ignorieren" "Einladen" @@ -111,6 +114,7 @@ "Mehr laden…" "Konto verwalten" "Geräte verwalten" + "Chats und Gruppen konfigurieren" "Nachricht" "Minimieren" "Weiter" @@ -155,10 +159,12 @@ "Überspringen" "Start" "Chat starten" + "Neu beginnen" "Verifizierung starten" "Tippe, um die Karte zu laden" "Foto aufnehmen" "Für Optionen tippen" + "Übersetzen" "Erneut versuchen" "Lösen" "Ansicht" @@ -188,6 +194,7 @@ "In die Zwischenablage kopiert" "Copyright" "Chat wird erstellt…" + "Space wird angelegt…" "Anfrage abgebrochen" "Hat den Chat verlassen" "Space verlassen" @@ -233,6 +240,7 @@ Grund: %1$s." "Hell" "Zeile in die Zwischenablage kopiert" "Link in die Zwischenablage kopiert" + "Neues Gerät verknüpfen" "Laden…" "Mehr wird geladen…" @@ -245,10 +253,12 @@ Grund: %1$s." "Nachricht" "Nachrichtenaktionen" + "Nachricht konnte nicht gesendet werden" "Nachrichtenlayout" "Nachricht entfernt" "Modern" "Stumm" + "Name" "%1$s(%2$s)" "Keine Ergebnisse" "Kein Chat-Name" @@ -294,7 +304,7 @@ Grund: %1$s." "Rich-Text-Editor" "Chat" "Chat-Name" - "z.B. dein Projektname" + "z.B. Projektname" "%1$d Chat" "%1$d Chats" @@ -323,6 +333,7 @@ Grund: %1$s." "Es ist ein Fehler aufgetreten." "Wir haben ein Problem festgestellt. Bitte versuch es erneut." "Space" + "Worum geht es hier?" "%1$d Space" "%1$d Spaces" @@ -330,6 +341,7 @@ Grund: %1$s." "Chat wird gestartet…" "Sticker" "Erfolg" + "Empfohlen" "Vorschläge" "Synchronisieren" "System" @@ -337,7 +349,7 @@ Grund: %1$s." "Hinweise von Drittanbietern" "Thread" "Thema" - "Worum geht es in diesem Chat?" + "Worum geht is in diesem Chat?" "Entschlüsselung nicht möglich" "Von einem ungesicherten Gerät gesendet" "Du hast keinen Zugriff auf diese Nachricht." @@ -367,6 +379,8 @@ Grund: %1$s." "Warten…" "Warte auf diese Nachricht" "Du" + "%1$s (%2$s) hat diese Nachricht geteilt, weil du nicht im Chat warst, als sie verschickt wurde." + "Diese Gruppe wurde so konfiguriert, dass neue Mitglieder den vergangenen Nachrichtenverlauf lesen können. %1$s" "%1$s\'s Identität has sich geändert. %2$s" "%1$s\'s %2$s Identität hat sich geändert. %3$s" "(%1$s)" @@ -387,6 +401,7 @@ Möchtest du wirklich fortfahren?" "Fehler" "Erfolg" "Warnung" + "Du hast nicht gespeicherte Änderungen." "Deine Änderungen wurden nicht gespeichert. Bist du sicher, dass du zurückgehen willst?" "Änderungen speichern?" "Die maximal erlaubte Dateigröße ist: %1$s" @@ -462,11 +477,12 @@ Möchtest du wirklich fortfahren?" "In Google Maps öffnen" "In OpenStreetMap öffnen" "Diesen Standort teilen" + "Das Hinzufügen eines Chats hat keinen Einfluss auf die Beitrittsregeln. Um die Regeln zu ändern, gehe zu \"Raum Info\" und dann zu \"Datenschutz und Sicherheit\"" "Von dir erstellte oder beigetretene Spaces." "%1$s • %2$s" + "Erstelle einen Space, um Chats zu organisieren" "%1$s Space" "Spaces" - "Mitglieder anzeigen" "Nachricht nicht gesendet, weil sich die verifizierte Identität von %1$s geändert hat." "Die Nachricht wurde nicht gesendet, weil %1$s nicht alle Geräte verifiziert hat." "Die Nachricht wurde nicht gesendet, weil du eines oder mehrere deiner Geräte nicht verifiziert hast." 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 5085f404a1d..98d20a15991 100644 --- a/libraries/ui-strings/src/main/res/values-el/translations.xml +++ b/libraries/ui-strings/src/main/res/values-el/translations.xml @@ -130,6 +130,7 @@ "Παράλειψη" "Εκκίνηση" "Έναρξη συνομιλίας" + "Ξανά από την αρχή" "Έναρξη επαλήθευσης" "Πάτα για φόρτωση χάρτη" "Τράβηξε φωτογραφία" @@ -148,6 +149,7 @@ "Ρυθμίσεις για προχωρημένους" "μια εικόνα" "Στατιστικά στοιχεία" + "Αποχωρήσατε από την αίθουσα" "Εμφάνιση" "Ήχος" "Αποκλεισμένοι χρήστες" @@ -326,6 +328,7 @@ "Σφάλμα" "Επιτυχία" "Προειδοποίηση" + "Έχεις μη αποθηκευμένες αλλαγές." "Οι αλλαγές σου δεν έχουν αποθηκευτεί. Σίγουρα θες να πας πίσω;" "Αποθήκευση αλλαγών;" "Ο οικιακός διακομιστής σου πρέπει να αναβαθμιστεί για να υποστηρίζει το Matrix Authentication Service και τη δημιουργία λογαριασμού." 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 00d2674ed70..2fc2fb0889d 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 @@ -1,6 +1,8 @@ + "Minimize message text field" "Minimize" "Favorite" "Favorited" + "This room has been configured so that new members can read history. %1$s" diff --git a/libraries/ui-strings/src/main/res/values-es/translations.xml b/libraries/ui-strings/src/main/res/values-es/translations.xml index 4428e0b864d..740f96edd4e 100644 --- a/libraries/ui-strings/src/main/res/values-es/translations.xml +++ b/libraries/ui-strings/src/main/res/values-es/translations.xml @@ -128,6 +128,7 @@ "Saltar" "Comenzar" "Iniciar chat" + "Empezar de nuevo" "Iniciar la verificación" "Pulsa para cargar el mapa" "Hacer foto" @@ -145,6 +146,7 @@ "Añadiendo leyenda" "Ajustes avanzados" "Estadísticas" + "Saliste de la sala" "Apariencia" "Sonido" "Usuarios bloqueados" @@ -318,6 +320,7 @@ Motivo: %1$s." "Error" "Terminado" "Atención" + "Tienes cambios sin guardar." "Tus cambios no se han guardado. ¿Estás seguro de que quieres volver atrás?" "¿Guardar cambios?" "Tu servidor base debe actualizarse para admitir Matrix Authentication Service y la creación de cuentas." diff --git a/libraries/ui-strings/src/main/res/values-et/translations.xml b/libraries/ui-strings/src/main/res/values-et/translations.xml index 4b8c38be6cd..9bd2ef325c6 100644 --- a/libraries/ui-strings/src/main/res/values-et/translations.xml +++ b/libraries/ui-strings/src/main/res/values-et/translations.xml @@ -112,6 +112,7 @@ "Näita veel" "Halda kasutajakontot" "Halda seadmeid" + "Halda jututuba" "Saada sõnum" "Minimeeri" "Edasi" @@ -156,10 +157,12 @@ "Jäta vahele" "Alusta" "Alusta vestlust" + "Alusta uuesti" "Alusta verifitseerimist" "Kaardi laadimiseks klõpsa" "Tee pilt" "Valikuteks klõpsa" + "Tõlgi" "Proovi uuesti" "Eemalda esiletõstmine" "Vaata" @@ -177,7 +180,7 @@ "Täiendavad seadistused" "pilt" "Analüütika" - "Sa oled jututoast lahkunud" + "Sina lahkusid jututoast" "Sa olid sessioonist väljaloginud" "Välimus" "Heli" @@ -234,6 +237,7 @@ Põhjus: %1$s." "Hele" "Rida on kopeeritud lõikelauale" "Link on kopeeritud lõikelauale" + "Seo uus seade" "Laadime…" "Laadime veel…" @@ -246,10 +250,12 @@ Põhjus: %1$s." "Sõnum" "Tegevused sõnumiga" + "Sõnumi saatmine ei õnnestunud" "Sõnumi paigutus" "Sõnum on eemaldatud" "Kaasaegne" "Summutatud" + "Nimi" "%1$s (%2$s)" "Otsingul pole tulemusi" "Jututoal puudub nimi" @@ -324,6 +330,7 @@ Põhjus: %1$s." "Midagi läks valesti" "Tekkis viga. Palun proovi uuesti." "Kogukond" + "Mida selles kogukonnas tehakse?" "%1$d kogukond" "%1$d kogukonda" @@ -368,6 +375,7 @@ Põhjus: %1$s." "Ootame…" "Ootame selle sõnumi dekrüptimisvõtit" "Sina" + "See jututuba on seadistatud sedaviisi, et ka uued liikmed saavad lugeda varasemat ajalugu. %1$s" "Kasutaja %1$s võrguidentiteet on lähtestatud. %2$s" "Kasutaja %1$s %2$s võrguidentiteet on lähtestatud. %3$s" "(%1$s)" @@ -388,6 +396,7 @@ Kas sa oled kindel, et soovid jätkata?" "Viga" "Õnnestus" "Hoiatus" + "Sul on salvestamata muudatusi" "Sinu tehtud muudatused pole veel salvestatud. Kas sa oled kindel, et soovid minna tagasi?" "Kas salvestame muudatused?" "Suurim lubatud failisuurus on: %1$s" @@ -445,10 +454,6 @@ Kas sa oled kindel, et soovid jätkata?" "Sinu sõnum on saatmata, kuna %1$s pole verifitseerinud kõiki oma seadmeid" "Üks või enam sinu seadet on verifitseerimata. Sa võid sõnumi ikkagi ära saata või katkestad saatmise ning proovid uuesti, kui oled kõik oma seadmed verifitseerinud." "Kuna sul on üks või enam verifitseerimata seadet, siis sinu sõnum jäi saatmata" - "Muuda seadistusi" - "Halda kogukonda" - "Halda jututuba" - "Õigused" "Muuda peakasutajaid või omanikke" "Meediafaili töötlemine enne üleslaadimist ei õnnestunud. Palun proovi uuesti." "Kasutaja andmete laadimine ei õnnestunud" @@ -471,7 +476,6 @@ Kas sa oled kindel, et soovid jätkata?" "%1$s • %2$s" "Kogukond: %1$s" "Kogukonnad" - "Vaata liikmeid" "Sõnum on saatmata, kuna kasutaja %1$s verifitseeritud identiteet on lähtestatud." "Sõnum on saatmata, kuna %1$s pole verifitseerinud kõiki oma seadmeid." "Kuna sa pole üks või enamgi oma seadet verifitseerinud, siis sinu sõnum on saatmata." diff --git a/libraries/ui-strings/src/main/res/values-eu/translations.xml b/libraries/ui-strings/src/main/res/values-eu/translations.xml index 15bfa21fb5e..9a92bf6289e 100644 --- a/libraries/ui-strings/src/main/res/values-eu/translations.xml +++ b/libraries/ui-strings/src/main/res/values-eu/translations.xml @@ -137,6 +137,7 @@ "Saltatu" "Hasi" "Hasi txata" + "Hasi berriro" "Hasi egiaztapena" "Sakatu mapa kargatzeko" "Egin argazkia" @@ -155,6 +156,7 @@ "Ezarpen aurreratuak" "irudia" "Estatistikak" + "Gelatik atera zara" "Itxura" "Audioa" "Blokeatutako erabiltzaileak" @@ -336,6 +338,7 @@ Ziur jarraitu nahi duzula?" "Errorea" "Arrakasta" "Abisua" + "Gorde gabeko aldaketak dituzu." "Zure aldaketak ez dira gorde. Ziur itzuli nahi duzula?" "Aldaketak gorde?" "Hautatu bideoaren igoera-kalitatea" diff --git a/libraries/ui-strings/src/main/res/values-fa/translations.xml b/libraries/ui-strings/src/main/res/values-fa/translations.xml index 1656bc3b0c3..083f84ddc69 100644 --- a/libraries/ui-strings/src/main/res/values-fa/translations.xml +++ b/libraries/ui-strings/src/main/res/values-fa/translations.xml @@ -150,6 +150,7 @@ "پرش" "آغاز" "آغاز گپ" + "آغاز از نو" "آغاز تأیید" "زدن برای بار کردن نقشه" "عکس گرفتن" @@ -345,6 +346,7 @@ "خطا" "موفّقیت" "هشدار" + "تغییراتی ذخیره نشده دارید." "تغییراتتان ذخیره نشده‌اند. مطمئنید که می‌خواهید برگردید؟" "ذخیرهٔ تغییرات؟" "حست‌وجوی شکلک‌ها" @@ -398,7 +400,6 @@ "%1$s • %2$s" "‏%1$s فضا" "فضاها" - "دیدن اعضا" "مکان" "نگارش : %1$s (%2$s)" "fa" 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 bc23e78b05c..7d71dca9eff 100644 --- a/libraries/ui-strings/src/main/res/values-fi/translations.xml +++ b/libraries/ui-strings/src/main/res/values-fi/translations.xml @@ -112,6 +112,7 @@ "Lataa lisää" "Hallitse tiliä" "Hallitse laitteita" + "Huoneiden hallitseminen" "Lähetä viesti" "Pienennä" "Seuraava" @@ -156,6 +157,7 @@ "Ohita" "Aloita" "Aloita keskustelu" + "Aloita alusta" "Aloita vahvistus" "Lataa kartta napauttamalla" "Ota kuva" @@ -388,6 +390,7 @@ Haluatko varmasti jatkaa?" "Virhe" "Onnistui" "Varoitus" + "Sinulla on tallentamattomia muutoksia" "Muutoksiasi ei ole tallennettu. Haluatko varmasti palata takaisin?" "Tallennetaanko muutokset?" "Suurin sallittu tiedostokoko on: %1$s" @@ -445,10 +448,6 @@ Haluatko varmasti jatkaa?" "Viestiäsi ei lähetetty, koska %1$s ei ole vahvistanut kaikkia laitteitaan." "Yksi tai useampi laitteistasi on vahvistamaton. Voit lähettää viestin silti tai peruuttaa sen toistaiseksi ja yrittää uudelleen myöhemmin, kun olet vahvistanut kaikki laitteesi." "Viestiäsi ei lähetetty, koska et ole vahvistanut yhtä tai useampaa laitettasi." - "Asetusten muuttaminen" - "Tilan hallitseminen" - "Huoneiden hallitseminen" - "Oikeudet" "Muokkaa ylläpitäjiä tai omistajia" "Median käsittely epäonnistui, yritä uudelleen." "Käyttäjän tietojen hakeminen epäonnistui" @@ -471,7 +470,6 @@ Haluatko varmasti jatkaa?" "%1$s • %2$s" "%1$s tila" "Tilat" - "Näytä jäsenet" "Viestiä ei lähetetty, koska käyttäjän %1$s vahvistettu identiteetti nollattiin." "Viestiä ei lähetetty, koska %1$s ei ole vahvistanut kaikkia laitteitaan." "Viestiä ei lähetetty, koska et ole vahvistanut yhtä tai useampaa laitettasi." 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 af8eb51a997..2c9ac4b8320 100644 --- a/libraries/ui-strings/src/main/res/values-fr/translations.xml +++ b/libraries/ui-strings/src/main/res/values-fr/translations.xml @@ -56,6 +56,7 @@ "Votre avatar" "Accepter" "Ajouter une légende" + "Ajouter des salons existants" "Ajouter à la discussion" "Retour" "Appel" @@ -75,6 +76,7 @@ "Copier le texte" "Créer" "Créer un salon" + "Créer un espace" "Désactiver" "Désactiver le compte" "Refuser" @@ -112,6 +114,7 @@ "Voir plus" "Gérer le compte" "Gérez les sessions" + "Gérer les salons" "Message" "Minimiser" "Suivant" @@ -156,10 +159,12 @@ "Passer" "Démarrer" "Démarrer une discussion" + "Recommencer" "Commencer la vérification" "Cliquez pour charger la carte" "Prendre une photo" "Appuyez pour afficher les options" + "Traduire" "Essayer à nouveau" "Désépingler" "Voir" @@ -177,7 +182,7 @@ "Paramètres avancés" "une image" "Statistiques d’utilisation" - "Vous avez quitter le salon" + "Vous avez quitté le salon" "Vous avez été déconnecté de la session" "Apparence" "Audio" @@ -189,6 +194,7 @@ "Copié dans le presse-papiers" "Droits d’auteur" "Création du salon…" + "Création de l’espace…" "Demande annulée" "Vous avez quitté le salon" "Vous avez quitté l’espace" @@ -234,6 +240,7 @@ Raison : %1$s." "Clair" "Ligne copiée dans le presse-papiers" "Lien copié dans le presse-papiers" + "Associer un nouvel appareil" "Chargement…" "Chargement…" @@ -246,10 +253,12 @@ Raison : %1$s." "Message" "Actions sur le message" + "Échec de l’envoi du message" "Mode d’affichage des messages" "Message supprimé" "Moderne" "Mettre en sourdine" + "Nom" "%1$s (%2$s)" "Aucun résultat" "Salon sans nom" @@ -324,6 +333,7 @@ Raison : %1$s." "Une erreur s’est produite" "Nous avons rencontré un problème. Veuillez réessayer." "Espace" + "Quel est le sujet de cet espace ?" "%1$d Espace" "%1$d Espaces" @@ -331,6 +341,7 @@ Raison : %1$s." "Création de la discussion…" "Autocollant" "Succès" + "Recommandé" "Suggestions" "Synchronisation" "Système" @@ -368,6 +379,9 @@ Raison : %1$s." "En attente…" "En attente de la clé de déchiffrement" "Vous" + "%1$s (%2$s) a partagé ce message avec vous car vous n’étiez pas dans le salon lors de son envoi." + "%1$s a partagé ce message avec vous car vous n’étiez pas dans le salon lors de son envoi." + "Ce salon a été configuré pour que les nouveaux membres puissent lire l’historique. %1$s" "L’identité de %1$s a été réinitialisée. %2$s" "L’identité de %1$s %2$s a été réinitialisée. %3$s" "(%1$s)" @@ -388,6 +402,7 @@ Raison : %1$s." "Erreur" "Succès" "Attention" + "Vous avez des modifications non-enregistrées." "Vos modifications n’ont pas été enregistrées. Êtes-vous certain de vouloir quitter ?" "Enregistrer les changements ?" "La taille maximale de fichier autorisée est: %1$s" @@ -445,10 +460,6 @@ Raison : %1$s." "Votre message n’a pas été envoyé car %1$s n’a pas vérifié tous ses appareils" "Un ou plusieurs de vos appareils ne sont pas vérifiés. Vous pouvez quand même envoyer le message, ou vous pouvez annuler et réessayer plus tard après avoir vérifié tous vos appareils." "Votre message n’a pas été envoyé car vous n’avez pas vérifié tous vos appareils" - "Changer les paramètres" - "Gérer l’espace" - "Gérer les salons" - "Autorisations" "Modifier les administrateurs ou les propriétaires" "Échec du traitement des médias à télécharger, veuillez réessayer." "Impossible de récupérer les détails de l’utilisateur" @@ -469,9 +480,9 @@ Raison : %1$s." "Partager cette position" "Espaces que vous avez créés ou rejoints." "%1$s • %2$s" + "Créer des espaces pour organiser les salons" "Espace %1$s" "Espaces" - "Voir les membres" "Le message n’a pas été envoyé car l’identité vérifiée de %1$s a été réinitialisée." "Le message n’a pas été envoyé car %1$s n’a pas vérifié tous ses appareils." "Message non envoyé car vous n’avez pas vérifié tous vos appareils." diff --git a/libraries/ui-strings/src/main/res/values-hr/translations.xml b/libraries/ui-strings/src/main/res/values-hr/translations.xml new file mode 100644 index 00000000000..2d9676c36aa --- /dev/null +++ b/libraries/ui-strings/src/main/res/values-hr/translations.xml @@ -0,0 +1,499 @@ + + + "Dodaj reakciju: %1$s" + "Avatar" + "Minimiziraj tekstno polje poruke" + "Izbriši" + + "%1$d unesena znamenka" + "%1$d unesene znamenke" + "%1$d unesenih znamenaka" + + "Uredi avatar" + "Potpuna adresa bit će %1$s" + "Pojedinosti o šifriranju" + "Proširi tekstno polje poruke" + "Sakrij zaporku" + "Pridruži se pozivu" + "Idi na dno" + "Pomakni kartu na moju lokaciju" + "Samo spominjanja" + "Bez zvuka" + "Nova spominjanja" + "Nove poruke" + "Poziv u tijeku" + "Avatar drugog korisnika" + "Stranica %1$d" + "Pauziraj" + "Glasovna poruka, trajanje: %1$s, trenutačno zaustavljeno na: %2$s" + "Polje za PIN" + "Reproduciraj" + "Anketa" + "Završena anketa" + "Reagiraj s %1$s" + "Reagiraj s drugim emotikonima" + "Pročitali %1$s i %2$s" + + "Pročitao %1$s i ostalih %2$d" + "Pročitala %1$s i ostala %2$d" + "Pročitalo %1$s i ostalih %2$d" + + "Pročitao korisnik %1$s" + "Dodirnite za prikaz svih" + "Ukloni reakciju: %1$s" + "Ukloni reakciju s %1$s" + "Avatar sobe" + "Pošalji datoteke" + "Potrebna je vremenski ograničena radnja, imate jednu minutu za potvrdu" + "Prikaži zaporku" + "Započni poziv" + "Soba označena za uklanjanje" + "Korisnički avatar" + "Korisnički izbornik" + "Prikaži avatar" + "Prikaži pojedinosti" + "Glasovna poruka, trajanje: %1$s" + "Snimite glasovnu poruku." + "Zaustavi snimanje" + "Vaš avatar" + "Prihvati" + "Dodaj opis" + "Dodaj na vremensku traku" + "Natrag" + "Poziv" + "Odustani" + "Otkaži za sada" + "Odaberi fotografiju" + "Očisti" + "Zatvori" + "Dovrši provjeru" + "Potvrdi" + "Potvrdi zaporku" + "Nastavi" + "Kopiraj" + "Kopiraj opis" + "Kopiraj poveznicu" + "Kopiraj poveznicu u poruku" + "Kopiraj tekst" + "Stvori" + "Stvori sobu" + "Deaktiviraj" + "Deaktiviraj račun" + "Odbij" + "Odbij i blokiraj" + "Izbriši anketu" + "Poništi sve odabire" + "Onemogući" + "Odbaci" + "Odbaci" + "Gotovo" + "Uredi" + "Uredi opis" + "Uredi anketu" + "Omogući" + "Završi anketu" + "Unesite PIN" + "Završi" + "Zaboravili ste zaporku?" + "Proslijedi" + "Idi natrag" + "Idi na uloge i dopuštenja" + "Idi na postavke" + "Zanemari" + "Pozovi" + "Pozovi osobe" + "Pozovi osobe u %1$s" + "Pozovi osobe u %1$s" + "Pozivnice" + "Pridruži se" + "Saznajte više" + "Napusti" + "Napusti razgovor" + "Napusti sobu" + "Napusti prostor" + "Učitaj više" + "Upravljanje računom" + "Upravljanje uređajima" + "Upravljaj sobama" + "Poruka" + "Minimiziraj" + "Dalje" + "Ne" + "Ne sada" + "U redu" + "Otvori kontekstni izbornik" + "Postavke" + "Otvori s" + "Prikvači" + "Brzi odgovor" + "Citiraj" + "Reagiraj" + "Odbij" + "Ukloni" + "Ukloni opis" + "Ukloni poruku" + "Odgovori" + "Odgovori u niti" + "Prijavi" + "Prijavi grešku" + "Prijavi sadržaj" + "Prijavi razgovor" + "Prijavi sobu" + "Poništi" + "Poništi identitet" + "Pokušaj ponovno" + "Ponovno pokušaj dešifrirati" + "Spremi" + "Pretraži" + "Odaberi sve" + "Pošalji" + "Pošalji uređenu poruku" + "Pošalji poruku" + "Pošalji glasovnu poruku" + "Podijeli" + "Podijeli poveznicu" + "Prikaži" + "Ponovno se prijavite" + "Odjava" + "Svejedno se odjavi" + "Preskoči" + "Započni" + "Započni razgovor" + "Kreni ispočetka" + "Započni provjeru" + "Dodirnite za učitavanje karte" + "Uslikaj" + "Dodirnite za mogućnosti" + "Prevedi" + "Pokušajte ponovno" + "Otkvači" + "Prikaz" + "Prikaži na vremenskoj traci" + "Prikaži izvor" + "Da" + "Da, pokušaj ponovno" + "Vaš poslužitelj sada podržava novi, brži protokol. Odjavite se i ponovno prijavite da biste odmah izvršili nadogradnju. Ako to sada napravite, izbjeći ćete prisilnu odjavu kada se poslije ukloni stari protokol." + "Dostupna je nadogradnja" + "O aplikaciji" + "Pravilnik o prihvatljivoj upotrebi" + "Dodaj račun" + "Dodaj još jedan račun" + "Dodavanje opisa" + "Napredne postavke" + "slika" + "Analitika" + "Napustili ste sobu" + "Odjavljeni ste iz sesije" + "Izgled" + "Audiozapis" + "Beta" + "Blokirani korisnici" + "Mjehurići" + "Poziv je započeo" + "Sigurnosna kopija razgovora" + "Kopirano u međuspremnik" + "Autorsko pravo" + "Stvaranje sobe…" + "Zahtjev je otkazan" + "Napustio/la je sobu" + "Napušteni prostor" + "Poziv je odbijen" + "Tamno" + "Pogreška kod dešifriranja" + "Opis" + "Mogućnosti za razvojne inženjere" + "ID uređaja" + "Izravni razgovor" + "Ne prikazuj ovo ponovno" + "Preuzimanje nije uspjelo" + "Preuzimanje" + "(uređeno)" + "Uređivanje" + "Uređivanje opisa" + "* %1$s %2$s" + "Prazna datoteka" + "Šifriranje" + "Šifriranje je omogućeno" + "Unesite svoj PIN" + "Pogreška" + "Došlo je do pogreške; možda nećete primati obavijesti za nove poruke. Riješite problem s obavijestima u postavkama. + +Razlog: %1$s ." + "Svi" + "Nije uspjelo" + "Favorit" + "Označeno kao favorit" + "Datoteka" + "Datoteka je izbrisana" + "Datoteka je spremljena" + "Datoteka je spremljena u preuzimanja" + "Proslijedi poruku" + "Često korišteno" + "GIF" + "Slika" + "Kao odgovor osobi %1$s" + "Instaliraj APK" + "Ovaj Matrix ID nije moguće pronaći, pa se pozivnica možda neće primiti." + "Izlazak iz sobe" + "Napuštanje prostora" + "Svijetlo" + "Redak je kopiran u međuspremnik" + "Poveznica je kopirana u međuspremnik." + "Poveži novi uređaj" + "Učitavanje…" + "Učitava se još…" + + "još %d" + "još %d" + "još %d" + + + "%1$d član" + "%1$d člana" + "%1$d članova" + + "Poruka" + "Radnje s porukama" + "Slanje poruke nije uspjelo" + "Izgled poruke" + "Poruka je uklonjena" + "Suvremeni" + "Isključi zvuk" + "Naziv" + "%1$s (%2$s)" + "Nema rezultata" + "Nema naziva sobe" + "Nema naziva prostora" + "Nije šifrirano" + "Izvan mreže" + "Licencije otvorenog koda" + "ili" + "Zaporka" + "Osobe" + "Stalna poveznica" + "Dopuštenje" + "Prikvačeno" + "Provjerite svoju internetsku vezu" + "Pričekajte…" + "Jeste li sigurni da želite završiti ovu anketu?" + "Anketa: %1$s" + "Ukupno glasova: %1$s" + "Rezultati će se objaviti nakon što završi anketa" + + "%d glas" + "%d glasa" + "%d glasova" + + "Priprema…" + "Pravilnik o zaštiti privatnosti" + "Privatna soba" + "Privatni prostor" + "Javna soba" + "Javni prostor" + "Reakcija" + "Reakcije" + "Razlog" + "Ključ za oporavak" + "Osvježavanje…" + + "%1$d odgovor" + "%1$d odgovora" + "%1$d odgovora" + + "Odgovara korisniku %1$s" + "Prijavi pogrešku" + "Prijavi problem" + "Prijava je podnesena" + "Uređivač obogaćenog teksta" + "Soba" + "Naziv sobe" + "npr. naziv vašeg projekta" + + "%1$d soba" + "%1$d sobe" + "%1$d soba" + + "Spremljene promjene" + "Spremanje" + "Zaključavanje zaslona" + "Potraži nekoga" + "Rezultati pretraživanja" + "Sigurnost" + "Vidio/la" + "Odaberi račun" + "Pošalji" + "Slanje…" + "Slanje nije uspjelo" + "Poslano" + ". " + "Poslužitelj nije podržan" + "Poslužitelj nije dostupan" + "URL poslužitelja" + "Postavke" + "Podijeli prostor" + "Podijeljena lokacija" + "Zajednički prostor" + "Odjava je u tijeku" + "Nešto je pošlo po zlu" + "Naišli smo na problem. Pokušajte ponovno." + "Prostor" + "O čemu se radi u ovom prostoru?" + + "%1$d prostor" + "%1$d prostora" + "%1$d prostora" + + "Započinjanje razgovora…" + "Naljepnica" + "Uspjeh" + "Prijedlozi" + "Sinkronizacija" + "Sustav" + "Tekst" + "Obavijesti trećih strana" + "Nit" + "Tema" + "O čemu je ova soba?" + "Nije moguće dešifrirati" + "Poslano s nesigurnog uređaja" + "Nemate pristup ovoj poruci" + "Pošiljateljev potvrđeni identitet je poništen" + "Pozivnice se nisu mogle poslati jednom korisniku ili više njih." + "Nije moguće poslati pozivnicu/e" + "Otključaj" + "Uključi zvuk" + "Nepodržani poziv" + "Nepodržani događaj" + "Korisničko ime" + "Provjera je otkazana" + "Provjera je dovršena" + "Provjera nije uspjela" + "Potvrđeno" + "Provjera uređaja" + "Potvrdi identitet" + "Provjeri korisnika" + "Videozapis" + "Visoka kvaliteta" + "Najbolja kvaliteta, ali veća veličina datoteke" + "Niska kvaliteta" + "Najbrža brzina prijenosa i najmanja veličina datoteke" + "Standardna kvaliteta" + "Ravnoteža kvalitete i brzine prijenosa" + "Glasovna poruka" + "Čekanje…" + "Čekam ovu poruku" + "Vi" + "Ova je soba konfigurirana tako da novi članovi mogu čitati stare poruke. %1$s" + "Identitet korisnika %1$s je poništen. %2$s" + "Identitet korisnika %1$s %2$s je poništen. %3$s" + "(%1$s)" + "Identitet korisnika %1$s je poništen." + "Identitet korisnika %1$s %2$s je poništen. %3$s" + "Povuci provjeru" + "Poveznica %1$s vodi vas na drugo mrežno mjesto %2$s + +Jeste li sigurni da želite nastaviti?" + "Dvaput provjerite ovu poveznicu" + "Odaberite zadanu kvalitetu videozapisa koje prenosite." + "Kvaliteta prijenosa videozapisa" + "Maksimalna dopuštena veličina datoteke je: %1$s" + "Datoteka je prevelika za prijenos" + "Soba je prijavljena" + "Soba je prijavljena i napuštena" + "Potvrda" + "Pogreška" + "Uspjeh" + "Upozorenje" + "Niste spremili sve promjene." + "Vaše promjene nisu spremljene. Jeste li sigurni da se želite vratiti?" + "Želite li spremiti promjene?" + "Maksimalna dopuštena veličina datoteke je: %1$s" + "Odaberite kvalitetu videozapisa koji želite prenijeti." + "Odaberi kvalitetu prijenosa videozapisa" + "Pretraživanje emotikona" + "Već ste prijavljeni na ovom uređaju kao %1$s." + "Vaš matični poslužitelj potrebno je nadograditi kako bi podržavao uslugu Matrix Authentication Service i stvaranje računa." + "Nije uspjelo stvaranje trajne poveznice" + "%1$s nije mogao učitati kartu. Pokušajte ponovno poslije." + "Učitavanje poruka nije uspjelo" + "%1$s nije mogao pristupiti vašoj lokaciji. Pokušajte ponovno poslije." + "Prijenos vaše glasovne poruke nije uspio." + "Soba više ne postoji ili pozivnica više ne vrijedi." + "Poruka nije pronađena" + "%1$s nema dopuštenje za pristup vašoj lokaciji. Pristup možete omogućiti u postavkama." + "%1$s nema dopuštenje za pristup vašoj lokaciji. Omogućite pristup u nastavku." + "%1$s nema dopuštenje za pristup vašem mikrofonu. Omogućite pristup kako biste mogli snimiti glasovnu poruku." + "To može biti zbog problema s mrežom ili poslužiteljem." + "Ova adresa sobe već postoji. Pokušajte urediti polje za adresu sobe ili promijeniti naziv sobe" + "Neki znakovi nisu dopušteni. Podržana su samo slova, brojke i simboli ! $ & ‘ ( ) * + / ; = ? @ [ ] - . _" + "Neke poruke nisu poslane" + "Žao nam je, došlo je do pogreške" + "Pošiljatelj događaja ne podudara se s vlasnikom uređaja koji ga je poslao." + "Autentičnost ove šifrirane poruke ne može se jamčiti na ovom uređaju" + "Šifrirao je prethodno provjereni korisnik." + "Nije šifrirano." + "Šifrirano nepoznatim ili izbrisanim uređajem." + "Šifrirano uređajem koji vlasnik nije potvrdio." + "Šifrirao neprovjereni korisnik." + "🔐️ Pridruži mi se u %1$s" + "Hej, razgovaraj sa mnom u aplikaciji %1$s: %2$s" + "%1$s Android" + "Snažno protresi za prijavu pogreške" + "Snimka zaslona" + "%1$s: %2$s" + "Mogućnosti" + "Ukloni %1$s" + "Postavke" + "Odabir medija nije uspio, pokušajte ponovno." + "Pritisnite poruku i odaberite “%1$s” kako biste uključili ovdje." + "Prikvačite važne poruke kako bi ih se lakše moglo pronaći" + + "%1$d prikvačena poruka" + "%1$d prikvačene poruke" + "%1$d prikvačenih poruka" + + "Prikvačene poruke" + "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" + "Možete povući svoju potvrdu i svejedno poslati ovu poruku ili možete za sada otkazati i pokušati ponovno poslije nakon ponovne potvrde korisnika %1$s." + "Vaša poruka nije poslana jer je poništen potvrđeni identitet korisnika %1$s" + "Svejedno pošalji poruku" + "%1$s se služi jednim nepotvrđenim uređajem ili više njih. Možete svejedno poslati poruku ili je za sada otkazati i pokušati ponovno poslije. %2$s je potvrdio/la sve svoje uređaje." + "Vaša poruka nije poslana jer %1$s nije potvrdio sve uređaje" + "Jedan vaš uređaj ili više njih nije potvrđeno. Možete svejedno poslati poruku ili za sada otkazati i pokušati ponovno poslije nakon što potvrdite sve svoje uređaje." + "Vaša poruka nije poslana jer niste potvrdili jedan svoj uređaj ili više njih" + "Uredi administratore ili vlasnike" + "Prijenos medija za obradu nije uspio, pokušajte ponovno." + "Nije moguće dohvatiti korisničke podatke" + "Poruka u sobi %1$s" + "Proširi" + "Smanji" + "Već gledam ovu sobu!" + "%1$s od %2$s" + "%1$s Prikvačene poruke" + "Učitavanje poruke…" + "Prikaži sve" + "Razgovor" + "Podijeli lokaciju" + "Podijeli moju lokaciju" + "Otvori u Apple Maps" + "Otvori u Google Maps" + "Otvori u OpenStreetMap" + "Podijeli ovu lokaciju" + "Prostori koje ste stvorili ili kojima ste se pridružili." + "%1$s • %2$s" + "Prostor %1$s" + "Prostori" + "Poruka nije poslana jer je poništen potvrđeni identitet korisnika %1$s." + "Poruka nije poslana jer %1$s nije potvrdio sve uređaje." + "Poruka nije poslana jer niste potvrdili jedan svoj uređaj ili više njih." + "Lokacija" + "Inačica: %1$s (%2$s)" + "hr" + "Prijašnje poruke nisu dostupne na ovom uređaju" + "Trebate potvrditi ovaj uređaj kako biste mogli pristupiti prijašnjim porukama" + "Nemate pristup ovoj poruci" + "Nije moguće dešifrirati poruku" + "Ova je poruka blokirana jer niste potvrdili svoj uređaj ili zato što pošiljatelj treba potvrditi vaš identitet." + 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 4539d065a3b..259ef3dd15a 100644 --- a/libraries/ui-strings/src/main/res/values-hu/translations.xml +++ b/libraries/ui-strings/src/main/res/values-hu/translations.xml @@ -112,6 +112,7 @@ "Továbbiak betöltése" "Fiók kezelése" "Eszközök kezelése" + "Szobák kezelése" "Üzenet" "Minimalizálás" "Következő" @@ -156,6 +157,7 @@ "Kihagyás" "Indítás" "Csevegés indítása" + "Újrakezdés" "Ellenőrzés elindítása" "Koppintson a térkép betöltéséhez" "Fénykép készítése" @@ -387,6 +389,7 @@ Biztos, hogy folytatja?" "Hiba" "Sikeres" "Figyelmeztetés" + "Mentetlen módosításai vannak." "A módosítások nem lettek mentve. Biztos, hogy visszalép?" "Menti a módosításokat?" "A legnagyobb megengedett fájlméret: %1$s" @@ -444,10 +447,6 @@ Biztos, hogy folytatja?" "Az üzenet nem lett elküldve, mert %1$s nem ellenőrizte az összes eszközét" "Egy vagy több eszköze nincs ellenőrizve. Így is elküldheti az üzenetet, vagy egyelőre megszakíthatja, és később, az összes eszköz ellenőrzése után újrapróbálkozhat." "Az üzenet nem lett elküldve, mert egy vagy több eszközét nem ellenőrizte" - "Beállítások módosítása" - "Tér kezelése" - "Szobák kezelése" - "Jogosultságok" "Adminisztrátorok vagy tulajdonosok szerkesztése" "Nem sikerült feldolgozni a feltöltendő médiát, próbálja újra." "Nem sikerült letölteni a felhasználói adatokat" @@ -470,7 +469,6 @@ Biztos, hogy folytatja?" "%1$s • %2$s" "%1$s tér" "Terek" - "Tagok megtekintése" "Az üzenet nem lett elküldve, mert %1$s ellenőrzött személyazonossága megváltozott." "Az üzenet nem lett elküldve, mert %1$s nem ellenőrizte az összes eszközét." "Az üzenet nem lett elküldve, mert egy vagy több eszközét nem ellenőrizte." diff --git a/libraries/ui-strings/src/main/res/values-in/translations.xml b/libraries/ui-strings/src/main/res/values-in/translations.xml index bd7739d5bab..cc2bad1f2b7 100644 --- a/libraries/ui-strings/src/main/res/values-in/translations.xml +++ b/libraries/ui-strings/src/main/res/values-in/translations.xml @@ -139,6 +139,7 @@ "Lewati" "Mulai" "Mulai obrolan" + "Mulai dari awal" "Mulai verifikasi" "Ketuk untuk memuat peta" "Ambil foto" @@ -158,6 +159,7 @@ "Pengaturan tingkat lanjut" "sebuah gambar" "Analitik" + "Anda keluar dari ruangan" "Penampilan" "Audio" "Pengguna yang diblokir" @@ -332,6 +334,7 @@ Apakah Anda yakin ingin melanjutkan?" "Eror" "Berhasil" "Peringatan" + "Anda memiliki perubahan yang belum disimpan." "Perubahan Anda belum disimpan. Apakah Anda yakin ingin kembali?" "Simpan perubahan?" "Homeserver Anda perlu ditingkatkan untuk mendukung Matrix Authentication Service dan pembuatan akun." diff --git a/libraries/ui-strings/src/main/res/values-it/translations.xml b/libraries/ui-strings/src/main/res/values-it/translations.xml index 793b1159173..c309732f11d 100644 --- a/libraries/ui-strings/src/main/res/values-it/translations.xml +++ b/libraries/ui-strings/src/main/res/values-it/translations.xml @@ -112,6 +112,7 @@ "Carica altro" "Gestisci account" "Gestisci dispositivi" + "Gestisci le stanze" "Invia messaggio" "Riduci" "Avanti" @@ -156,6 +157,7 @@ "Salta" "Inizia" "Avvia conversazione" + "Ricomincia" "Avvia la verifica" "Tocca per caricare la mappa" "Scatta foto" @@ -388,6 +390,7 @@ Sei sicuro di voler continuare?" "Errore" "Operazione riuscita" "Attenzione" + "Hai delle modifiche non salvate." "Le modifiche non sono state salvate. Vuoi davvero tornare indietro?" "Salvare le modifiche?" "La dimensione massima consentita per il file è: %1$s" @@ -445,10 +448,6 @@ Sei sicuro di voler continuare?" "Il tuo messaggio non è stato inviato perché %1$s non ha verificato tutti i dispositivi." "Uno o più dispositivi non sono verificati. Puoi inviare il messaggio comunque, oppure annullarlo e riprovare più tardi dopo aver verificato tutti i tuoi dispositivi." "Il tuo messaggio non è stato inviato perché non hai verificato uno o più dispositivi." - "Modifica impostazioni" - "Gestire lo spazio" - "Gestisci le stanze" - "Autorizzazioni" "Modifica amministratori o proprietari" "Elaborazione del file multimediale da caricare fallita, riprova." "Impossibile recuperare i dettagli dell\'utente" @@ -471,7 +470,6 @@ Sei sicuro di voler continuare?" "%1$s • %2$s" "%1$s spazio" "Spazi" - "Visualizza membri" "Messaggio non inviato perché l\'identità verificata di %1$s è stata reimpostata." "Messaggio non inviato perché %1$s non ha verificato tutti i dispositivi." "Messaggio non inviato perché non hai verificato uno o più dispositivi." diff --git a/libraries/ui-strings/src/main/res/values-ka/translations.xml b/libraries/ui-strings/src/main/res/values-ka/translations.xml index dd67ca02c2e..0a7b612e55c 100644 --- a/libraries/ui-strings/src/main/res/values-ka/translations.xml +++ b/libraries/ui-strings/src/main/res/values-ka/translations.xml @@ -113,6 +113,7 @@ "მისაღები გამოყენების პოლიტიკა" "გაფართოებული პარამეტრები" "ანალიტიკა" + "თქვენ დატოვეთ ოთახი" "გარეგნობა" "აუდიო" "დაბლოკილი მომხმარებლები" @@ -236,6 +237,7 @@ "შეცდომა" "წარმატება" "გაფრთხილება" + "თქვენ გაქვთ შეუნახავი ცვლილებები" "თქვენი ცვლილებები არაა შენახული. დარწმუნებული ხართ დაბრუნებაში?" "შენახვა?" "მუდმივი ბმულის შექმნა ვერ მოხერხდა" diff --git a/libraries/ui-strings/src/main/res/values-ko/translations.xml b/libraries/ui-strings/src/main/res/values-ko/translations.xml index 256e561db5c..2ea2017c763 100644 --- a/libraries/ui-strings/src/main/res/values-ko/translations.xml +++ b/libraries/ui-strings/src/main/res/values-ko/translations.xml @@ -148,6 +148,7 @@ "건너뛰기" "시작" "채팅 시작" + "다시 시작하다" "인증 시작" "탭해서 지도 불러오기" "사진 찍기" @@ -167,7 +168,7 @@ "고급 설정" "이미지" "통계" - "방에서 나갔습니다" + "방을 떠남" "세션에서 로그아웃되었습니다." "외관" "소리" @@ -364,6 +365,7 @@ "오류" "성공" "경고" + "저장되지 않은 변경 사항이 있습니다." "변경 내용이 저장되지 않았습니다. 정말로 돌아가시겠습니까?" "변경 사항을 저장하시겠습니까?" "허용되는 최대 파일 크기: %1$s diff --git a/libraries/ui-strings/src/main/res/values-lt/translations.xml b/libraries/ui-strings/src/main/res/values-lt/translations.xml index 535e57f7fe4..90aed6b6f84 100644 --- a/libraries/ui-strings/src/main/res/values-lt/translations.xml +++ b/libraries/ui-strings/src/main/res/values-lt/translations.xml @@ -64,6 +64,7 @@ "Taip" "Apie" "Analitika" + "Jūs išėjote iš kambario" "Garsas" "Burbulai" "Pokalbio atsarginė kopija" diff --git a/libraries/ui-strings/src/main/res/values-nb/translations.xml b/libraries/ui-strings/src/main/res/values-nb/translations.xml index e64a005577b..05673571c6a 100644 --- a/libraries/ui-strings/src/main/res/values-nb/translations.xml +++ b/libraries/ui-strings/src/main/res/values-nb/translations.xml @@ -95,6 +95,7 @@ "Glemt passordet?" "Videresend" "Gå tilbake" + "Gå til roller og tillatelser" "Gå til innstillinger" "Ignorer" "Inviter" @@ -155,6 +156,7 @@ "Hopp over" "Start" "Start chat" + "Begynn på nytt" "Start verifisering" "Trykk for å laste inn kart" "Ta bilde" @@ -385,6 +387,7 @@ Er du sikker på at du vil fortsette?" "Feil" "Suksess" "Advarsel" + "Du har endringer som ikke er lagret." "Endringene dine er ikke lagret. Er du sikker på at du vil gå tilbake?" "Lagre endringer?" "Maksimal tillatt filstørrelse er: %1$s" @@ -464,7 +467,6 @@ Er du sikker på at du vil fortsette?" "%1$s • %2$s" "%1$s område" "Områder" - "Vis medlemmer" "Meldingen ble ikke sendt fordi %1$ss verifiserte identitet er tilbakestilt." "Meldingen ble ikke sendt fordi %1$s ikke har verifisert alle enheter." "Meldingen ble ikke sendt fordi du ikke har verifisert en eller flere av enhetene dine." diff --git a/libraries/ui-strings/src/main/res/values-nl/translations.xml b/libraries/ui-strings/src/main/res/values-nl/translations.xml index 7c682233a61..790363ba549 100644 --- a/libraries/ui-strings/src/main/res/values-nl/translations.xml +++ b/libraries/ui-strings/src/main/res/values-nl/translations.xml @@ -140,6 +140,7 @@ "Overslaan" "Starten" "Chat starten" + "Opnieuw beginnen" "Verificatie starten" "Tik om kaart te laden" "Foto maken" @@ -158,6 +159,7 @@ "Bijschrift toevoegen" "Geavanceerde instellingen" "Gebruiksgegevens" + "Je hebt de kamer verlaten" "Weergave" "Geluid" "Geblokkeerde gebruikers" @@ -300,6 +302,7 @@ Reden: %1$s." "Fout" "Geslaagd" "Waarschuwing" + "Je hebt niet-opgeslagen wijzigingen" "Je wijzigingen zijn niet opgeslagen. Weet je zeker dat je terug wilt gaan?" "Wijzigingen opslaan?" "Je homeserver moet worden geüpgraded om de Matrix Authentication Service en het aanmaken van accounts te ondersteunen." 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 e4186fd0f29..aafac63332e 100644 --- a/libraries/ui-strings/src/main/res/values-pl/translations.xml +++ b/libraries/ui-strings/src/main/res/values-pl/translations.xml @@ -157,6 +157,7 @@ "Pomiń" "Rozpocznij" "Rozpocznij chat" + "Zacznij od nowa" "Rozpocznij weryfikację" "Stuknij, aby załadować mapę" "Zrób zdjęcie" @@ -395,6 +396,7 @@ Czy na pewno chcesz kontynuować?" "Błąd" "Sukces" "Ostrzeżenie" + "Masz niezapisane zmiany." "Zmiany nie zostały zapisane. Czy na pewno chcesz wrócić?" "Zapisać zmiany?" "Maksymalny dozwolony rozmiar pliku to: %1$s" diff --git a/libraries/ui-strings/src/main/res/values-pt-rBR/translations.xml b/libraries/ui-strings/src/main/res/values-pt-rBR/translations.xml index 2f9b4a13e8b..3c874086946 100644 --- a/libraries/ui-strings/src/main/res/values-pt-rBR/translations.xml +++ b/libraries/ui-strings/src/main/res/values-pt-rBR/translations.xml @@ -75,6 +75,7 @@ "Copiar texto" "Criar" "Criar uma sala" + "Criar espaço" "Desativar" "Desativar conta" "Recusar" @@ -112,6 +113,7 @@ "Carregar mais" "Gerenciar conta" "Gerenciar dispositivos" + "Gerenciar salas" "Mensagem" "Minimizar" "Avançar" @@ -156,10 +158,12 @@ "Pular" "Iniciar" "Iniciar conversa" + "Começar de novo" "Iniciar verificação" "Toque para carregar o mapa" "Tirar foto" "Toque para opções" + "Traduzir" "Tente novamente" "Desafixar" "Visualizar" @@ -189,6 +193,7 @@ "Copiado para a área de transferência" "Direitos autorais" "Criando sala…" + "Criando espaço…" "Solicitação cancelada" "Saiu da sala" "Saiu do espaço" @@ -234,6 +239,7 @@ Motivo:​ %1$s." "Claro" "Linha copiada para a área de transferência" "Link copiado para área de transferência" + "Vincular novo dispositivo" "Carregando…" "Carregando mais…" @@ -246,10 +252,12 @@ Motivo:​ %1$s." "Mensagem" "Ações de mensagem" + "Falha no envio da mensagem" "Layout da mensagem" "Mensagem removida" "Moderno" "Mudo" + "Nome" "%1$s (%2$s)" "Não há resultados" "Não há um nome para a sala" @@ -324,6 +332,7 @@ Motivo:​ %1$s." "Algo deu errado" "Encontramos um problema. Tente novamente." "Espaço" + "Sobre o que é esse espaço?" "%1$d espaço" "%1$d espaços" @@ -331,6 +340,7 @@ Motivo:​ %1$s." "Iniciando a conversa…" "Figurinha" "Sucesso" + "Sugerido" "Sugestões" "Sincronizando" "Sistema" @@ -368,6 +378,7 @@ Motivo:​ %1$s." "Aguardando…" "Aguardando por esta mensagem" "Você" + "Esta sala foi configurada para que membros novos possam ler o histórico. %1$s" "A identidade de %1$s foi redefinida. %2$s" "A identidade de %1$s %2$s foi redefinida. %3$s" "(%1$s)" @@ -388,6 +399,7 @@ Você tem certeza de que deseja continuar?" "Erro" "Sucesso" "Alerta" + "Você tem alterações não salvas." "Suas alterações não foram salvas. Tem certeza de que você quer voltar?" "Salvar alterações?" "O tamanho máximo permitido de arquivos é: %1$s" @@ -445,10 +457,6 @@ Você tem certeza de que deseja continuar?" "Sua mensagem não foi enviada porque %1$s não verificou todos os dispositivos" "Um ou mais de seus dispositivos não foram verificados. Você pode enviar a mensagem mesmo assim ou pode cancelar por enquanto e tentar novamente mais tarde, depois de ter verificado todos os seus dispositivos." "Sua mensagem não foi enviada porque você não verificou um ou mais de seus dispositivos" - "Alterar configurações" - "Gerenciar espaço" - "Gerenciar salas" - "Permissões" "Editar administradores ou proprietários" "Falha ao processar a mídia para o envio. Tente novamente." "Não foi possível buscar os detalhes do usuário" @@ -471,7 +479,6 @@ Você tem certeza de que deseja continuar?" "%1$s • %2$s" "Espaço %1$s" "Espaços" - "Ver membros" "Mensagem não enviada porque a identidade verificada de %1$s foi redefinida." "A mensagem não foi enviada porque %1$s não verificou todos os dispositivos." "Mensagem não enviada porque você não verificou um ou mais dos seus dispositivos." diff --git a/libraries/ui-strings/src/main/res/values-pt/translations.xml b/libraries/ui-strings/src/main/res/values-pt/translations.xml index 5e55f3a9de7..019f2e6ac49 100644 --- a/libraries/ui-strings/src/main/res/values-pt/translations.xml +++ b/libraries/ui-strings/src/main/res/values-pt/translations.xml @@ -153,6 +153,7 @@ "Saltar" "Iniciar" "Iniciar conversa" + "Começar de novo" "Iniciar verificação" "Toca para carregar o mapa" "Tirar foto" @@ -382,6 +383,7 @@ Tens a certeza de que queres continuar?" "Erro" "Sucesso" "Aviso" + "Tens alterações por guardar." "As tuas alterações não foram guardadas. Tens a certeza que queres voltar atrás?" "Guardar alterações?" "O tamanho máximo de ficheiro permitido é: %1$s" diff --git a/libraries/ui-strings/src/main/res/values-ro/translations.xml b/libraries/ui-strings/src/main/res/values-ro/translations.xml index 0cf1acf673c..0851be76487 100644 --- a/libraries/ui-strings/src/main/res/values-ro/translations.xml +++ b/libraries/ui-strings/src/main/res/values-ro/translations.xml @@ -97,6 +97,7 @@ "Ați uitat parola?" "Redirecționați" "Înapoi" + "Mergeți la roluri și permisiuni" "Mergeți la setări" "Ignorați" "Invitați" @@ -113,6 +114,7 @@ "Încărcați mai mult" "Administrare cont" "Gestionare dispozitive" + "Gestionați camerele" "Mesaj" "Minimizați" "Următorul" @@ -157,6 +159,7 @@ "Omiteți" "Începeți" "Începeți discuția" + "Începeți din nou" "Începeți verificarea" "Atingeți pentru a încărca harta" "Faceți o fotografie" @@ -235,6 +238,7 @@ Motiv:%1$s." "Deschis" "Linie copiată în clipboard" "Linkul a fost copiat în clipboard" + "Conectați un dispozitiv nou" "Se încarcă…" "Se încarcă…" @@ -249,10 +253,12 @@ Motiv:%1$s." "Mesaj" "Acțiuni mesaj" + "Mesajul nu a putut fi trimis" "Aspectul mesajelor" "Mesaj șters" "Modern" "Dezactivați sunetul" + "Nume" "%1$s (%2$s)" "Niciun rezultat" "Fără nume de cameră" @@ -330,6 +336,7 @@ Motiv:%1$s." "Ceva nu a mers bine" "Am întâmpinat o problemă. Vă rugăm să încercați din nou." "Spațiu" + "Despre ce este vorba în acest spațiu?" "%1$d Spațiu" "%1$d Spații" @@ -375,6 +382,7 @@ Motiv:%1$s." "Se aşteaptă…" "Mesaj în așteptare" "Dumneavoastră" + "Această cameră a fost configurată astfel încât noii membri să poată citi istoricul. %1$s" "Identitatea lui %1$s a fost resetată. %2$s" "Identitatea %2$s a lui %1$s a fost resetată. %3$s" "(%1$s)" @@ -395,6 +403,7 @@ Sunteți sigur că doriți să continuați?" "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?" "Salvați modificările?" "Dimensiunea maximă permisă pentru fișiere este: %1$s" 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 1290cb36c8c..1078a92fd66 100644 --- a/libraries/ui-strings/src/main/res/values-ru/translations.xml +++ b/libraries/ui-strings/src/main/res/values-ru/translations.xml @@ -114,6 +114,7 @@ "Загрузить еще" "Настройки учетной записи" "Управление устройствами" + "Управление комнатами" "Сообщение" "Свернуть" "Далее" @@ -158,6 +159,7 @@ "Пропустить" "Начать" "Начать чат" + "Начать заново" "Начать подтверждение" "Нажмите, чтобы загрузить карту" "Сделать фото" @@ -396,6 +398,7 @@ "Ошибка" "Успешно" "Предупреждение" + "У вас есть несохраненные изменения." "Изменения не сохранены. Вы действительно хотите вернуться?" "Сохранить изменения?" "Максимально допустимый размер файла: %1$s" @@ -454,10 +457,6 @@ "Ваше сообщение не было отправлено, потому что %1$s не проверил одно или несколько устройств" "Одно или несколько ваших устройств не проверены. Вы можете отправить сообщение в любом случае или отменить его пока и повторить попытку позже, проверив все свои устройства." "Ваше сообщение не было отправлено, поскольку вы не подтвердили одно или несколько своих устройств." - "Изменить настройки" - "Управление пространством" - "Управление комнатами" - "Разрешения" "Редактировать роль владельца и администратора" "Не удалось обработать медиафайл для загрузки, попробуйте еще раз." "Не удалось получить данные о пользователе" @@ -480,7 +479,6 @@ "%1$s • %2$s" "%1$s пространство" "Пространства" - "Просмотреть участников" "Сообщение не отправлено, потому что подтвержденная личность %1$s была сброшена." "Сообщение не отправлено, потому что %1$s не проверил одно или несколько устройств." "Сообщение не отправлено, поскольку вы не подтвердили одно или несколько своих устройств." diff --git a/libraries/ui-strings/src/main/res/values-sk/translations.xml b/libraries/ui-strings/src/main/res/values-sk/translations.xml index a94c5657cf5..e209bd0a212 100644 --- a/libraries/ui-strings/src/main/res/values-sk/translations.xml +++ b/libraries/ui-strings/src/main/res/values-sk/translations.xml @@ -114,6 +114,7 @@ "Načítať viac" "Spravovať účet" "Spravovať zariadenia" + "Spravovať miestnosti" "Poslať správu" "Minimalizovať" "Ďalej" @@ -158,6 +159,7 @@ "Preskočiť" "Spustiť" "Začať konverzáciu" + "Začať odznova" "Spustiť overovanie" "Ťuknutím načítate mapu" "Urobiť fotku" @@ -250,6 +252,7 @@ Dôvod: %1$s." "Správa" "Akcie správy" + "Správu sa nepodarilo odoslať" "Štýl správ" "Správa odstránená" "Moderné" @@ -376,6 +379,7 @@ Dôvod: %1$s." "Čaká sa…" "Čaká sa na dešifrovací kľúč" "Vy" + "Správy, ktoré odošlete, budú zdieľané s novými členmi pozvanými do tejto miestnosti. %1$s" "Totožnosť používateľa %1$s sa obnovila.%2$s" "Totožnosť používateľa %1$s %2$s bola obnovená. %3$s" "(%1$s)" @@ -396,6 +400,7 @@ Naozaj chcete pokračovať?" "Chyba" "Úspech" "Upozornenie" + "Máte neuložené zmeny." "Vaše zmeny neboli uložené. Naozaj sa chcete vrátiť?" "Uložiť zmeny?" "Maximálna povolená veľkosť súboru je: %1$s" @@ -476,7 +481,6 @@ Naozaj chcete pokračovať?" "%1$s • %2$s" "%1$s priestor" "Priestory" - "Zobraziť členov" "Správa nebola odoslaná, pretože sa zmenila overená totožnosť používateľa %1$s." "Správa nebola odoslaná, pretože %1$s neoveril/a všetky zariadenia." "Správa nebola odoslaná, pretože ste neoverili jedno alebo viac svojich zariadení." diff --git a/libraries/ui-strings/src/main/res/values-sv/translations.xml b/libraries/ui-strings/src/main/res/values-sv/translations.xml index e3cf26193e3..7d84b11d874 100644 --- a/libraries/ui-strings/src/main/res/values-sv/translations.xml +++ b/libraries/ui-strings/src/main/res/values-sv/translations.xml @@ -148,6 +148,7 @@ "Hoppa över" "Starta" "Starta chat" + "Börja om" "Starta verifiering" "Tryck för att ladda kartan" "Ta ett foto" @@ -369,6 +370,7 @@ Anledning:%1$s." "Fel" "Lyckades" "Varning" + "Du har osparade ändringar." "Dina ändringar har inte sparats. Är du säker på att du vill gå tillbaka?" "Spara ändringar?" "Den maximala tillåtna filstorleken är: %1$s" diff --git a/libraries/ui-strings/src/main/res/values-tr/translations.xml b/libraries/ui-strings/src/main/res/values-tr/translations.xml index c72f5a6139e..b7b0bc841d1 100644 --- a/libraries/ui-strings/src/main/res/values-tr/translations.xml +++ b/libraries/ui-strings/src/main/res/values-tr/translations.xml @@ -122,6 +122,7 @@ "Atla" "Başlat" "Sohbeti başlat" + "Yeniden Başla" "Doğrulamayı başlat" "Haritayı yüklemek için dokunun" "Fotoğraf çek" @@ -139,6 +140,7 @@ "Açıklama ekleme" "Gelişmiş Ayarlar" "Analizler" + "Odadan ayrıldın." "Görünüm" "Ses" "Engellenen kullanıcılar" @@ -307,6 +309,7 @@ Devam etmek istediğinizden emin misiniz?" "Hata" "Başarılı" "Uyarı" + "Kaydedilmemiş değişiklikleriniz var." "Değişiklikleriniz kaydedilmedi. Geri dönmek istediğinden emin misin?" "Değişiklikleri Kaydet?" "Ana sunucunuzun Matrix Authentication Service ve hesap oluşturmayı destekleyecek şekilde güncellenmesi gerekiyor." 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 a4c171a21b6..0b1083ea1af 100644 --- a/libraries/ui-strings/src/main/res/values-uk/translations.xml +++ b/libraries/ui-strings/src/main/res/values-uk/translations.xml @@ -152,6 +152,7 @@ "Пропустити" "Розпочати" "Розпочати бесіду" + "Почати спочатку" "Почати верифікацію" "Натисніть, щоб завантажити мапу" "Зробити фото" @@ -382,6 +383,7 @@ "Помилка" "Успіх" "Попередження" + "У вас є не збережені зміни." "Внесені зміни не збережено. Ви впевнені, що хочете повернутися?" "Зберегти зміни?" "Максимально дозволений розмір файлу: %1$s" diff --git a/libraries/ui-strings/src/main/res/values-ur/translations.xml b/libraries/ui-strings/src/main/res/values-ur/translations.xml index 5abd2e08946..18d8b49ed5e 100644 --- a/libraries/ui-strings/src/main/res/values-ur/translations.xml +++ b/libraries/ui-strings/src/main/res/values-ur/translations.xml @@ -114,6 +114,7 @@ "چھوڑیں" "شروع کریں" "گفتگو شروع کریں" + "از سر نو شروع کریں" "توثیق شروع کریں" "نقشہ لادنے کیلئے تھپتھپائیں" "تصویر لیں" @@ -129,6 +130,7 @@ "قابل قبول استعمال کی سیاست" "اعلیٰ ترتیبات" "تجزیات" + "آپ کمرے سے رخصت ہوگئے" "ظہور" "صوت" "مسدود صارفین" @@ -265,6 +267,7 @@ "خرابی" "کامیابی" "انتباہ" + "آپکے پاس غیر محفوظ تبدیلیاں ہیں" "آپ کی تبدیلیاں محفوظ نہیں کی گئیں۔ کیا آپ کو یقین ہے کہ آپ واپس جانا چاہتے ہیں؟" "تبدیلیاں محفوظ کریں؟" "‏Matrix Authentication Service اور اکاؤنٹ بنانے میں معاونت کے لیے آپ کے ہوم سرور کو اپ گریڈ کرنے کی ضرورت ہے۔" diff --git a/libraries/ui-strings/src/main/res/values-uz/translations.xml b/libraries/ui-strings/src/main/res/values-uz/translations.xml index 8f23ee0d9e7..5d5b7ea4d45 100644 --- a/libraries/ui-strings/src/main/res/values-uz/translations.xml +++ b/libraries/ui-strings/src/main/res/values-uz/translations.xml @@ -150,6 +150,7 @@ "Oʻtkazib yuborish" "Boshlash" "Suhbatni boshlash" + "Qaytadan boshlang" "Tasdiqlashni boshlang" "Xaritani yuklash uchun bosing" "Rasmga olmoq" @@ -376,6 +377,7 @@ Davom etasizmi?" "Xato" "Muvaffaqiyat" "Ogohlantirish" + "Sizda saqlanmagan oʻzgarishlar bor" "Oʻzgarishlar saqlanmadi. Haqiqatan ham orqaga qaytmoqchimisiz?" "O‘zgartirishlarni saqlaysizmi?" "Ruxsat etilgan maksimal fayl hajmi: %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 1c88ac71dfb..e70937357d5 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 @@ -110,6 +110,7 @@ "載入更多" "管理帳號" "管理裝置" + "管理聊天室" "聊天" "最小化" "下一步" @@ -154,6 +155,7 @@ "略過" "開始" "開始聊天" + "重新開始" "開始驗證" "點擊以載入地圖" "拍照" @@ -175,7 +177,7 @@ "進階設定" "影像" "分析" - "您離開了聊天室" + "您離開聊天室" "您已登出工作階段" "外觀" "音訊" @@ -380,6 +382,7 @@ "錯誤" "成功" "警告" + "您有尚未儲存的變更" "變更尚未儲存,您確定要返回嗎?" "是否儲存變更?" "最大允許的檔案大小為:%1$s" @@ -436,10 +439,6 @@ "未傳送您的訊息,因為 %1$s 尚未驗證所有裝置。" "您的一個或多個裝置未經驗證。您仍可傳送訊息,也可以取消並在您驗證您的所有裝置後再試一次。" "因為您尚未驗證一個或多個裝置,因為未傳送您的訊息" - "變更設定" - "管理空間" - "管理聊天室" - "權限" "編輯管理員或擁有者" "無法處理要上傳的媒體,請再試一次。" "無法擷取使用者詳細資訊" @@ -462,7 +461,6 @@ "%1$s • %2$s" "%1$s 空間" "空間" - "檢視成員" "因為 %1$s 的驗證身份已重設,因此未傳送訊息。" "訊息未傳送,因為 %1$s 尚未驗證所有裝置。" "因為您尚未驗證一個或多個裝置,因此未傳送訊息" 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 a743a5771b2..7d63c0ccb96 100644 --- a/libraries/ui-strings/src/main/res/values-zh/translations.xml +++ b/libraries/ui-strings/src/main/res/values-zh/translations.xml @@ -154,6 +154,7 @@ "跳过" "开始" "开始聊天" + "重新开始" "开始验证" "点击以加载地图" "拍摄照片" @@ -380,6 +381,7 @@ "错误" "成功" "警告" + "您有未保存的更改。" "更改尚未保存,确定要返回吗?" "保存更改?" "允许的最大文件大小为:%1$s" @@ -458,7 +460,6 @@ "%1$s • %2$s" "%1$s空间" "空间" - "查看成员" "消息未发送,因为%1$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 dcd3cdd6e26..70dd6135263 100644 --- a/libraries/ui-strings/src/main/res/values/localazy.xml +++ b/libraries/ui-strings/src/main/res/values/localazy.xml @@ -56,6 +56,7 @@ "Your avatar" "Accept" "Add caption" + "Add existing rooms" "Add to timeline" "Back" "Call" @@ -75,6 +76,7 @@ "Copy text" "Create" "Create a room" + "Create space" "Deactivate" "Deactivate account" "Decline" @@ -112,6 +114,7 @@ "Load more" "Manage account" "Manage devices" + "Manage rooms" "Message" "Minimise" "Next" @@ -156,10 +159,12 @@ "Skip" "Start" "Start chat" + "Start over" "Start verification" "Tap to load map" "Take photo" "Tap for options" + "Translate" "Try again" "Unpin" "View" @@ -189,6 +194,7 @@ "Copied to clipboard" "Copyright" "Creating room…" + "Creating space…" "Request canceled" "Left room" "Left space" @@ -234,6 +240,7 @@ Reason: %1$s." "Light" "Line copied to clipboard" "Link copied to clipboard" + "Link new device" "Loading…" "Loading more…" @@ -251,6 +258,7 @@ Reason: %1$s." "Message removed" "Modern" "Mute" + "Name" "%1$s (%2$s)" "No results" "No room name" @@ -309,6 +317,10 @@ Reason: %1$s." "Security" "Seen by" "Select an account" + + "%1$d selected" + "%1$d selected" + "Send to" "Sending…" "Sending failed" @@ -325,6 +337,7 @@ Reason: %1$s." "Something went wrong" "We encountered an issue. Please try again." "Space" + "What is this space about?" "%1$d Space" "%1$d Spaces" @@ -332,6 +345,7 @@ Reason: %1$s." "Starting chat…" "Sticker" "Success" + "Suggested" "Suggestions" "Syncing" "System" @@ -369,7 +383,9 @@ Reason: %1$s." "Waiting…" "Waiting for this message" "You" - "Messages you send will be shared with new members invited to this room. %1$s" + "%1$s (%2$s) shared this message since you were not in the room when it was sent." + "%1$s shared this message since you were not in the room when it was sent." + "This room has been configured so that new members can read history. %1$s" "%1$s\'s identity was reset. %2$s" "%1$s’s %2$s identity was reset. %3$s" "(%1$s)" @@ -390,6 +406,7 @@ Are you sure you want to continue?" "Error" "Success" "Warning" + "You have unsaved changes." "Your changes have not been saved. Are you sure you want to go back?" "Save changes?" "The max file size allowed is: %1$s" @@ -429,11 +446,6 @@ Are you sure you want to continue?" "Options" "Remove %1$s" "Settings" - "Spaces where members can join the room without an invitation." - "Manage spaces" - "(Unknown space)" - "Other spaces you’re not a member of" - "Your spaces" "Failed selecting media, please try again." "Press on a message and choose “%1$s” to include here." "Pin important messages so that they can be easily discovered" @@ -452,10 +464,6 @@ Are you sure you want to continue?" "Your message was not sent because %1$s has not verified all devices" "One or more of your devices are unverified. You can send the message anyway, or you can cancel for now and try again later after you have verified all of your devices." "Your message was not sent because you have not verified one or more of your devices" - "Change settings" - "Manage space" - "Manage rooms" - "Permissions" "Edit Admins or Owners" "Failed processing media to upload, please try again." "Could not retrieve user details" @@ -474,11 +482,12 @@ Are you sure you want to continue?" "Open in Google Maps" "Open in OpenStreetMap" "Share this location" + "Adding a room will not affect the room access. To change the access go to Room info > Privacy & security." "Spaces you have created or joined." "%1$s • %2$s" + "Create spaces to organize rooms" "%1$s space" "Spaces" - "View members" "Message not sent because %1$s’s verified identity was reset." "Message not sent because %1$s has not verified all devices." "Message not sent because you have not verified one or more of your devices." diff --git a/libraries/voiceplayer/api/src/main/kotlin/io/element/android/libraries/voiceplayer/api/VoiceMessageEvents.kt b/libraries/voiceplayer/api/src/main/kotlin/io/element/android/libraries/voiceplayer/api/VoiceMessageEvent.kt similarity index 59% rename from libraries/voiceplayer/api/src/main/kotlin/io/element/android/libraries/voiceplayer/api/VoiceMessageEvents.kt rename to libraries/voiceplayer/api/src/main/kotlin/io/element/android/libraries/voiceplayer/api/VoiceMessageEvent.kt index 4adc1cfab6c..bec03f39433 100644 --- a/libraries/voiceplayer/api/src/main/kotlin/io/element/android/libraries/voiceplayer/api/VoiceMessageEvents.kt +++ b/libraries/voiceplayer/api/src/main/kotlin/io/element/android/libraries/voiceplayer/api/VoiceMessageEvent.kt @@ -8,7 +8,8 @@ package io.element.android.libraries.voiceplayer.api -sealed interface VoiceMessageEvents { - data object PlayPause : VoiceMessageEvents - data class Seek(val percentage: Float) : VoiceMessageEvents +sealed interface VoiceMessageEvent { + data object PlayPause : VoiceMessageEvent + data class Seek(val percentage: Float) : VoiceMessageEvent + data object ChangePlaybackSpeed : VoiceMessageEvent } diff --git a/libraries/voiceplayer/api/src/main/kotlin/io/element/android/libraries/voiceplayer/api/VoiceMessageState.kt b/libraries/voiceplayer/api/src/main/kotlin/io/element/android/libraries/voiceplayer/api/VoiceMessageState.kt index e13d97b18f5..2faf35f91f9 100644 --- a/libraries/voiceplayer/api/src/main/kotlin/io/element/android/libraries/voiceplayer/api/VoiceMessageState.kt +++ b/libraries/voiceplayer/api/src/main/kotlin/io/element/android/libraries/voiceplayer/api/VoiceMessageState.kt @@ -9,13 +9,14 @@ package io.element.android.libraries.voiceplayer.api data class VoiceMessageState( - val button: Button, + val buttonType: ButtonType, val progress: Float, val time: String, val showCursor: Boolean, - val eventSink: (event: VoiceMessageEvents) -> Unit, + val playbackSpeed: Float, + val eventSink: (event: VoiceMessageEvent) -> Unit, ) { - enum class Button { + enum class ButtonType { Play, Pause, Downloading, diff --git a/libraries/voiceplayer/api/src/main/kotlin/io/element/android/libraries/voiceplayer/api/VoiceMessageStateProvider.kt b/libraries/voiceplayer/api/src/main/kotlin/io/element/android/libraries/voiceplayer/api/VoiceMessageStateProvider.kt index 7a98c528333..dd8d222e69b 100644 --- a/libraries/voiceplayer/api/src/main/kotlin/io/element/android/libraries/voiceplayer/api/VoiceMessageStateProvider.kt +++ b/libraries/voiceplayer/api/src/main/kotlin/io/element/android/libraries/voiceplayer/api/VoiceMessageStateProvider.kt @@ -14,29 +14,29 @@ open class VoiceMessageStateProvider : PreviewParameterProvider get() = sequenceOf( aVoiceMessageState( - VoiceMessageState.Button.Downloading, + VoiceMessageState.ButtonType.Downloading, progress = 0f, time = "0:00", ), aVoiceMessageState( - VoiceMessageState.Button.Retry, + VoiceMessageState.ButtonType.Retry, progress = 0.5f, time = "0:01", ), aVoiceMessageState( - VoiceMessageState.Button.Play, + VoiceMessageState.ButtonType.Play, progress = 1f, time = "1:00", showCursor = true, ), aVoiceMessageState( - VoiceMessageState.Button.Pause, + VoiceMessageState.ButtonType.Pause, progress = 0.2f, time = "10:00", showCursor = true, ), aVoiceMessageState( - VoiceMessageState.Button.Disabled, + VoiceMessageState.ButtonType.Disabled, progress = 0.2f, time = "30:00", ), @@ -44,14 +44,16 @@ open class VoiceMessageStateProvider : PreviewParameterProvider VoiceMessageState.Button.Disabled - playerState.isPlaying -> VoiceMessageState.Button.Pause - play.value is AsyncData.Loading -> VoiceMessageState.Button.Downloading - play.value is AsyncData.Failure -> VoiceMessageState.Button.Retry - else -> VoiceMessageState.Button.Play + eventId == null -> VoiceMessageState.ButtonType.Disabled + playerState.isPlaying -> VoiceMessageState.ButtonType.Pause + play.value is AsyncData.Loading -> VoiceMessageState.ButtonType.Downloading + play.value is AsyncData.Failure -> VoiceMessageState.ButtonType.Retry + else -> VoiceMessageState.ButtonType.Play } } } @@ -85,9 +95,9 @@ class VoiceMessagePresenter( } } - fun handleEvent(event: VoiceMessageEvents) { + fun handleEvent(event: VoiceMessageEvent) { when (event) { - is VoiceMessageEvents.PlayPause -> { + is VoiceMessageEvent.PlayPause -> { if (playerState.isPlaying) { player.pause() } else if (playerState.isReady) { @@ -109,17 +119,23 @@ class VoiceMessagePresenter( } } } - is VoiceMessageEvents.Seek -> { + is VoiceMessageEvent.Seek -> { player.seekTo((event.percentage * duration).toLong()) } + is VoiceMessageEvent.ChangePlaybackSpeed -> localCoroutineScope.launch { + voicePlayerStore.setPlayBackSpeedIndex( + (playbackSpeedIndex + 1) % VoicePlayerConfig.availablePlaybackSpeeds.size + ) + } } } return VoiceMessageState( - button = button, + buttonType = buttonType, progress = progress, time = time, showCursor = showCursor, + playbackSpeed = VoicePlayerConfig.availablePlaybackSpeeds[playbackSpeedIndex], eventSink = ::handleEvent, ) } diff --git a/libraries/voiceplayer/impl/src/main/kotlin/io/element/android/libraries/voiceplayer/impl/VoicePlayerConfig.kt b/libraries/voiceplayer/impl/src/main/kotlin/io/element/android/libraries/voiceplayer/impl/VoicePlayerConfig.kt new file mode 100644 index 00000000000..3ade34d99f3 --- /dev/null +++ b/libraries/voiceplayer/impl/src/main/kotlin/io/element/android/libraries/voiceplayer/impl/VoicePlayerConfig.kt @@ -0,0 +1,14 @@ +/* + * Copyright (c) 2025 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.voiceplayer.impl + +object VoicePlayerConfig { + // Available playback speeds for voice messages, the first one is the default speed, and + // the UI will allow to change to the next speed in the list, in loop. + val availablePlaybackSpeeds = listOf(1.0f, 1.5f, 2.0f, 0.5f) +} diff --git a/libraries/voiceplayer/impl/src/main/kotlin/io/element/android/libraries/voiceplayer/impl/VoicePlayerStore.kt b/libraries/voiceplayer/impl/src/main/kotlin/io/element/android/libraries/voiceplayer/impl/VoicePlayerStore.kt new file mode 100644 index 00000000000..3c400b3e92e --- /dev/null +++ b/libraries/voiceplayer/impl/src/main/kotlin/io/element/android/libraries/voiceplayer/impl/VoicePlayerStore.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2025 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.voiceplayer.impl + +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.core.intPreferencesKey +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.preferences.api.store.PreferenceDataStoreFactory +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map + +interface VoicePlayerStore { + suspend fun setPlayBackSpeedIndex(index: Int) + fun playBackSpeedIndex(): Flow +} + +@ContributesBinding(AppScope::class) +class PreferencesVoicePlayerStore( + preferenceDataStoreFactory: PreferenceDataStoreFactory, +) : VoicePlayerStore { + private val store = preferenceDataStoreFactory.create("elementx_voice_player") + private val playbackSpeedIndex = intPreferencesKey("playback_speed_index") + + override fun playBackSpeedIndex(): Flow { + return store.data.map { prefs -> + prefs[playbackSpeedIndex] ?: 0 + } + } + + override suspend fun setPlayBackSpeedIndex(index: Int) { + store.edit { prefs -> + prefs[playbackSpeedIndex] = index + } + } +} diff --git a/libraries/voiceplayer/impl/src/test/kotlin/io/element/android/libraries/voiceplayer/impl/InMemoryVoicePlayerStore.kt b/libraries/voiceplayer/impl/src/test/kotlin/io/element/android/libraries/voiceplayer/impl/InMemoryVoicePlayerStore.kt new file mode 100644 index 00000000000..e746b5acf58 --- /dev/null +++ b/libraries/voiceplayer/impl/src/test/kotlin/io/element/android/libraries/voiceplayer/impl/InMemoryVoicePlayerStore.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2025 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.voiceplayer.impl + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow + +internal class InMemoryVoicePlayerStore( + defaultPlaybackSpeedIndex: Int = 0, +) : VoicePlayerStore { + private val playBackSpeedIndex = MutableStateFlow(defaultPlaybackSpeedIndex) + + override fun playBackSpeedIndex(): Flow { + return playBackSpeedIndex.asStateFlow() + } + + override suspend fun setPlayBackSpeedIndex(index: Int) { + playBackSpeedIndex.emit(index) + } +} diff --git a/libraries/voiceplayer/impl/src/test/kotlin/io/element/android/libraries/voiceplayer/impl/VoiceMessagePresenterTest.kt b/libraries/voiceplayer/impl/src/test/kotlin/io/element/android/libraries/voiceplayer/impl/VoiceMessagePresenterTest.kt index 642a55deadc..80546779373 100644 --- a/libraries/voiceplayer/impl/src/test/kotlin/io/element/android/libraries/voiceplayer/impl/VoiceMessagePresenterTest.kt +++ b/libraries/voiceplayer/impl/src/test/kotlin/io/element/android/libraries/voiceplayer/impl/VoiceMessagePresenterTest.kt @@ -8,19 +8,17 @@ package io.element.android.libraries.voiceplayer.impl -import app.cash.molecule.RecompositionMode -import app.cash.molecule.moleculeFlow -import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.mediaplayer.test.FakeMediaPlayer -import io.element.android.libraries.voiceplayer.api.VoiceMessageEvents +import io.element.android.libraries.voiceplayer.api.VoiceMessageEvent import io.element.android.libraries.voiceplayer.api.VoiceMessageException import io.element.android.libraries.voiceplayer.api.VoiceMessageState import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.test.FakeAnalyticsService +import io.element.android.tests.testutils.test import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest import org.junit.Test @@ -31,11 +29,9 @@ class VoiceMessagePresenterTest { @Test fun `initial state has proper default values`() = runTest { val presenter = createVoiceMessagePresenter() - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { + presenter.test { awaitItem().let { - assertThat(it.button).isEqualTo(VoiceMessageState.Button.Play) + assertThat(it.buttonType).isEqualTo(VoiceMessageState.ButtonType.Play) assertThat(it.progress).isEqualTo(0f) assertThat(it.time).isEqualTo("1:01") } @@ -48,29 +44,27 @@ class VoiceMessagePresenterTest { mediaPlayer = FakeMediaPlayer(fakeTotalDurationMs = 2_000), duration = 2_000.milliseconds, ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { + presenter.test { val initialState = awaitItem().also { - assertThat(it.button).isEqualTo(VoiceMessageState.Button.Play) + assertThat(it.buttonType).isEqualTo(VoiceMessageState.ButtonType.Play) assertThat(it.progress).isEqualTo(0f) assertThat(it.time).isEqualTo("0:02") } - initialState.eventSink(VoiceMessageEvents.PlayPause) + initialState.eventSink(VoiceMessageEvent.PlayPause) awaitItem().also { - assertThat(it.button).isEqualTo(VoiceMessageState.Button.Downloading) + assertThat(it.buttonType).isEqualTo(VoiceMessageState.ButtonType.Downloading) assertThat(it.progress).isEqualTo(0f) assertThat(it.time).isEqualTo("0:02") } awaitItem().also { - assertThat(it.button).isEqualTo(VoiceMessageState.Button.Downloading) + assertThat(it.buttonType).isEqualTo(VoiceMessageState.ButtonType.Downloading) assertThat(it.progress).isEqualTo(0f) assertThat(it.time).isEqualTo("0:00") } awaitItem().also { - assertThat(it.button).isEqualTo(VoiceMessageState.Button.Pause) + assertThat(it.buttonType).isEqualTo(VoiceMessageState.ButtonType.Pause) assertThat(it.progress).isEqualTo(0.5f) assertThat(it.time).isEqualTo("0:01") } @@ -86,24 +80,22 @@ class VoiceMessagePresenterTest { analyticsService = analyticsService, duration = 2_000.milliseconds, ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { + presenter.test { val initialState = awaitItem().also { - assertThat(it.button).isEqualTo(VoiceMessageState.Button.Play) + assertThat(it.buttonType).isEqualTo(VoiceMessageState.ButtonType.Play) assertThat(it.progress).isEqualTo(0f) assertThat(it.time).isEqualTo("0:02") } - initialState.eventSink(VoiceMessageEvents.PlayPause) + initialState.eventSink(VoiceMessageEvent.PlayPause) awaitItem().also { - assertThat(it.button).isEqualTo(VoiceMessageState.Button.Downloading) + assertThat(it.buttonType).isEqualTo(VoiceMessageState.ButtonType.Downloading) assertThat(it.progress).isEqualTo(0f) assertThat(it.time).isEqualTo("0:02") } awaitItem().also { - assertThat(it.button).isEqualTo(VoiceMessageState.Button.Retry) + assertThat(it.buttonType).isEqualTo(VoiceMessageState.ButtonType.Retry) assertThat(it.progress).isEqualTo(0f) assertThat(it.time).isEqualTo("0:02") } @@ -122,27 +114,25 @@ class VoiceMessagePresenterTest { mediaPlayer = FakeMediaPlayer(fakeTotalDurationMs = 2_000), duration = 2_000.milliseconds, ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { + presenter.test { val initialState = awaitItem().also { - assertThat(it.button).isEqualTo(VoiceMessageState.Button.Play) + assertThat(it.buttonType).isEqualTo(VoiceMessageState.ButtonType.Play) assertThat(it.progress).isEqualTo(0f) assertThat(it.time).isEqualTo("0:02") } - initialState.eventSink(VoiceMessageEvents.PlayPause) + initialState.eventSink(VoiceMessageEvent.PlayPause) skipItems(2) // skip downloading states val playingState = awaitItem().also { - assertThat(it.button).isEqualTo(VoiceMessageState.Button.Pause) + assertThat(it.buttonType).isEqualTo(VoiceMessageState.ButtonType.Pause) assertThat(it.progress).isEqualTo(0.5f) assertThat(it.time).isEqualTo("0:01") } - playingState.eventSink(VoiceMessageEvents.PlayPause) + playingState.eventSink(VoiceMessageEvent.PlayPause) awaitItem().also { - assertThat(it.button).isEqualTo(VoiceMessageState.Button.Play) + assertThat(it.buttonType).isEqualTo(VoiceMessageState.ButtonType.Play) assertThat(it.progress).isEqualTo(0.5f) assertThat(it.time).isEqualTo("0:01") } @@ -154,11 +144,9 @@ class VoiceMessagePresenterTest { val presenter = createVoiceMessagePresenter( eventId = null, ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { + presenter.test { awaitItem().also { - assertThat(it.button).isEqualTo(VoiceMessageState.Button.Disabled) + assertThat(it.buttonType).isEqualTo(VoiceMessageState.ButtonType.Disabled) assertThat(it.progress).isEqualTo(0f) assertThat(it.time).isEqualTo("1:01") } @@ -171,19 +159,17 @@ class VoiceMessagePresenterTest { mediaPlayer = FakeMediaPlayer(fakeTotalDurationMs = 2_000), duration = 10_000.milliseconds, ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { + presenter.test { val initialState = awaitItem().also { - assertThat(it.button).isEqualTo(VoiceMessageState.Button.Play) + assertThat(it.buttonType).isEqualTo(VoiceMessageState.ButtonType.Play) assertThat(it.progress).isEqualTo(0f) assertThat(it.time).isEqualTo("0:10") } - initialState.eventSink(VoiceMessageEvents.Seek(0.5f)) + initialState.eventSink(VoiceMessageEvent.Seek(0.5f)) awaitItem().also { - assertThat(it.button).isEqualTo(VoiceMessageState.Button.Play) + assertThat(it.buttonType).isEqualTo(VoiceMessageState.ButtonType.Play) assertThat(it.progress).isEqualTo(0.5f) assertThat(it.time).isEqualTo("0:05") } @@ -195,40 +181,66 @@ class VoiceMessagePresenterTest { val presenter = createVoiceMessagePresenter( duration = 10_000.milliseconds, ) - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { + presenter.test { val initialState = awaitItem().also { - assertThat(it.button).isEqualTo(VoiceMessageState.Button.Play) + assertThat(it.buttonType).isEqualTo(VoiceMessageState.ButtonType.Play) assertThat(it.progress).isEqualTo(0f) assertThat(it.time).isEqualTo("0:10") } - initialState.eventSink(VoiceMessageEvents.PlayPause) + initialState.eventSink(VoiceMessageEvent.PlayPause) skipItems(2) // skip downloading states awaitItem().also { - assertThat(it.button).isEqualTo(VoiceMessageState.Button.Pause) + assertThat(it.buttonType).isEqualTo(VoiceMessageState.ButtonType.Pause) assertThat(it.progress).isEqualTo(0.1f) assertThat(it.time).isEqualTo("0:01") + it.eventSink(VoiceMessageEvent.Seek(0.5f)) } - initialState.eventSink(VoiceMessageEvents.Seek(0.5f)) - awaitItem().also { - assertThat(it.button).isEqualTo(VoiceMessageState.Button.Pause) + assertThat(it.buttonType).isEqualTo(VoiceMessageState.ButtonType.Pause) assertThat(it.progress).isEqualTo(0.5f) assertThat(it.time).isEqualTo("0:05") } } } + + @Test + fun `changing playback speed cycles through available speeds`() = runTest { + val presenter = createVoiceMessagePresenter( + duration = 10_000.milliseconds, + ) + presenter.test { + awaitItem().also { + assertThat(it.playbackSpeed).isEqualTo(1.0f) + it.eventSink(VoiceMessageEvent.ChangePlaybackSpeed) + } + awaitItem().also { + assertThat(it.playbackSpeed).isEqualTo(1.5f) + it.eventSink(VoiceMessageEvent.ChangePlaybackSpeed) + } + awaitItem().also { + assertThat(it.playbackSpeed).isEqualTo(2.0f) + it.eventSink(VoiceMessageEvent.ChangePlaybackSpeed) + } + awaitItem().also { + assertThat(it.playbackSpeed).isEqualTo(0.5f) + it.eventSink(VoiceMessageEvent.ChangePlaybackSpeed) + } + awaitItem().also { + assertThat(it.playbackSpeed).isEqualTo(1.0f) + } + } + } } fun TestScope.createVoiceMessagePresenter( mediaPlayer: FakeMediaPlayer = FakeMediaPlayer(), voiceMessageMediaRepo: VoiceMessageMediaRepo = FakeVoiceMessageMediaRepo(), analyticsService: AnalyticsService = FakeAnalyticsService(), + voicePlayerStore: VoicePlayerStore = InMemoryVoicePlayerStore(), eventId: EventId? = EventId("\$anEventId"), filename: String = "filename doesn't really matter for a voice message", duration: Duration = 61_000.milliseconds, @@ -246,6 +258,7 @@ fun TestScope.createVoiceMessagePresenter( mimeType = mimeType, filename = filename ), + voicePlayerStore = voicePlayerStore, eventId = eventId, duration = duration, ) diff --git a/libraries/workmanager/api/src/main/kotlin/io/element/android/libraries/workmanager/api/WorkManagerScheduler.kt b/libraries/workmanager/api/src/main/kotlin/io/element/android/libraries/workmanager/api/WorkManagerScheduler.kt index b385f0b2fb3..b538486d357 100644 --- a/libraries/workmanager/api/src/main/kotlin/io/element/android/libraries/workmanager/api/WorkManagerScheduler.kt +++ b/libraries/workmanager/api/src/main/kotlin/io/element/android/libraries/workmanager/api/WorkManagerScheduler.kt @@ -12,16 +12,19 @@ import io.element.android.libraries.matrix.api.core.SessionId interface WorkManagerScheduler { fun submit(workManagerRequest: WorkManagerRequest) + fun hasPendingWork(sessionId: SessionId, requestType: WorkManagerRequestType): Boolean fun cancel(sessionId: SessionId) } fun workManagerTag(sessionId: SessionId, requestType: WorkManagerRequestType): String { val prefix = when (requestType) { WorkManagerRequestType.NOTIFICATION_SYNC -> "notifications" + WorkManagerRequestType.DB_VACUUM -> "db_vacuum" } return "$prefix-$sessionId" } enum class WorkManagerRequestType { NOTIFICATION_SYNC, + DB_VACUUM, } diff --git a/libraries/workmanager/impl/build.gradle.kts b/libraries/workmanager/impl/build.gradle.kts index ebf0f906bce..878edb6fe24 100644 --- a/libraries/workmanager/impl/build.gradle.kts +++ b/libraries/workmanager/impl/build.gradle.kts @@ -1,4 +1,5 @@ import extension.setupDependencyInjection +import extension.testCommonDependencies /* * Copyright (c) 2025 Element Creations Ltd. @@ -22,4 +23,7 @@ dependencies { implementation(projects.libraries.core) implementation(projects.libraries.matrix.api) implementation(projects.libraries.di) + + testCommonDependencies(libs, false) + testImplementation(projects.libraries.sessionStorage.test) } diff --git a/libraries/workmanager/impl/src/main/kotlin/io/element/android/libraries/workmanager/impl/DefaultWorkManagerScheduler.kt b/libraries/workmanager/impl/src/main/kotlin/io/element/android/libraries/workmanager/impl/DefaultWorkManagerScheduler.kt index 6943c1432eb..4f3806db62e 100644 --- a/libraries/workmanager/impl/src/main/kotlin/io/element/android/libraries/workmanager/impl/DefaultWorkManagerScheduler.kt +++ b/libraries/workmanager/impl/src/main/kotlin/io/element/android/libraries/workmanager/impl/DefaultWorkManagerScheduler.kt @@ -8,12 +8,14 @@ package io.element.android.libraries.workmanager.impl -import android.content.Context +import androidx.work.WorkInfo import androidx.work.WorkManager import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding -import io.element.android.libraries.di.annotations.ApplicationContext +import dev.zacsweers.metro.SingleIn import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.sessionstorage.api.observer.SessionListener +import io.element.android.libraries.sessionstorage.api.observer.SessionObserver import io.element.android.libraries.workmanager.api.WorkManagerRequest import io.element.android.libraries.workmanager.api.WorkManagerRequestType import io.element.android.libraries.workmanager.api.WorkManagerScheduler @@ -21,10 +23,23 @@ import io.element.android.libraries.workmanager.api.workManagerTag import timber.log.Timber @ContributesBinding(AppScope::class) +@SingleIn(AppScope::class) class DefaultWorkManagerScheduler( - @ApplicationContext private val context: Context, + lazyWorkManager: Lazy, + sessionObserver: SessionObserver, ) : WorkManagerScheduler { - private val workManager by lazy { WorkManager.getInstance(context) } + private val workManager by lazyWorkManager + + init { + // Observe session removals to cancel associated work automatically + sessionObserver.addListener(object : SessionListener { + override suspend fun onSessionDeleted(userId: String, wasLastSession: Boolean) { + val sessionId = SessionId(userId) + Timber.d("Session deleted for userId: $userId, cancelling associated workmanager requests") + cancel(sessionId) + } + }) + } override fun submit(workManagerRequest: WorkManagerRequest) { workManagerRequest.build().fold( @@ -37,6 +52,18 @@ class DefaultWorkManagerScheduler( ) } + override fun hasPendingWork(sessionId: SessionId, requestType: WorkManagerRequestType): Boolean { + val workInfos = workManager.getWorkInfosByTag(workManagerTag(sessionId, requestType)).get().orEmpty() + return workInfos.any { info -> + val isPeriodic = info.periodicityInfo != null + val isCancelled = info.state == WorkInfo.State.CANCELLED + // It has pending work if: + // - It's not periodic and is not finished. + // - It's periodic and is not cancelled - since it'll be run again in a next iteration otherwise + !isPeriodic && !info.state.isFinished || isPeriodic && !isCancelled + } + } + override fun cancel(sessionId: SessionId) { Timber.d("Cancelling work for sessionId: $sessionId") for (requestType in WorkManagerRequestType.entries) { diff --git a/libraries/workmanager/impl/src/main/kotlin/io/element/android/libraries/workmanager/impl/WorkManagerModule.kt b/libraries/workmanager/impl/src/main/kotlin/io/element/android/libraries/workmanager/impl/WorkManagerModule.kt new file mode 100644 index 00000000000..7df9e7f4318 --- /dev/null +++ b/libraries/workmanager/impl/src/main/kotlin/io/element/android/libraries/workmanager/impl/WorkManagerModule.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2025 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.workmanager.impl + +import android.content.Context +import androidx.work.WorkManager +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.BindingContainer +import dev.zacsweers.metro.ContributesTo +import dev.zacsweers.metro.Provides +import dev.zacsweers.metro.SingleIn +import io.element.android.libraries.di.annotations.ApplicationContext + +@BindingContainer +@ContributesTo(AppScope::class) +object WorkManagerModule { + @Provides + @SingleIn(AppScope::class) + fun providesWorkManager( + @ApplicationContext context: Context, + ): WorkManager { + return WorkManager.getInstance(context) + } +} diff --git a/libraries/workmanager/impl/src/test/kotlin/io/element/android/libraries/workmanager/impl/DefaultWorkManagerSchedulerTest.kt b/libraries/workmanager/impl/src/test/kotlin/io/element/android/libraries/workmanager/impl/DefaultWorkManagerSchedulerTest.kt new file mode 100644 index 00000000000..b4f964ed120 --- /dev/null +++ b/libraries/workmanager/impl/src/test/kotlin/io/element/android/libraries/workmanager/impl/DefaultWorkManagerSchedulerTest.kt @@ -0,0 +1,105 @@ +/* + * Copyright (c) 2025 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.workmanager.impl + +import androidx.work.WorkManager +import androidx.work.WorkRequest +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.sessionstorage.test.observer.FakeSessionObserver +import io.element.android.libraries.workmanager.api.WorkManagerRequest +import io.element.android.libraries.workmanager.api.WorkManagerRequestType +import io.element.android.libraries.workmanager.api.workManagerTag +import io.mockk.every +import io.mockk.mockk +import io.mockk.spyk +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest +import org.junit.Test + +@OptIn(ExperimentalCoroutinesApi::class) +class DefaultWorkManagerSchedulerTest { + @Test + fun `starts observing sessions on init to remove work for logged out sessions`() = runTest { + val sessionId = "@session1:matrix.org" + val sessionObserver = FakeSessionObserver() + + val workManager = spyk() + + DefaultWorkManagerScheduler( + lazyWorkManager = lazy { workManager }, + sessionObserver = sessionObserver, + ) + + // We remove the session + sessionObserver.onSessionDeleted(sessionId) + + runCurrent() + + // The session is now gone and work associated with the session is cancelled + verify { workManager.cancelAllWorkByTag("notifications-$sessionId") } + } + + @Test + fun `submit builds the request and enqueues it`() = runTest { + val workManager = spyk() + + val scheduler = DefaultWorkManagerScheduler( + lazyWorkManager = lazy { workManager }, + sessionObserver = FakeSessionObserver(), + ) + + scheduler.submit(FakeWorkManagerRequest()) + + verify { workManager.enqueue(any>()) } + } + + @Test + fun `submit won't do anything if building the work request fails`() = runTest { + val workManager = spyk() + + val scheduler = DefaultWorkManagerScheduler( + lazyWorkManager = lazy { workManager }, + sessionObserver = FakeSessionObserver(), + ) + + scheduler.submit(FakeWorkManagerRequest(result = Result.failure(IllegalStateException("Test error")))) + + verify(exactly = 0) { workManager.enqueue(any>()) } + } + + @Test + fun `cancel will cancel all pending work for a session id`() = runTest { + val workManager = spyk() + + val scheduler = DefaultWorkManagerScheduler( + lazyWorkManager = lazy { workManager }, + sessionObserver = FakeSessionObserver(), + ) + + val sessionId = SessionId("@alice:matrix.org") + val tagToRemove = workManagerTag(sessionId, WorkManagerRequestType.NOTIFICATION_SYNC) + val mockSessionA = mockk { + every { tags } returns setOf(tagToRemove) + } + scheduler.submit(FakeWorkManagerRequest(result = Result.success(listOf(mockSessionA)))) + + scheduler.cancel(sessionId) + + verify { workManager.cancelAllWorkByTag(tagToRemove) } + } +} + +private class FakeWorkManagerRequest( + private val result: Result> = Result.success(listOf()), +) : WorkManagerRequest { + override fun build(): Result> { + return result + } +} diff --git a/libraries/workmanager/test/src/main/kotlin/io/element/android/libraries/workmanager/test/FakeWorkManagerScheduler.kt b/libraries/workmanager/test/src/main/kotlin/io/element/android/libraries/workmanager/test/FakeWorkManagerScheduler.kt index 94ee826ddcf..f2caa8c7436 100644 --- a/libraries/workmanager/test/src/main/kotlin/io/element/android/libraries/workmanager/test/FakeWorkManagerScheduler.kt +++ b/libraries/workmanager/test/src/main/kotlin/io/element/android/libraries/workmanager/test/FakeWorkManagerScheduler.kt @@ -10,17 +10,23 @@ package io.element.android.libraries.workmanager.test import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.workmanager.api.WorkManagerRequest +import io.element.android.libraries.workmanager.api.WorkManagerRequestType import io.element.android.libraries.workmanager.api.WorkManagerScheduler import io.element.android.tests.testutils.lambda.lambdaError class FakeWorkManagerScheduler( private val submitLambda: (WorkManagerRequest) -> Unit = { lambdaError() }, + private val hasPendingWorkLambda: (SessionId, WorkManagerRequestType) -> Boolean = { _, _ -> false }, private val cancelLambda: (SessionId) -> Unit = { lambdaError() }, ) : WorkManagerScheduler { override fun submit(workManagerRequest: WorkManagerRequest) { submitLambda(workManagerRequest) } + override fun hasPendingWork(sessionId: SessionId, requestType: WorkManagerRequestType): Boolean { + return hasPendingWorkLambda(sessionId, requestType) + } + override fun cancel(sessionId: SessionId) { cancelLambda(sessionId) } diff --git a/plugins/src/main/kotlin/Versions.kt b/plugins/src/main/kotlin/Versions.kt index d481228585a..039f6cdd85a 100644 --- a/plugins/src/main/kotlin/Versions.kt +++ b/plugins/src/main/kotlin/Versions.kt @@ -46,7 +46,7 @@ private const val versionMonth = 4 * Release number in the month. Value must be in [0,99]. * Do not update this value. it is updated by the release script. */ -private const val versionReleaseNumber = 0 +private const val versionReleaseNumber = 1 object Versions { /** diff --git a/plugins/src/main/kotlin/config/BuildTimeConfig.kt b/plugins/src/main/kotlin/config/BuildTimeConfig.kt index dacf587c3df..d4d33e703cd 100644 --- a/plugins/src/main/kotlin/config/BuildTimeConfig.kt +++ b/plugins/src/main/kotlin/config/BuildTimeConfig.kt @@ -29,6 +29,7 @@ object BuildTimeConfig { val SERVICES_POSTHOG_HOST: String? = null val SERVICES_POSTHOG_APIKEY: String? = null val SERVICES_SENTRY_DSN: String? = null + val SERVICES_SENTRY_DSN_RUST: String? = null val BUG_REPORT_URL: String? = null val BUG_REPORT_APP_NAME: String? = null diff --git a/plugins/src/main/kotlin/extension/locales.kt b/plugins/src/main/kotlin/extension/locales.kt index 31d59b73b88..1af71466819 100644 --- a/plugins/src/main/kotlin/extension/locales.kt +++ b/plugins/src/main/kotlin/extension/locales.kt @@ -18,6 +18,7 @@ val locales = setOf( "fa", "fi", "fr", + "hr", "hu", "in", "it", diff --git a/screenshots/de/features.announcement.impl.spaces_SpaceAnnouncementView_Day_0_de.png b/screenshots/de/features.announcement.impl.spaces_SpaceAnnouncementView_Day_0_de.png index 503aad17151..2d59285f586 100644 --- a/screenshots/de/features.announcement.impl.spaces_SpaceAnnouncementView_Day_0_de.png +++ b/screenshots/de/features.announcement.impl.spaces_SpaceAnnouncementView_Day_0_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bd3a50849007792a0f7c8a2d2662dd156e48f9251f1108b6748bf6b52ace8cf8 -size 69441 +oid sha256:0996d8aed1bd5cb8f99743adb77ea85ade9d65a02aefcc2032714d1b6d1f131d +size 69468 diff --git a/screenshots/de/features.createroom.impl.configureroom_ConfigureRoomViewDark_0_de.png b/screenshots/de/features.createroom.impl.configureroom_ConfigureRoomViewDark_0_de.png index bd0e8752b45..7ca177c1463 100644 --- a/screenshots/de/features.createroom.impl.configureroom_ConfigureRoomViewDark_0_de.png +++ b/screenshots/de/features.createroom.impl.configureroom_ConfigureRoomViewDark_0_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bba0a8575b618446b61d69372af6b8c9a87aec8dd925066bea543c9bda4de758 -size 34195 +oid sha256:153b2806d0c7d5365655f251574dc4d8efdabb2ed7df730d6868ef26c4220aeb +size 35747 diff --git a/screenshots/de/features.createroom.impl.configureroom_ConfigureRoomViewDark_1_de.png b/screenshots/de/features.createroom.impl.configureroom_ConfigureRoomViewDark_1_de.png index d996235ee9b..0193a9231d8 100644 --- a/screenshots/de/features.createroom.impl.configureroom_ConfigureRoomViewDark_1_de.png +++ b/screenshots/de/features.createroom.impl.configureroom_ConfigureRoomViewDark_1_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0a95826d38ba306dad54a21fea3f0c3b348bb388ce3f734d084fb36bb689678b -size 40246 +oid sha256:6a4eec28988e82c97fb8214f34551195c8603f6256116c927db8ab1295213e17 +size 37733 diff --git a/screenshots/de/features.createroom.impl.configureroom_ConfigureRoomViewDark_2_de.png b/screenshots/de/features.createroom.impl.configureroom_ConfigureRoomViewDark_2_de.png index dec36c9974b..18c3031dc11 100644 --- a/screenshots/de/features.createroom.impl.configureroom_ConfigureRoomViewDark_2_de.png +++ b/screenshots/de/features.createroom.impl.configureroom_ConfigureRoomViewDark_2_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:caab417458e9798b4c338de667afc8e8fac9ee8a45168ee60e7e31ddf035f8cd -size 61831 +oid sha256:1821e3f108b027a68f7ace7b1f6d21fb0b23bbf75f89f7d27062e1ea7f1333dc +size 47279 diff --git a/screenshots/de/features.createroom.impl.configureroom_ConfigureRoomViewDark_3_de.png b/screenshots/de/features.createroom.impl.configureroom_ConfigureRoomViewDark_3_de.png index 411a3ed5b83..db5a190e483 100644 --- a/screenshots/de/features.createroom.impl.configureroom_ConfigureRoomViewDark_3_de.png +++ b/screenshots/de/features.createroom.impl.configureroom_ConfigureRoomViewDark_3_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:83e83f59efcff4f7a1d9b01eee242d3384c512f5a1d0623fa9dbfcfdc73a61e0 -size 62570 +oid sha256:d352f8a17349e87fbe4208bbd59a857a829bb6a642f1b784f4e45be8de9121c8 +size 48100 diff --git a/screenshots/de/features.createroom.impl.configureroom_ConfigureRoomViewDark_4_de.png b/screenshots/de/features.createroom.impl.configureroom_ConfigureRoomViewDark_4_de.png index d666f4e02a1..c101ef8fe76 100644 --- a/screenshots/de/features.createroom.impl.configureroom_ConfigureRoomViewDark_4_de.png +++ b/screenshots/de/features.createroom.impl.configureroom_ConfigureRoomViewDark_4_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8f1ced6eb71c9009b54c408e7617d2b945071e64c88caf6eff10a4c8c482aa9a -size 63985 +oid sha256:a1fb60c0aa28717fb826f26829479583d4d5c4e259f051141b6c3f9bc0612737 +size 49652 diff --git a/screenshots/de/features.createroom.impl.configureroom_ConfigureRoomViewDark_5_de.png b/screenshots/de/features.createroom.impl.configureroom_ConfigureRoomViewDark_5_de.png index dec36c9974b..18c3031dc11 100644 --- a/screenshots/de/features.createroom.impl.configureroom_ConfigureRoomViewDark_5_de.png +++ b/screenshots/de/features.createroom.impl.configureroom_ConfigureRoomViewDark_5_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:caab417458e9798b4c338de667afc8e8fac9ee8a45168ee60e7e31ddf035f8cd -size 61831 +oid sha256:1821e3f108b027a68f7ace7b1f6d21fb0b23bbf75f89f7d27062e1ea7f1333dc +size 47279 diff --git a/screenshots/de/features.createroom.impl.configureroom_ConfigureRoomViewDark_6_de.png b/screenshots/de/features.createroom.impl.configureroom_ConfigureRoomViewDark_6_de.png new file mode 100644 index 00000000000..a4c473986c8 --- /dev/null +++ b/screenshots/de/features.createroom.impl.configureroom_ConfigureRoomViewDark_6_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7f875911124d060cf6dda0b5c20c66a6da7e398e5d3a4682dbd1919d0113403d +size 48260 diff --git a/screenshots/de/features.createroom.impl.configureroom_ConfigureRoomViewLight_0_de.png b/screenshots/de/features.createroom.impl.configureroom_ConfigureRoomViewLight_0_de.png index 74ba2521406..27abd5e7aa1 100644 --- a/screenshots/de/features.createroom.impl.configureroom_ConfigureRoomViewLight_0_de.png +++ b/screenshots/de/features.createroom.impl.configureroom_ConfigureRoomViewLight_0_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bf37caa5c17b00d268041c4897b9b148f7d7d4e16dd4c0938c05b6e1b9fd0e84 -size 35289 +oid sha256:a13e8b7def20d642afabca21352c1794375540f750eadd5fc72d936058fda1f1 +size 37054 diff --git a/screenshots/de/features.createroom.impl.configureroom_ConfigureRoomViewLight_1_de.png b/screenshots/de/features.createroom.impl.configureroom_ConfigureRoomViewLight_1_de.png index 32acfffcc85..14a0e6206bf 100644 --- a/screenshots/de/features.createroom.impl.configureroom_ConfigureRoomViewLight_1_de.png +++ b/screenshots/de/features.createroom.impl.configureroom_ConfigureRoomViewLight_1_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:43586d8414cb23ab363491c57b7c4bb22a95d2bb55b8dc1d1507054a67a69f7f -size 41598 +oid sha256:ae79dbebfa8c9ba79adb7090c685a20c11ff61e3d975a4d70441a4f975f04960 +size 39147 diff --git a/screenshots/de/features.createroom.impl.configureroom_ConfigureRoomViewLight_2_de.png b/screenshots/de/features.createroom.impl.configureroom_ConfigureRoomViewLight_2_de.png index 726a329f486..b2cdc0fea71 100644 --- a/screenshots/de/features.createroom.impl.configureroom_ConfigureRoomViewLight_2_de.png +++ b/screenshots/de/features.createroom.impl.configureroom_ConfigureRoomViewLight_2_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:27f2ddc3924cea4dfc4f7ee4b077dc2e9a3fb33122a4f0fb1fade3322f305815 -size 63881 +oid sha256:78a0a71076d0a1dc741e7e32e93d75df12b89e4f278ea4dfa8aa28e780f530d2 +size 49129 diff --git a/screenshots/de/features.createroom.impl.configureroom_ConfigureRoomViewLight_3_de.png b/screenshots/de/features.createroom.impl.configureroom_ConfigureRoomViewLight_3_de.png index cdbed88a651..7b03f8e40f7 100644 --- a/screenshots/de/features.createroom.impl.configureroom_ConfigureRoomViewLight_3_de.png +++ b/screenshots/de/features.createroom.impl.configureroom_ConfigureRoomViewLight_3_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d2fd3ba9c6bfa6de0cb78195fdda1677408281f5c408996ef8d6ae3e1567f83b -size 64670 +oid sha256:cc2375bed58e7104fadb739c4d49eebd793e35e8a72c113782515b3cedbd31ea +size 49983 diff --git a/screenshots/de/features.createroom.impl.configureroom_ConfigureRoomViewLight_4_de.png b/screenshots/de/features.createroom.impl.configureroom_ConfigureRoomViewLight_4_de.png index 99b5863d779..8ce919a69a5 100644 --- a/screenshots/de/features.createroom.impl.configureroom_ConfigureRoomViewLight_4_de.png +++ b/screenshots/de/features.createroom.impl.configureroom_ConfigureRoomViewLight_4_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:07e66f22455d1c79851b28807bab15ee114f45bca794c6405358c47d8a313bcc -size 66193 +oid sha256:484f21f65a0a74bae1aa96ea71f6649e1560204c8ac5c4e14af6efd1c719a75b +size 51672 diff --git a/screenshots/de/features.createroom.impl.configureroom_ConfigureRoomViewLight_5_de.png b/screenshots/de/features.createroom.impl.configureroom_ConfigureRoomViewLight_5_de.png index 726a329f486..b2cdc0fea71 100644 --- a/screenshots/de/features.createroom.impl.configureroom_ConfigureRoomViewLight_5_de.png +++ b/screenshots/de/features.createroom.impl.configureroom_ConfigureRoomViewLight_5_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:27f2ddc3924cea4dfc4f7ee4b077dc2e9a3fb33122a4f0fb1fade3322f305815 -size 63881 +oid sha256:78a0a71076d0a1dc741e7e32e93d75df12b89e4f278ea4dfa8aa28e780f530d2 +size 49129 diff --git a/screenshots/de/features.createroom.impl.configureroom_ConfigureRoomViewLight_6_de.png b/screenshots/de/features.createroom.impl.configureroom_ConfigureRoomViewLight_6_de.png new file mode 100644 index 00000000000..9de401ad7a2 --- /dev/null +++ b/screenshots/de/features.createroom.impl.configureroom_ConfigureRoomViewLight_6_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:93eb08d49cd64cfb211cbf515d751f16d75a4c6bcb9daf94e12c8edf2cb91d0f +size 49964 diff --git a/screenshots/de/features.home.impl.components_RoomListContentView_Day_0_de.png b/screenshots/de/features.home.impl.components_RoomListContentView_Day_0_de.png index 718868cb852..ebed766b402 100644 --- a/screenshots/de/features.home.impl.components_RoomListContentView_Day_0_de.png +++ b/screenshots/de/features.home.impl.components_RoomListContentView_Day_0_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:dc274e63a8428f432ae8ddb7d91cfabffef8d43f130c91c8927427194defb40b -size 43970 +oid sha256:cd5a8bb187b96200fb85a79a57c6ad555324df7fd825615053adb15f4b7a07db +size 44076 diff --git a/screenshots/de/features.home.impl.components_RoomListContentView_Day_5_de.png b/screenshots/de/features.home.impl.components_RoomListContentView_Day_5_de.png index 3a04710ab30..537e7cd715c 100644 --- a/screenshots/de/features.home.impl.components_RoomListContentView_Day_5_de.png +++ b/screenshots/de/features.home.impl.components_RoomListContentView_Day_5_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:cf1ae6e0f2fbdb68ef717a5dc96b25ac771a3ad32f3434f75564550816f72eb2 -size 64067 +oid sha256:59cf2e0b4094609c7748ca389c9afd0e84fcbeba22a9e435c890e94f164c8fe2 +size 64367 diff --git a/screenshots/de/features.home.impl.components_RoomSummaryRow_Day_37_de.png b/screenshots/de/features.home.impl.components_RoomSummaryRow_Day_37_de.png new file mode 100644 index 00000000000..f919d8a3de6 --- /dev/null +++ b/screenshots/de/features.home.impl.components_RoomSummaryRow_Day_37_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c59f0552149c52947cc5d2ffac8879921d7db853cb16ded5bba0559efe26f8fd +size 16055 diff --git a/screenshots/de/features.home.impl.search_RoomListSearchContent_Day_1_de.png b/screenshots/de/features.home.impl.search_RoomListSearchContent_Day_1_de.png index 5c7cd8673c9..1c4fb92e9c4 100644 --- a/screenshots/de/features.home.impl.search_RoomListSearchContent_Day_1_de.png +++ b/screenshots/de/features.home.impl.search_RoomListSearchContent_Day_1_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1f6a4818c375c234a36c4187d92a394f4056bada31d7825c63806f4a81cdce1b -size 45989 +oid sha256:f5ee7f6355bab9a287fdafcc62463798c82a4fd41a08b00a6cc7feb6e47de9a2 +size 46251 diff --git a/screenshots/de/features.home.impl.spaces_HomeSpacesView_Day_2_de.png b/screenshots/de/features.home.impl.spaces_HomeSpacesView_Day_2_de.png new file mode 100644 index 00000000000..11761bef37b --- /dev/null +++ b/screenshots/de/features.home.impl.spaces_HomeSpacesView_Day_2_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7a93222cebbc0bc6ba382b10bef1118edf67fdf520b37038d0c47725e3c9e831 +size 42115 diff --git a/screenshots/de/features.home.impl_HomeView_Day_0_de.png b/screenshots/de/features.home.impl_HomeView_Day_0_de.png index f7a54299c16..a1dc5a763e8 100644 --- a/screenshots/de/features.home.impl_HomeView_Day_0_de.png +++ b/screenshots/de/features.home.impl_HomeView_Day_0_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d24343a46913ad6c047a4395088dd07ca87899563a8e44a92d0393ff62bd72ff -size 63660 +oid sha256:ede89923763221905dbae3033521df9f1cbc8963d3e79a6e1a560bfc034687e5 +size 67281 diff --git a/screenshots/de/features.home.impl_HomeView_Day_10_de.png b/screenshots/de/features.home.impl_HomeView_Day_10_de.png index 069dd23b63c..a380abb6317 100644 --- a/screenshots/de/features.home.impl_HomeView_Day_10_de.png +++ b/screenshots/de/features.home.impl_HomeView_Day_10_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:62d1ad64d8d096a34458c7845fb800fa0cd605461f3ef4fe0699d30ca03c7925 -size 33284 +oid sha256:4acc7d49f09091fe4d675c13c2cd6ecfd1cd9b471e3fa12f04657f91c2c4a6a8 +size 37021 diff --git a/screenshots/de/features.home.impl_HomeView_Day_13_de.png b/screenshots/de/features.home.impl_HomeView_Day_13_de.png index 3ff79f211a8..99c54fe8a29 100644 --- a/screenshots/de/features.home.impl_HomeView_Day_13_de.png +++ b/screenshots/de/features.home.impl_HomeView_Day_13_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:cb610fdae1d9e7b90787347487e8c7fe36349b6bbf6026a9132465ada21ac860 -size 95048 +oid sha256:e13c5a998a4903daa9fddfb7ca8583e710674403afdf801f7a37a315775414b5 +size 89311 diff --git a/screenshots/de/features.home.impl_HomeView_Day_14_de.png b/screenshots/de/features.home.impl_HomeView_Day_14_de.png index c424e73a0fe..db81606d72a 100644 --- a/screenshots/de/features.home.impl_HomeView_Day_14_de.png +++ b/screenshots/de/features.home.impl_HomeView_Day_14_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0554ef990f666f6ec3c17bc57fb3fe58a82d966c26fd2b79fff8c1e6b069a193 -size 89633 +oid sha256:26bb5998203b72484f9428c785e8553b252667fa3adeec0474ffb090b6f98e2b +size 86242 diff --git a/screenshots/de/features.home.impl_HomeView_Day_15_de.png b/screenshots/de/features.home.impl_HomeView_Day_15_de.png index 6ac7326cdf2..eb6efe51581 100644 --- a/screenshots/de/features.home.impl_HomeView_Day_15_de.png +++ b/screenshots/de/features.home.impl_HomeView_Day_15_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b7ab0fb505552504a2b069118818f82738f1c364e54f4a7298fecbfcee067f53 -size 55052 +oid sha256:1c42e7a3a004be541769274234393ca31680c871dd2cbb5e48442926b6a49421 +size 55036 diff --git a/screenshots/de/features.home.impl_HomeView_Day_1_de.png b/screenshots/de/features.home.impl_HomeView_Day_1_de.png index f7a54299c16..a1dc5a763e8 100644 --- a/screenshots/de/features.home.impl_HomeView_Day_1_de.png +++ b/screenshots/de/features.home.impl_HomeView_Day_1_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d24343a46913ad6c047a4395088dd07ca87899563a8e44a92d0393ff62bd72ff -size 63660 +oid sha256:ede89923763221905dbae3033521df9f1cbc8963d3e79a6e1a560bfc034687e5 +size 67281 diff --git a/screenshots/de/features.home.impl_HomeView_Day_2_de.png b/screenshots/de/features.home.impl_HomeView_Day_2_de.png index f7a54299c16..a1dc5a763e8 100644 --- a/screenshots/de/features.home.impl_HomeView_Day_2_de.png +++ b/screenshots/de/features.home.impl_HomeView_Day_2_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d24343a46913ad6c047a4395088dd07ca87899563a8e44a92d0393ff62bd72ff -size 63660 +oid sha256:ede89923763221905dbae3033521df9f1cbc8963d3e79a6e1a560bfc034687e5 +size 67281 diff --git a/screenshots/de/features.home.impl_HomeView_Day_4_de.png b/screenshots/de/features.home.impl_HomeView_Day_4_de.png index ffba89ca4f0..4fa57e9cf61 100644 --- a/screenshots/de/features.home.impl_HomeView_Day_4_de.png +++ b/screenshots/de/features.home.impl_HomeView_Day_4_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8902154fddfa359e605cf95c715e080a5b027a79bac4ff0026464c20dd29311a -size 55725 +oid sha256:238c0cc5521bf1a151226a73282a8bf1e0b53cb06e8ce10f7326a9a51aab8e1b +size 55937 diff --git a/screenshots/de/features.home.impl_HomeView_Day_5_de.png b/screenshots/de/features.home.impl_HomeView_Day_5_de.png index f7a54299c16..a1dc5a763e8 100644 --- a/screenshots/de/features.home.impl_HomeView_Day_5_de.png +++ b/screenshots/de/features.home.impl_HomeView_Day_5_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d24343a46913ad6c047a4395088dd07ca87899563a8e44a92d0393ff62bd72ff -size 63660 +oid sha256:ede89923763221905dbae3033521df9f1cbc8963d3e79a6e1a560bfc034687e5 +size 67281 diff --git a/screenshots/de/features.home.impl_HomeView_Day_9_de.png b/screenshots/de/features.home.impl_HomeView_Day_9_de.png index 05978a718ff..08e3a7d89a5 100644 --- a/screenshots/de/features.home.impl_HomeView_Day_9_de.png +++ b/screenshots/de/features.home.impl_HomeView_Day_9_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4cc0259ebbd085fe4c915e999f2fa88413da74211c1d1a5f4ee2e8d0cc18f641 -size 90765 +oid sha256:ab9eaf4843f6d0cf375d78749235753da89f6488d52e68be411209906943197c +size 84935 diff --git a/screenshots/de/features.invitepeople.impl_InvitePeopleView_Day_0_de.png b/screenshots/de/features.invitepeople.impl_InvitePeopleView_Day_0_de.png index 2a28b1e8f49..0402613534a 100644 --- a/screenshots/de/features.invitepeople.impl_InvitePeopleView_Day_0_de.png +++ b/screenshots/de/features.invitepeople.impl_InvitePeopleView_Day_0_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4bae9cb2503981a199cf82e53a538e2cd5d5071a4d49ea0dd9ddc6efc8eaff69 -size 9393 +oid sha256:2a5b7c5d7fa9a6f9156f25aa565944028149a7d2ab9f418a0b02a17d948a6768 +size 9415 diff --git a/screenshots/de/features.invitepeople.impl_InvitePeopleView_Day_1_de.png b/screenshots/de/features.invitepeople.impl_InvitePeopleView_Day_1_de.png index f2d7135b768..999bb266e8d 100644 --- a/screenshots/de/features.invitepeople.impl_InvitePeopleView_Day_1_de.png +++ b/screenshots/de/features.invitepeople.impl_InvitePeopleView_Day_1_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:12e179c18e4fe4dd12f72be6e4dd3ea3f428220206ff179f11873156201bb8b0 -size 21703 +oid sha256:53643845f3b082175ed3a33e0d8fd6edc4006cbe74a0c9db7a7100c2351acc4d +size 21744 diff --git a/screenshots/de/features.invitepeople.impl_InvitePeopleView_Day_4_de.png b/screenshots/de/features.invitepeople.impl_InvitePeopleView_Day_4_de.png index 7c2afb45b55..5472de64d6c 100644 --- a/screenshots/de/features.invitepeople.impl_InvitePeopleView_Day_4_de.png +++ b/screenshots/de/features.invitepeople.impl_InvitePeopleView_Day_4_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bf1cafe9f59259e325cc772768436da6615bc8796c758882c2eab1f2ded58ac3 -size 9905 +oid sha256:51b14d3633dd614fcad706c714e93447dc3ac6039ef0cec6213f77a3c24cf2b3 +size 9984 diff --git a/screenshots/de/features.invitepeople.impl_InvitePeopleView_Day_5_de.png b/screenshots/de/features.invitepeople.impl_InvitePeopleView_Day_5_de.png index 0155d2b0864..ba1c5b663ce 100644 --- a/screenshots/de/features.invitepeople.impl_InvitePeopleView_Day_5_de.png +++ b/screenshots/de/features.invitepeople.impl_InvitePeopleView_Day_5_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9f40663092e27a21969d6a7d43ecf384cb978c4ba473a2d3dc59d4c9a4896b63 -size 39241 +oid sha256:7402d0fb1b714f733d3929955fcbf0b096187cf70e3503acc21ffa089142610f +size 38851 diff --git a/screenshots/de/features.invitepeople.impl_InvitePeopleView_Day_6_de.png b/screenshots/de/features.invitepeople.impl_InvitePeopleView_Day_6_de.png index ebc6bab03b0..d46d8971aae 100644 --- a/screenshots/de/features.invitepeople.impl_InvitePeopleView_Day_6_de.png +++ b/screenshots/de/features.invitepeople.impl_InvitePeopleView_Day_6_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c64c5ad385c27b8db8ddf0612b6b45e6b77699292154c1291c862e110775f4cd -size 38286 +oid sha256:2457c74365a98c64974eeaf607879461d2cd9a1757096f330d913fb034816b31 +size 38734 diff --git a/screenshots/de/features.invitepeople.impl_InvitePeopleView_Day_7_de.png b/screenshots/de/features.invitepeople.impl_InvitePeopleView_Day_7_de.png index 2324cdc8cd8..988c8dd3187 100644 --- a/screenshots/de/features.invitepeople.impl_InvitePeopleView_Day_7_de.png +++ b/screenshots/de/features.invitepeople.impl_InvitePeopleView_Day_7_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d7ab5834d27935ba64544d7d768ba7aba1144bb127c6cf902d05c103394d2057 -size 30295 +oid sha256:045a5183f88a998968af1f1f8a02e864697add38b1259b6e40fda702dce82b48 +size 30661 diff --git a/screenshots/de/features.invitepeople.impl_InvitePeopleView_Day_9_de.png b/screenshots/de/features.invitepeople.impl_InvitePeopleView_Day_9_de.png index f2d7135b768..999bb266e8d 100644 --- a/screenshots/de/features.invitepeople.impl_InvitePeopleView_Day_9_de.png +++ b/screenshots/de/features.invitepeople.impl_InvitePeopleView_Day_9_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:12e179c18e4fe4dd12f72be6e4dd3ea3f428220206ff179f11873156201bb8b0 -size 21703 +oid sha256:53643845f3b082175ed3a33e0d8fd6edc4006cbe74a0c9db7a7100c2351acc4d +size 21744 diff --git a/screenshots/de/features.linknewdevice.impl.screens.desktop_DesktopNoticeView_Day_0_de.png b/screenshots/de/features.linknewdevice.impl.screens.desktop_DesktopNoticeView_Day_0_de.png new file mode 100644 index 00000000000..2258304d986 --- /dev/null +++ b/screenshots/de/features.linknewdevice.impl.screens.desktop_DesktopNoticeView_Day_0_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6d255b02f1bc12f10201111717a41404171556b8a5a4d45c65370db6d1fdef0e +size 49852 diff --git a/screenshots/de/features.linknewdevice.impl.screens.desktop_DesktopNoticeView_Day_1_de.png b/screenshots/de/features.linknewdevice.impl.screens.desktop_DesktopNoticeView_Day_1_de.png new file mode 100644 index 00000000000..8dca9fed7dd --- /dev/null +++ b/screenshots/de/features.linknewdevice.impl.screens.desktop_DesktopNoticeView_Day_1_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:48e2d8fffea5b23d7894fc546dac33bb87d7df5267aa58d3f79a14f7f93ee85a +size 44823 diff --git a/screenshots/de/features.linknewdevice.impl.screens.error_ErrorView_Day_0_de.png b/screenshots/de/features.linknewdevice.impl.screens.error_ErrorView_Day_0_de.png new file mode 100644 index 00000000000..65573d908fd --- /dev/null +++ b/screenshots/de/features.linknewdevice.impl.screens.error_ErrorView_Day_0_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3fc70436b6ccb1afd77bed07598c020796341aa100b1eab6dc5b00868297dca6 +size 24990 diff --git a/screenshots/de/features.linknewdevice.impl.screens.error_ErrorView_Day_1_de.png b/screenshots/de/features.linknewdevice.impl.screens.error_ErrorView_Day_1_de.png new file mode 100644 index 00000000000..c7cf5a7efa8 --- /dev/null +++ b/screenshots/de/features.linknewdevice.impl.screens.error_ErrorView_Day_1_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:50c28eb8481b1101f75bf8341481ead0e77daf116207d27ddc5c95316daf8e41 +size 22035 diff --git a/screenshots/de/features.linknewdevice.impl.screens.error_ErrorView_Day_2_de.png b/screenshots/de/features.linknewdevice.impl.screens.error_ErrorView_Day_2_de.png new file mode 100644 index 00000000000..44685113560 --- /dev/null +++ b/screenshots/de/features.linknewdevice.impl.screens.error_ErrorView_Day_2_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:20558650fc6a3859569d52baaab1c8af2a68e2944fd772f0d62ced5c1aa44208 +size 29397 diff --git a/screenshots/de/features.linknewdevice.impl.screens.error_ErrorView_Day_3_de.png b/screenshots/de/features.linknewdevice.impl.screens.error_ErrorView_Day_3_de.png new file mode 100644 index 00000000000..3603b23b246 --- /dev/null +++ b/screenshots/de/features.linknewdevice.impl.screens.error_ErrorView_Day_3_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ba73d59bab19bc1263174c632f6ca3bfed98d11b1bb949e9340b02bb5c666580 +size 38741 diff --git a/screenshots/de/features.linknewdevice.impl.screens.error_ErrorView_Day_4_de.png b/screenshots/de/features.linknewdevice.impl.screens.error_ErrorView_Day_4_de.png new file mode 100644 index 00000000000..64cdb63c154 --- /dev/null +++ b/screenshots/de/features.linknewdevice.impl.screens.error_ErrorView_Day_4_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3cfdfd9b233eacb527f3f108fe89eaeab38c6338852cddeb7803deb4ec8f3468 +size 41492 diff --git a/screenshots/de/features.linknewdevice.impl.screens.error_ErrorView_Day_5_de.png b/screenshots/de/features.linknewdevice.impl.screens.error_ErrorView_Day_5_de.png new file mode 100644 index 00000000000..33122e6dead --- /dev/null +++ b/screenshots/de/features.linknewdevice.impl.screens.error_ErrorView_Day_5_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b39882c9c6d9920d0f76f3b80599b7b41cc6eab4480c6a10f09cf0992acbf203 +size 68940 diff --git a/screenshots/de/features.linknewdevice.impl.screens.error_ErrorView_Day_6_de.png b/screenshots/de/features.linknewdevice.impl.screens.error_ErrorView_Day_6_de.png new file mode 100644 index 00000000000..8d47b730a2f --- /dev/null +++ b/screenshots/de/features.linknewdevice.impl.screens.error_ErrorView_Day_6_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c9bc94377c776271128fd411397d698539fdf74870b074988b8de1c86a4ecec0 +size 21798 diff --git a/screenshots/de/features.linknewdevice.impl.screens.error_ErrorView_Day_7_de.png b/screenshots/de/features.linknewdevice.impl.screens.error_ErrorView_Day_7_de.png new file mode 100644 index 00000000000..5f79162d1b1 --- /dev/null +++ b/screenshots/de/features.linknewdevice.impl.screens.error_ErrorView_Day_7_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c184323fa1b1d87f46be6f49bf983f7d4048b5547906531b452924ee89110d30 +size 23962 diff --git a/screenshots/de/features.linknewdevice.impl.screens.number_EnterNumberView_Day_0_de.png b/screenshots/de/features.linknewdevice.impl.screens.number_EnterNumberView_Day_0_de.png new file mode 100644 index 00000000000..90b3890bc98 --- /dev/null +++ b/screenshots/de/features.linknewdevice.impl.screens.number_EnterNumberView_Day_0_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:35aed1260eddc19b72ba7f9a4a09f75635ec24f0b2910ac506e484a0140cf019 +size 38921 diff --git a/screenshots/de/features.linknewdevice.impl.screens.number_EnterNumberView_Day_1_de.png b/screenshots/de/features.linknewdevice.impl.screens.number_EnterNumberView_Day_1_de.png new file mode 100644 index 00000000000..7d5c5b3a7e8 --- /dev/null +++ b/screenshots/de/features.linknewdevice.impl.screens.number_EnterNumberView_Day_1_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b85fde469fec530130cfa2c9cfbe00d96b25795bec3010570054a73cd0d3f352 +size 38889 diff --git a/screenshots/de/features.linknewdevice.impl.screens.number_EnterNumberView_Day_2_de.png b/screenshots/de/features.linknewdevice.impl.screens.number_EnterNumberView_Day_2_de.png new file mode 100644 index 00000000000..589ff5978d6 --- /dev/null +++ b/screenshots/de/features.linknewdevice.impl.screens.number_EnterNumberView_Day_2_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:355be1eec264a02a8662b312208b8e47908081dc1e82dbb6b54277e396c63b01 +size 39466 diff --git a/screenshots/de/features.linknewdevice.impl.screens.number_EnterNumberView_Day_3_de.png b/screenshots/de/features.linknewdevice.impl.screens.number_EnterNumberView_Day_3_de.png new file mode 100644 index 00000000000..387b28e38a0 --- /dev/null +++ b/screenshots/de/features.linknewdevice.impl.screens.number_EnterNumberView_Day_3_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e5925d26ac2ee7a415304f4d7f7456b0e6dc281ba5389d192a4d7adb868f8c51 +size 39606 diff --git a/screenshots/de/features.linknewdevice.impl.screens.number_EnterNumberView_Day_4_de.png b/screenshots/de/features.linknewdevice.impl.screens.number_EnterNumberView_Day_4_de.png new file mode 100644 index 00000000000..2ab873a7908 --- /dev/null +++ b/screenshots/de/features.linknewdevice.impl.screens.number_EnterNumberView_Day_4_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:88628fa76195ff667c743484a077c44e8cd9701a051706c47911abfb806a84e7 +size 43642 diff --git a/screenshots/de/features.linknewdevice.impl.screens.number_EnterNumberView_Day_5_de.png b/screenshots/de/features.linknewdevice.impl.screens.number_EnterNumberView_Day_5_de.png new file mode 100644 index 00000000000..e0a7b37ac0b --- /dev/null +++ b/screenshots/de/features.linknewdevice.impl.screens.number_EnterNumberView_Day_5_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5707358f700b47a57dac17491b7eacfe70f045ab0fceb240071d8b264c85622e +size 42617 diff --git a/screenshots/de/features.linknewdevice.impl.screens.qrcode_ShowQrCodeView_Day_0_de.png b/screenshots/de/features.linknewdevice.impl.screens.qrcode_ShowQrCodeView_Day_0_de.png new file mode 100644 index 00000000000..fa7a594d8da --- /dev/null +++ b/screenshots/de/features.linknewdevice.impl.screens.qrcode_ShowQrCodeView_Day_0_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d4f376a429697bd619122b695cda372e5320dbeea106490e5769970427893303 +size 34302 diff --git a/screenshots/de/features.linknewdevice.impl.screens.root_LinkNewDeviceRootView_Day_0_de.png b/screenshots/de/features.linknewdevice.impl.screens.root_LinkNewDeviceRootView_Day_0_de.png new file mode 100644 index 00000000000..e122019fb7e --- /dev/null +++ b/screenshots/de/features.linknewdevice.impl.screens.root_LinkNewDeviceRootView_Day_0_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5dc912e2c23ace736a849c38fc51051f99ad7c92a304685e59942d29acbe786a +size 19424 diff --git a/screenshots/de/features.linknewdevice.impl.screens.root_LinkNewDeviceRootView_Day_1_de.png b/screenshots/de/features.linknewdevice.impl.screens.root_LinkNewDeviceRootView_Day_1_de.png new file mode 100644 index 00000000000..855fcbcaa38 --- /dev/null +++ b/screenshots/de/features.linknewdevice.impl.screens.root_LinkNewDeviceRootView_Day_1_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ab3d38acf432c2fd2252af4f728303919de081f80bac9f408ff916d6ffb85b86 +size 25747 diff --git a/screenshots/de/features.linknewdevice.impl.screens.root_LinkNewDeviceRootView_Day_2_de.png b/screenshots/de/features.linknewdevice.impl.screens.root_LinkNewDeviceRootView_Day_2_de.png new file mode 100644 index 00000000000..6f847cf8966 --- /dev/null +++ b/screenshots/de/features.linknewdevice.impl.screens.root_LinkNewDeviceRootView_Day_2_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d3f51352e91d5eb2d80d61d43b8004718437129040c810e326888df256688d77 +size 28368 diff --git a/screenshots/de/features.linknewdevice.impl.screens.root_LinkNewDeviceRootView_Day_3_de.png b/screenshots/de/features.linknewdevice.impl.screens.root_LinkNewDeviceRootView_Day_3_de.png new file mode 100644 index 00000000000..8b15b0e596a --- /dev/null +++ b/screenshots/de/features.linknewdevice.impl.screens.root_LinkNewDeviceRootView_Day_3_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:779b8306902a7a736b28c832854d8f0107da72497ac4a2427db56f85c647235c +size 26240 diff --git a/screenshots/de/features.linknewdevice.impl.screens.root_LinkNewDeviceRootView_Day_4_de.png b/screenshots/de/features.linknewdevice.impl.screens.root_LinkNewDeviceRootView_Day_4_de.png new file mode 100644 index 00000000000..d615c54d4d1 --- /dev/null +++ b/screenshots/de/features.linknewdevice.impl.screens.root_LinkNewDeviceRootView_Day_4_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:59c3e6ab7c7bea73de824e785dc4d28fc66d19a06266128c715e3de70d72f16f +size 26459 diff --git a/screenshots/de/features.linknewdevice.impl.screens.root_LinkNewDeviceRootView_Day_5_de.png b/screenshots/de/features.linknewdevice.impl.screens.root_LinkNewDeviceRootView_Day_5_de.png new file mode 100644 index 00000000000..e41385a7abe --- /dev/null +++ b/screenshots/de/features.linknewdevice.impl.screens.root_LinkNewDeviceRootView_Day_5_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7297874baa52933de2b305a8bbc16f58a001c2bd96331f19575085477d5a4afd +size 35381 diff --git a/screenshots/de/features.linknewdevice.impl.screens.scan_ScanQrCodeView_Day_0_de.png b/screenshots/de/features.linknewdevice.impl.screens.scan_ScanQrCodeView_Day_0_de.png new file mode 100644 index 00000000000..153df13caab --- /dev/null +++ b/screenshots/de/features.linknewdevice.impl.screens.scan_ScanQrCodeView_Day_0_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:adda1a1a7c20d0eb54528f5f8c60e13c2e8428afc92d849f882f25d727136687 +size 15861 diff --git a/screenshots/de/features.linknewdevice.impl.screens.scan_ScanQrCodeView_Day_1_de.png b/screenshots/de/features.linknewdevice.impl.screens.scan_ScanQrCodeView_Day_1_de.png new file mode 100644 index 00000000000..d50a67ef387 --- /dev/null +++ b/screenshots/de/features.linknewdevice.impl.screens.scan_ScanQrCodeView_Day_1_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a12dbc4f2fb3d12b1848e2ac4e079522613df2493d0a416f2f47827eb0daa322 +size 16502 diff --git a/screenshots/de/features.linknewdevice.impl.screens.scan_ScanQrCodeView_Day_2_de.png b/screenshots/de/features.linknewdevice.impl.screens.scan_ScanQrCodeView_Day_2_de.png new file mode 100644 index 00000000000..b7862928961 --- /dev/null +++ b/screenshots/de/features.linknewdevice.impl.screens.scan_ScanQrCodeView_Day_2_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3e19add1c5894aa7dbad4bc1fff8f3789584946ca1069c3b39b17ed826f4a06c +size 16673 diff --git a/screenshots/de/features.linknewdevice.impl.screens.scan_ScanQrCodeView_Day_3_de.png b/screenshots/de/features.linknewdevice.impl.screens.scan_ScanQrCodeView_Day_3_de.png new file mode 100644 index 00000000000..305f24b9d74 --- /dev/null +++ b/screenshots/de/features.linknewdevice.impl.screens.scan_ScanQrCodeView_Day_3_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a6a773294a47272bcc0333e7a325e0609ef53d576eca1d40422b0855cc48dae7 +size 31739 diff --git a/screenshots/de/features.lockscreen.impl.unlock_PinUnlockView_Day_0_de.png b/screenshots/de/features.lockscreen.impl.unlock_PinUnlockView_Day_0_de.png index cc177bb9f6c..e09362dd037 100644 --- a/screenshots/de/features.lockscreen.impl.unlock_PinUnlockView_Day_0_de.png +++ b/screenshots/de/features.lockscreen.impl.unlock_PinUnlockView_Day_0_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d46d8b8f49f4a15e342099444367b5ea84d0663ce24d1b125f128e1a5e42c7ba -size 38378 +oid sha256:fa4cf17898cfd78c295bcacb9df84a30e2bde4a3d819cce2a737c887976d1a7e +size 38448 diff --git a/screenshots/de/features.lockscreen.impl.unlock_PinUnlockView_Day_1_de.png b/screenshots/de/features.lockscreen.impl.unlock_PinUnlockView_Day_1_de.png index f2d4e3c45ea..ed1ba7c97d4 100644 --- a/screenshots/de/features.lockscreen.impl.unlock_PinUnlockView_Day_1_de.png +++ b/screenshots/de/features.lockscreen.impl.unlock_PinUnlockView_Day_1_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:442886da954416ff3d3d5a9833af2c906490f6e58792e5ac64435fc754545942 -size 38777 +oid sha256:8066d263a899ca57f633bd2d6792c249f6a5674ca523fd1afec337b074f854b3 +size 38849 diff --git a/screenshots/de/features.lockscreen.impl.unlock_PinUnlockView_Day_2_de.png b/screenshots/de/features.lockscreen.impl.unlock_PinUnlockView_Day_2_de.png index cf7f7a70b8e..0045d8f0c3e 100644 --- a/screenshots/de/features.lockscreen.impl.unlock_PinUnlockView_Day_2_de.png +++ b/screenshots/de/features.lockscreen.impl.unlock_PinUnlockView_Day_2_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0056002441e3a5632172c328ec764f08b7482b4c294aa46bc58517e97f333329 -size 39063 +oid sha256:b002741ad82c48b8b8e48303cf7473d1da596437809119093e004588a50e4bf7 +size 39136 diff --git a/screenshots/de/features.lockscreen.impl.unlock_PinUnlockView_Day_3_de.png b/screenshots/de/features.lockscreen.impl.unlock_PinUnlockView_Day_3_de.png index 78b50bdac7a..f63895de694 100644 --- a/screenshots/de/features.lockscreen.impl.unlock_PinUnlockView_Day_3_de.png +++ b/screenshots/de/features.lockscreen.impl.unlock_PinUnlockView_Day_3_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:77b60d10bdf6f4805fd8414f2ac06550014cf7dae09741475dd9dd680969e32f -size 44276 +oid sha256:60b61665c7356642f4775acb9968d01d43817dd67801afca9ad5b60d6b570b0f +size 44321 diff --git a/screenshots/de/features.lockscreen.impl.unlock_PinUnlockView_Day_4_de.png b/screenshots/de/features.lockscreen.impl.unlock_PinUnlockView_Day_4_de.png index a933e303398..b2a0b0915b7 100644 --- a/screenshots/de/features.lockscreen.impl.unlock_PinUnlockView_Day_4_de.png +++ b/screenshots/de/features.lockscreen.impl.unlock_PinUnlockView_Day_4_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:dc498359b64e564bbe93584bf06a5dee85e69bf087739b06cf8f6b45f9ef8a38 -size 35777 +oid sha256:27b58735f417e5883f45ff9a40fdffc8250f1db728ac0de7410d0f8d7021e5e0 +size 35846 diff --git a/screenshots/de/features.lockscreen.impl.unlock_PinUnlockView_Day_5_de.png b/screenshots/de/features.lockscreen.impl.unlock_PinUnlockView_Day_5_de.png index e4109aa8486..81789e7884a 100644 --- a/screenshots/de/features.lockscreen.impl.unlock_PinUnlockView_Day_5_de.png +++ b/screenshots/de/features.lockscreen.impl.unlock_PinUnlockView_Day_5_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3d1c96ff639d6f90a5ac3d0602fa274be5b337f93ede86d67f4884a776c79690 -size 41196 +oid sha256:ebe316bccee2ef6045a8a3e77d48c633ad6855dcbec3f7fd3f69071abddde135 +size 41246 diff --git a/screenshots/de/features.lockscreen.impl.unlock_PinUnlockView_Day_6_de.png b/screenshots/de/features.lockscreen.impl.unlock_PinUnlockView_Day_6_de.png index 557ee76ac4c..5405dde38b5 100644 --- a/screenshots/de/features.lockscreen.impl.unlock_PinUnlockView_Day_6_de.png +++ b/screenshots/de/features.lockscreen.impl.unlock_PinUnlockView_Day_6_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ff076d7a9b3ec5e47eaac119c674a06ed9701fae17b93bd33fd12fd0a730d5f0 -size 32089 +oid sha256:807ced3cff6d0a0f70738bd4723be16df848f61fb7a8c594c3c5261a4b9267a2 +size 32133 diff --git a/screenshots/de/features.lockscreen.impl.unlock_PinUnlockView_Day_7_de.png b/screenshots/de/features.lockscreen.impl.unlock_PinUnlockView_Day_7_de.png index 9a705725668..3368a795a0a 100644 --- a/screenshots/de/features.lockscreen.impl.unlock_PinUnlockView_Day_7_de.png +++ b/screenshots/de/features.lockscreen.impl.unlock_PinUnlockView_Day_7_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a85c15d3d4fd79b4ea8070675e416bac6fccee1976dbfee7f266afd8eb1ab4ce -size 32285 +oid sha256:014e8208a369fcc39f331a752f4c1f7039ef966d8af12cefb46fded6d7c07d28 +size 32326 diff --git a/screenshots/de/features.login.impl.screens.qrcode.scan_QrCodeScanView_Day_0_de.png b/screenshots/de/features.login.impl.screens.qrcode.scan_QrCodeScanView_Day_0_de.png index ff4a5d57858..d50a67ef387 100644 --- a/screenshots/de/features.login.impl.screens.qrcode.scan_QrCodeScanView_Day_0_de.png +++ b/screenshots/de/features.login.impl.screens.qrcode.scan_QrCodeScanView_Day_0_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:530e1e9747be0284eb2600abcca2ad6103d10aa1513712d610b797d0595190a3 -size 14532 +oid sha256:a12dbc4f2fb3d12b1848e2ac4e079522613df2493d0a416f2f47827eb0daa322 +size 16502 diff --git a/screenshots/de/features.login.impl.screens.qrcode.scan_QrCodeScanView_Day_1_de.png b/screenshots/de/features.login.impl.screens.qrcode.scan_QrCodeScanView_Day_1_de.png index 736fcfe796d..c98fa292eda 100644 --- a/screenshots/de/features.login.impl.screens.qrcode.scan_QrCodeScanView_Day_1_de.png +++ b/screenshots/de/features.login.impl.screens.qrcode.scan_QrCodeScanView_Day_1_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3ba23fbb4402878a5a249a997504d69fcc268a15b5ef00512dd15e12857280a2 -size 19411 +oid sha256:1e6e8cd76fdbb3835b0622593e099ed61d526de35c6d20b6547cea83d6aa8e19 +size 20741 diff --git a/screenshots/de/features.login.impl.screens.qrcode.scan_QrCodeScanView_Day_2_de.png b/screenshots/de/features.login.impl.screens.qrcode.scan_QrCodeScanView_Day_2_de.png index 303f7d42829..305f24b9d74 100644 --- a/screenshots/de/features.login.impl.screens.qrcode.scan_QrCodeScanView_Day_2_de.png +++ b/screenshots/de/features.login.impl.screens.qrcode.scan_QrCodeScanView_Day_2_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:26d79ad02e39da6cb3f5a5f962b302832dee465096a1269941f9eb6c74277971 -size 30499 +oid sha256:a6a773294a47272bcc0333e7a325e0609ef53d576eca1d40422b0855cc48dae7 +size 31739 diff --git a/screenshots/de/features.login.impl.screens.qrcode.scan_QrCodeScanView_Day_3_de.png b/screenshots/de/features.login.impl.screens.qrcode.scan_QrCodeScanView_Day_3_de.png index 6fcadfdd61a..877598f1e17 100644 --- a/screenshots/de/features.login.impl.screens.qrcode.scan_QrCodeScanView_Day_3_de.png +++ b/screenshots/de/features.login.impl.screens.qrcode.scan_QrCodeScanView_Day_3_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3562640bc228a3977c216d7981692029b0a94c2b109628121571cb9804a68024 -size 39016 +oid sha256:500c0857431f0604b828e50751b6250e8349559a39b73f201ff5e7eb4fe0239c +size 40262 diff --git a/screenshots/de/features.login.impl.screens.qrcode.scan_QrCodeScanView_Day_4_de.png b/screenshots/de/features.login.impl.screens.qrcode.scan_QrCodeScanView_Day_4_de.png index 77f1733a603..46cb8688563 100644 --- a/screenshots/de/features.login.impl.screens.qrcode.scan_QrCodeScanView_Day_4_de.png +++ b/screenshots/de/features.login.impl.screens.qrcode.scan_QrCodeScanView_Day_4_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:eafdb26bca443afa4921432073c560c48116c2d3e9d45f8afcc1e35d8819905d -size 34110 +oid sha256:60ee229ac4cc0712b8afba1d678cebc88bc1f0abfe133bf4e4311f05b0fa6c28 +size 35288 diff --git a/screenshots/de/features.login.impl.screens.qrcode.scan_QrCodeScanView_Day_5_de.png b/screenshots/de/features.login.impl.screens.qrcode.scan_QrCodeScanView_Day_5_de.png index 11303295dfb..cef37f89369 100644 --- a/screenshots/de/features.login.impl.screens.qrcode.scan_QrCodeScanView_Day_5_de.png +++ b/screenshots/de/features.login.impl.screens.qrcode.scan_QrCodeScanView_Day_5_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:988384f6d8fc5b65e5e8d5c047ddb55a953867d34fa7d9d6543ff7f07c096b34 -size 32203 +oid sha256:848b24931a1da8f4e2b3dfdbb6b8e3f09d71dee356507eb5f46e08e74ffe94a5 +size 33464 diff --git a/screenshots/de/features.messages.impl.attachments.preview_AttachmentsView_0_de.png b/screenshots/de/features.messages.impl.attachments.preview_AttachmentsPreviewView_0_de.png similarity index 100% rename from screenshots/de/features.messages.impl.attachments.preview_AttachmentsView_0_de.png rename to screenshots/de/features.messages.impl.attachments.preview_AttachmentsPreviewView_0_de.png diff --git a/screenshots/de/features.messages.impl.attachments.preview_AttachmentsView_1_de.png b/screenshots/de/features.messages.impl.attachments.preview_AttachmentsPreviewView_1_de.png similarity index 100% rename from screenshots/de/features.messages.impl.attachments.preview_AttachmentsView_1_de.png rename to screenshots/de/features.messages.impl.attachments.preview_AttachmentsPreviewView_1_de.png diff --git a/screenshots/de/features.messages.impl.attachments.preview_AttachmentsView_2_de.png b/screenshots/de/features.messages.impl.attachments.preview_AttachmentsPreviewView_2_de.png similarity index 100% rename from screenshots/de/features.messages.impl.attachments.preview_AttachmentsView_2_de.png rename to screenshots/de/features.messages.impl.attachments.preview_AttachmentsPreviewView_2_de.png diff --git a/screenshots/de/features.messages.impl.attachments.preview_AttachmentsView_3_de.png b/screenshots/de/features.messages.impl.attachments.preview_AttachmentsPreviewView_3_de.png similarity index 100% rename from screenshots/de/features.messages.impl.attachments.preview_AttachmentsView_3_de.png rename to screenshots/de/features.messages.impl.attachments.preview_AttachmentsPreviewView_3_de.png diff --git a/screenshots/de/features.messages.impl.attachments.preview_AttachmentsView_4_de.png b/screenshots/de/features.messages.impl.attachments.preview_AttachmentsPreviewView_4_de.png similarity index 100% rename from screenshots/de/features.messages.impl.attachments.preview_AttachmentsView_4_de.png rename to screenshots/de/features.messages.impl.attachments.preview_AttachmentsPreviewView_4_de.png diff --git a/screenshots/de/features.messages.impl.attachments.preview_AttachmentsView_5_de.png b/screenshots/de/features.messages.impl.attachments.preview_AttachmentsPreviewView_5_de.png similarity index 100% rename from screenshots/de/features.messages.impl.attachments.preview_AttachmentsView_5_de.png rename to screenshots/de/features.messages.impl.attachments.preview_AttachmentsPreviewView_5_de.png diff --git a/screenshots/de/features.messages.impl.attachments.preview_AttachmentsPreviewView_6_de.png b/screenshots/de/features.messages.impl.attachments.preview_AttachmentsPreviewView_6_de.png new file mode 100644 index 00000000000..5a467d20db9 --- /dev/null +++ b/screenshots/de/features.messages.impl.attachments.preview_AttachmentsPreviewView_6_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4824c960aeced8a1745c0beac52b406e83d24079eeac21bca78aa7ef0698a4f0 +size 72357 diff --git a/screenshots/de/features.messages.impl.attachments.preview_AttachmentsView_7_de.png b/screenshots/de/features.messages.impl.attachments.preview_AttachmentsPreviewView_7_de.png similarity index 100% rename from screenshots/de/features.messages.impl.attachments.preview_AttachmentsView_7_de.png rename to screenshots/de/features.messages.impl.attachments.preview_AttachmentsPreviewView_7_de.png diff --git a/screenshots/de/features.messages.impl.attachments.preview_AttachmentsPreviewView_8_de.png b/screenshots/de/features.messages.impl.attachments.preview_AttachmentsPreviewView_8_de.png new file mode 100644 index 00000000000..9c802957272 --- /dev/null +++ b/screenshots/de/features.messages.impl.attachments.preview_AttachmentsPreviewView_8_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0afbea1b4ee5db426d45293cc3260cef6ebf7cbcf7aa387452b6433120e5c5e0 +size 89922 diff --git a/screenshots/de/features.messages.impl.attachments.preview_AttachmentsView_6_de.png b/screenshots/de/features.messages.impl.attachments.preview_AttachmentsView_6_de.png deleted file mode 100644 index 9b9c0d482b0..00000000000 --- a/screenshots/de/features.messages.impl.attachments.preview_AttachmentsView_6_de.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:40ec83fef8cf41389125f11d8a42c46b76b45225016e77ee2f9a49dcbcd4a006 -size 71980 diff --git a/screenshots/de/features.messages.impl.attachments.preview_AttachmentsView_8_de.png b/screenshots/de/features.messages.impl.attachments.preview_AttachmentsView_8_de.png deleted file mode 100644 index 40614785251..00000000000 --- a/screenshots/de/features.messages.impl.attachments.preview_AttachmentsView_8_de.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:c5e0b2b6866184d18ed0928af4450cadfa860e60b60713dac4399c9b0dedc16c -size 89678 diff --git a/screenshots/de/features.messages.impl.attachments.preview_VideoQualitySelectorDialog_Day_0_de.png b/screenshots/de/features.messages.impl.attachments.preview_VideoQualitySelectorDialog_Day_0_de.png index f614e6eac79..8f9bad96f6c 100644 --- a/screenshots/de/features.messages.impl.attachments.preview_VideoQualitySelectorDialog_Day_0_de.png +++ b/screenshots/de/features.messages.impl.attachments.preview_VideoQualitySelectorDialog_Day_0_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c71d15c6788611ecc0be5f75d64696d7bf68673f9c75c91d692358fedf219ba6 -size 71872 +oid sha256:0346829dcefb0fa299a8ef834ca56e76fa258e49fa36aea425e0a2f074fde401 +size 71943 diff --git a/screenshots/de/features.messages.impl.crypto.historyvisible_HistoryVisibleStateView_Day_0_de.png b/screenshots/de/features.messages.impl.crypto.historyvisible_HistoryVisibleStateView_Day_0_de.png new file mode 100644 index 00000000000..3cbafb85e6c --- /dev/null +++ b/screenshots/de/features.messages.impl.crypto.historyvisible_HistoryVisibleStateView_Day_0_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:86e3e8b778551e041a265bb44d1d38b89ba482374f07165d329038c29bc002b6 +size 32517 diff --git a/screenshots/de/features.messages.impl.crypto.historyvisible_MessagesViewWithHistoryVisible_Day_0_de.png b/screenshots/de/features.messages.impl.crypto.historyvisible_MessagesViewWithHistoryVisible_Day_0_de.png new file mode 100644 index 00000000000..4ec720ee807 --- /dev/null +++ b/screenshots/de/features.messages.impl.crypto.historyvisible_MessagesViewWithHistoryVisible_Day_0_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c8d8e5573f6fda64ba0a314135a9525f89c34e43ec955b4a3ddf0b83865b4ea4 +size 71692 diff --git a/screenshots/de/features.messages.impl.timeline.components.customreaction.picker_EmojiPicker_Day_0_de.png b/screenshots/de/features.messages.impl.timeline.components.customreaction.picker_EmojiPicker_Day_0_de.png index f4b2bf2d831..35761fbc07a 100644 --- a/screenshots/de/features.messages.impl.timeline.components.customreaction.picker_EmojiPicker_Day_0_de.png +++ b/screenshots/de/features.messages.impl.timeline.components.customreaction.picker_EmojiPicker_Day_0_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:72e593c0daada7d0dbb3bd964c6296e6432e97a5480429da9562782e76b27e57 -size 22225 +oid sha256:ad8fc597fb7c6674d5c9402f659ef163296d2131e80f7d0f89c6ac8a2e7201f9 +size 22266 diff --git a/screenshots/de/features.messages.impl.timeline.components.customreaction.picker_EmojiPicker_Day_1_de.png b/screenshots/de/features.messages.impl.timeline.components.customreaction.picker_EmojiPicker_Day_1_de.png index 49addf5d036..c1409acb779 100644 --- a/screenshots/de/features.messages.impl.timeline.components.customreaction.picker_EmojiPicker_Day_1_de.png +++ b/screenshots/de/features.messages.impl.timeline.components.customreaction.picker_EmojiPicker_Day_1_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9d1a41cc35a495f97b8a4660fa9e905e05e8f1ee41980e67237899cd49506c0b -size 6535 +oid sha256:6d907836e749f5d74713e58b7427cac9c3972919ccccb2c66f41a688e9367c67 +size 6653 diff --git a/screenshots/de/features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Day_1_de.png b/screenshots/de/features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Day_1_de.png index 2e4b967586f..dbe86d6685c 100644 --- a/screenshots/de/features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Day_1_de.png +++ b/screenshots/de/features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Day_1_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1a07d10aba2d12a4e664b4ff122f10434b5af4bb90fdaf57fc05a52c67bd47ff -size 12207 +oid sha256:82f339bf796b1dd88bb2eb7bc20ea999402b83764c267fe70d91e69aacfd8365 +size 12199 diff --git a/screenshots/de/features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Day_2_de.png b/screenshots/de/features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Day_2_de.png index d1d9065005e..8f400cf087f 100644 --- a/screenshots/de/features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Day_2_de.png +++ b/screenshots/de/features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Day_2_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:212650482a277d51efb218af0485aa0e5d7c72a33bd5e8c482dd704299c975c6 -size 17684 +oid sha256:2267052cc5675747b74f096b308431c77356d1c8937d827fd1d0f29dafc39096 +size 17718 diff --git a/screenshots/de/features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Day_3_de.png b/screenshots/de/features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Day_3_de.png index 3a19f3bf867..1a0298e410d 100644 --- a/screenshots/de/features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Day_3_de.png +++ b/screenshots/de/features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Day_3_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:240798176e52148a905fb4beecd34e9ae962c5da9ba03324d3f3cf48ef9544ad -size 22605 +oid sha256:a366fe4261a7a35cdcd357814d56361b3fe30d96b94984866e78b0fac0dec742 +size 22667 diff --git a/screenshots/de/features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Day_4_de.png b/screenshots/de/features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Day_4_de.png index 828befb6d19..4029a678d44 100644 --- a/screenshots/de/features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Day_4_de.png +++ b/screenshots/de/features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Day_4_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f875b3189dd12e945dd7128e1c826e4b17463d098d790f52a4932cf27b878b5b -size 27015 +oid sha256:34d0a6f5093359b0f233d11152b1cb17905b11bfb71be11ce9ed3f41cd50e76e +size 27084 diff --git a/screenshots/de/features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Day_5_de.png b/screenshots/de/features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Day_5_de.png index edbda381afa..95153ad95a1 100644 --- a/screenshots/de/features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Day_5_de.png +++ b/screenshots/de/features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Day_5_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1cd78cd18060a2799221b3e6f3eb517bf6d597927fe4900d8c49349ca3a545d1 -size 32333 +oid sha256:836b4253c1846802ee4000455f91ba9a55195ab04f875cbece2bb57054456783 +size 32496 diff --git a/screenshots/de/features.messages.impl.timeline.components_CallMenuItem_Day_3_de.png b/screenshots/de/features.messages.impl.timeline.components_CallMenuItem_Day_3_de.png index 8a2b554cfec..7aebedf5a46 100644 --- a/screenshots/de/features.messages.impl.timeline.components_CallMenuItem_Day_3_de.png +++ b/screenshots/de/features.messages.impl.timeline.components_CallMenuItem_Day_3_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:af71462d647769e7b003ebe9878b6ae75c09d47bdce0c9c5aaf90c960398fb54 -size 6275 +oid sha256:f34a79713b48c14bc96c3b1371c74cdf3703339af04bfa3cc90b022101b30abf +size 6114 diff --git a/screenshots/de/features.messages.impl.timeline.components_ThreadSummaryView_Day_0_de.png b/screenshots/de/features.messages.impl.timeline.components_ThreadSummaryView_Day_0_de.png index 9f240daeaec..958bd5275f1 100644 --- a/screenshots/de/features.messages.impl.timeline.components_ThreadSummaryView_Day_0_de.png +++ b/screenshots/de/features.messages.impl.timeline.components_ThreadSummaryView_Day_0_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:18a7f4dbf3a56c49e5b1edbd0f419620dbfcd4485bfe25db5347968b8844973b -size 9933 +oid sha256:aadadd01c5a31342a1d6eae9506c560a8710f1bc000f43dbe50fbce229da442a +size 9926 diff --git a/screenshots/de/features.messages.impl.timeline.components_TimelineItemCallNotifyView_Day_0_de.png b/screenshots/de/features.messages.impl.timeline.components_TimelineItemCallNotifyView_Day_0_de.png index 3528c076eb5..7522320c11c 100644 --- a/screenshots/de/features.messages.impl.timeline.components_TimelineItemCallNotifyView_Day_0_de.png +++ b/screenshots/de/features.messages.impl.timeline.components_TimelineItemCallNotifyView_Day_0_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4bf1663b19905d0da350f70de60a1561a3ccf7870b63ad151822908b69e0ab11 -size 42177 +oid sha256:37907f4f9f4f18857605e4e524793c0282100f07cf6e44fe8c8e4467c71bfca1 +size 42078 diff --git a/screenshots/de/features.messages.impl.timeline.components_TimelineItemEventRowWithReplyInformative_Day_0_de.png b/screenshots/de/features.messages.impl.timeline.components_TimelineItemEventRowWithReplyInformative_Day_0_de.png index 03e01779ab1..78a683e9dd9 100644 --- a/screenshots/de/features.messages.impl.timeline.components_TimelineItemEventRowWithReplyInformative_Day_0_de.png +++ b/screenshots/de/features.messages.impl.timeline.components_TimelineItemEventRowWithReplyInformative_Day_0_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f480c419bfba6a06b7737aab3bce77848c198f7f03f27a2dc92737014ccc9975 -size 364873 +oid sha256:d4e206a9a8836609ec40e98f9d95b7b6d68e47523e243097228aeeba35f04be2 +size 364866 diff --git a/screenshots/de/features.messages.impl.timeline.components_TimelineItemEventRowWithReplyInformative_Day_1_de.png b/screenshots/de/features.messages.impl.timeline.components_TimelineItemEventRowWithReplyInformative_Day_1_de.png index d4e74cf5209..7e69b98c759 100644 --- a/screenshots/de/features.messages.impl.timeline.components_TimelineItemEventRowWithReplyInformative_Day_1_de.png +++ b/screenshots/de/features.messages.impl.timeline.components_TimelineItemEventRowWithReplyInformative_Day_1_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:eb4a6263b593b1ee107f4e42d18f842e304c3be1292250de795b4fefa647b275 -size 370540 +oid sha256:636f49cd6431b01fa72e92879db35522d35a7260a52d1d320efcf74fe5765358 +size 370534 diff --git a/screenshots/de/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_4_de.png b/screenshots/de/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_4_de.png index 5498d18abd7..107d88db387 100644 --- a/screenshots/de/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_4_de.png +++ b/screenshots/de/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_4_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9c82703c9b9446b167fd735d1b27c7c445de0ac434c2287a193d67fe6a4170d4 -size 364073 +oid sha256:fe3daf2cf97dee0cc6b67994ce7d3178d5f16fe50fe7a9e9ff5bf0d47d282b22 +size 364064 diff --git a/screenshots/de/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_8_de.png b/screenshots/de/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_8_de.png index 15b5693fed8..5a62ac80968 100644 --- a/screenshots/de/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_8_de.png +++ b/screenshots/de/features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_8_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:985b04b170d3e13dabc86ab4ab0f496974403f84d4a550c971634da59756633a +oid sha256:18d42f29dc43e05079e6964b8a97a2201898a8bff3ed1772cec5c18b137d87e3 size 366116 diff --git a/screenshots/de/features.messages.impl.timeline.components_TimelineItemEventRowWithThreadSummary_Day_0_de.png b/screenshots/de/features.messages.impl.timeline.components_TimelineItemEventRowWithThreadSummary_Day_0_de.png index 72c3db744fa..4a48c958061 100644 --- a/screenshots/de/features.messages.impl.timeline.components_TimelineItemEventRowWithThreadSummary_Day_0_de.png +++ b/screenshots/de/features.messages.impl.timeline.components_TimelineItemEventRowWithThreadSummary_Day_0_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c699247a7d2a5f56855c4c5a050b9e65d394803f61b4a2d86753477b357140d3 +oid sha256:19ab48b39fe02b510edded16f680e11c8f2ebbd6d1455d4b3c99d33c3e6e7842 size 68132 diff --git a/screenshots/de/features.messages.impl.timeline_TimelineView_Day_8_de.png b/screenshots/de/features.messages.impl.timeline_TimelineView_Day_8_de.png deleted file mode 100644 index 65f68e3f9a0..00000000000 --- a/screenshots/de/features.messages.impl.timeline_TimelineView_Day_8_de.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:49a4829e1f8768ccf77c60ccd76853eb6bc24a545728fb80c75ed9fd900fd19b -size 55427 diff --git a/screenshots/de/features.messages.impl.topbars_MessagesViewTopBar_Day_0_de.png b/screenshots/de/features.messages.impl.topbars_MessagesViewTopBar_Day_0_de.png index f012ca51b81..89b6968710b 100644 --- a/screenshots/de/features.messages.impl.topbars_MessagesViewTopBar_Day_0_de.png +++ b/screenshots/de/features.messages.impl.topbars_MessagesViewTopBar_Day_0_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a582f6175541953aa8ca75a42990c50fd24befd4db63e10aad16d6dfa360ffff -size 41968 +oid sha256:9eee9b32dc1ebb91657140f277f4ef9f41167383b28d12a3b0d8137d8637fcf7 +size 41896 diff --git a/screenshots/de/features.messages.impl_MessagesView_Day_10_de.png b/screenshots/de/features.messages.impl_MessagesView_Day_10_de.png new file mode 100644 index 00000000000..b4fd6a6ecb8 --- /dev/null +++ b/screenshots/de/features.messages.impl_MessagesView_Day_10_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:15f0e4899422bbc75362f91f67b1f25b61f7c1ba5885b9a186d62cf0fa1962d7 +size 68472 diff --git a/screenshots/de/features.messages.impl_MessagesView_Day_11_de.png b/screenshots/de/features.messages.impl_MessagesView_Day_11_de.png new file mode 100644 index 00000000000..d5c2ae6469f --- /dev/null +++ b/screenshots/de/features.messages.impl_MessagesView_Day_11_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f71c4bc5e9fa42c31ec582f932a730c93d4936fcc28d98e77c6d8655458b561a +size 73622 diff --git a/screenshots/de/features.messages.impl_MessagesView_Day_12_de.png b/screenshots/de/features.messages.impl_MessagesView_Day_12_de.png new file mode 100644 index 00000000000..b4fd6a6ecb8 --- /dev/null +++ b/screenshots/de/features.messages.impl_MessagesView_Day_12_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:15f0e4899422bbc75362f91f67b1f25b61f7c1ba5885b9a186d62cf0fa1962d7 +size 68472 diff --git a/screenshots/de/features.poll.impl.create_CreatePollView_Day_2_de.png b/screenshots/de/features.poll.impl.create_CreatePollView_Day_2_de.png index 5f7d39d4b16..41623d158df 100644 --- a/screenshots/de/features.poll.impl.create_CreatePollView_Day_2_de.png +++ b/screenshots/de/features.poll.impl.create_CreatePollView_Day_2_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bda355b4cc26ca3eb609ac11a06b1249c5be56340fbca6b526118c0588e53218 -size 45160 +oid sha256:537fb0eec587e1f9e7b5f4095e9aa09c687f47e2b45025efa7dbc8cb4e030fd3 +size 42366 diff --git a/screenshots/de/features.preferences.impl.blockedusers_BlockedUsersView_Day_0_de.png b/screenshots/de/features.preferences.impl.blockedusers_BlockedUsersView_Day_0_de.png index a2afac1d7ee..871afe71ef5 100644 --- a/screenshots/de/features.preferences.impl.blockedusers_BlockedUsersView_Day_0_de.png +++ b/screenshots/de/features.preferences.impl.blockedusers_BlockedUsersView_Day_0_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:faf2b9b719419f5644c315c9b2e0c69bf5789107239b89b557d26c4b2815087d -size 59229 +oid sha256:e5a733462618eb242f77a46c18e66a3d98d7de1ff300441350a56bf59edcb63b +size 53727 diff --git a/screenshots/de/features.preferences.impl.blockedusers_BlockedUsersView_Day_1_de.png b/screenshots/de/features.preferences.impl.blockedusers_BlockedUsersView_Day_1_de.png index 94e921838f8..5fe49e49508 100644 --- a/screenshots/de/features.preferences.impl.blockedusers_BlockedUsersView_Day_1_de.png +++ b/screenshots/de/features.preferences.impl.blockedusers_BlockedUsersView_Day_1_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0cd97628c4eab2c22a1b7f5268839313fb0e162df3a4b65e18861b816d7915d4 -size 58932 +oid sha256:4e1bb7419a82d26252fea05af5206635e64a1b64d4063ac51e5ff5c51fd3d655 +size 58261 diff --git a/screenshots/de/features.preferences.impl.blockedusers_BlockedUsersView_Day_3_de.png b/screenshots/de/features.preferences.impl.blockedusers_BlockedUsersView_Day_3_de.png index 38457ca8d29..e838aea1072 100644 --- a/screenshots/de/features.preferences.impl.blockedusers_BlockedUsersView_Day_3_de.png +++ b/screenshots/de/features.preferences.impl.blockedusers_BlockedUsersView_Day_3_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5eec8a8effd80ef9b443ae64d90ebf37de0b06496d20c69d08d17c0dbc67181b -size 58732 +oid sha256:fb7f869a12d97b8890e582f378f6d6811d1cff943121d3513e964b89bd29bf9c +size 58676 diff --git a/screenshots/de/features.preferences.impl.blockedusers_BlockedUsersView_Day_4_de.png b/screenshots/de/features.preferences.impl.blockedusers_BlockedUsersView_Day_4_de.png index dbf0e397ab6..3ca273fede4 100644 --- a/screenshots/de/features.preferences.impl.blockedusers_BlockedUsersView_Day_4_de.png +++ b/screenshots/de/features.preferences.impl.blockedusers_BlockedUsersView_Day_4_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2d6988ce2970cc634277fe3d6f6fc3769c8259bdfb465e55bbf0477bcd1bd83a -size 62803 +oid sha256:555e0b1dc3ad335956a9c9595ab074a8678b81a593d8d5789b64369be625b40e +size 57370 diff --git a/screenshots/de/features.preferences.impl.blockedusers_BlockedUsersView_Day_5_de.png b/screenshots/de/features.preferences.impl.blockedusers_BlockedUsersView_Day_5_de.png index 6987693d949..f3a824bcf6d 100644 --- a/screenshots/de/features.preferences.impl.blockedusers_BlockedUsersView_Day_5_de.png +++ b/screenshots/de/features.preferences.impl.blockedusers_BlockedUsersView_Day_5_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:507ac618ea0b3a0ce08b22f9ed53736d2c264d9ff732583756276f184c4f3680 -size 61958 +oid sha256:a0d7f67c26cb32ed1d64ddf4abb629e9b72a6a38d74079c549a27b8c1ee9fca2 +size 56497 diff --git a/screenshots/de/features.preferences.impl.blockedusers_BlockedUsersView_Day_6_de.png b/screenshots/de/features.preferences.impl.blockedusers_BlockedUsersView_Day_6_de.png index a2afac1d7ee..871afe71ef5 100644 --- a/screenshots/de/features.preferences.impl.blockedusers_BlockedUsersView_Day_6_de.png +++ b/screenshots/de/features.preferences.impl.blockedusers_BlockedUsersView_Day_6_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:faf2b9b719419f5644c315c9b2e0c69bf5789107239b89b557d26c4b2815087d -size 59229 +oid sha256:e5a733462618eb242f77a46c18e66a3d98d7de1ff300441350a56bf59edcb63b +size 53727 diff --git a/screenshots/de/features.preferences.impl.labs_LabsView_Day_0_de.png b/screenshots/de/features.preferences.impl.labs_LabsView_Day_0_de.png index 5cfc6d90de7..283d6a560fd 100644 --- a/screenshots/de/features.preferences.impl.labs_LabsView_Day_0_de.png +++ b/screenshots/de/features.preferences.impl.labs_LabsView_Day_0_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:84704e1aaeaaa2354248be2bf6864df789bce2f4f2da930d297e8596f6a00f78 -size 48333 +oid sha256:36d6e179c0fdecb3b4ebc1233fd3543c19c2615adeba0db15917e5540111d1f5 +size 48335 diff --git a/screenshots/de/features.preferences.impl.labs_LabsView_Day_1_de.png b/screenshots/de/features.preferences.impl.labs_LabsView_Day_1_de.png index ce61c23bff0..3edea046cf1 100644 --- a/screenshots/de/features.preferences.impl.labs_LabsView_Day_1_de.png +++ b/screenshots/de/features.preferences.impl.labs_LabsView_Day_1_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b0573c15203e5694c4069430333804ba5671c8d67ddd78921c64a573100819bd -size 39484 +oid sha256:eb2bf0d0f83c2125a34393713a9f385734ef16e02a14e93ea152125b59b0c66e +size 39483 diff --git a/screenshots/de/features.preferences.impl.root_MultiAccountSection_Day_0_de.png b/screenshots/de/features.preferences.impl.root_MultiAccountSection_Day_0_de.png deleted file mode 100644 index 71d906108f2..00000000000 --- a/screenshots/de/features.preferences.impl.root_MultiAccountSection_Day_0_de.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:934c47b945f3eecd77a6ab6c050c3ca4f0594a78e05d292970e398f03883e82e -size 59248 diff --git a/screenshots/de/features.preferences.impl.root_PreferencesRootViewDark_0_de.png b/screenshots/de/features.preferences.impl.root_PreferencesRootViewDark_0_de.png index e08cac7ffe3..46af5af5a61 100644 --- a/screenshots/de/features.preferences.impl.root_PreferencesRootViewDark_0_de.png +++ b/screenshots/de/features.preferences.impl.root_PreferencesRootViewDark_0_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9e5805062266fb2efac61ce1814526f39bb79421227e59968fd1bc56ed2ad6d4 -size 42026 +oid sha256:2c3b9c7fb4fd1171684e92341fb957d3de64b3cd3ceca35f843684c29c40e98c +size 42406 diff --git a/screenshots/de/features.preferences.impl.root_PreferencesRootViewDark_1_de.png b/screenshots/de/features.preferences.impl.root_PreferencesRootViewDark_1_de.png index fdc2a6ff059..c67c7ad8920 100644 --- a/screenshots/de/features.preferences.impl.root_PreferencesRootViewDark_1_de.png +++ b/screenshots/de/features.preferences.impl.root_PreferencesRootViewDark_1_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7dbe9c6ad43bf8565729ab434faa610223d77b1d8d9de4757acccf781c47b5f7 -size 41853 +oid sha256:af20c31aedd05e357a026a6de8d39edff03321ea054d1cfb246313190bf4c756 +size 42219 diff --git a/screenshots/de/features.preferences.impl.root_PreferencesRootViewLight_0_de.png b/screenshots/de/features.preferences.impl.root_PreferencesRootViewLight_0_de.png index 7b3621de727..ec273a1146a 100644 --- a/screenshots/de/features.preferences.impl.root_PreferencesRootViewLight_0_de.png +++ b/screenshots/de/features.preferences.impl.root_PreferencesRootViewLight_0_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2314627a1260dd216ce430b72c9bf4651865e73d8f115518b99b5f8dfd153b0f -size 43272 +oid sha256:7bc7ec5c5e68c1b8cd9c085b5d385f1b67943da758bf234cce74f773a4a8fb41 +size 43673 diff --git a/screenshots/de/features.preferences.impl.root_PreferencesRootViewLight_1_de.png b/screenshots/de/features.preferences.impl.root_PreferencesRootViewLight_1_de.png index 0f3320eeb41..a0aa940b28e 100644 --- a/screenshots/de/features.preferences.impl.root_PreferencesRootViewLight_1_de.png +++ b/screenshots/de/features.preferences.impl.root_PreferencesRootViewLight_1_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f4222dd22f478350a2f82ca8103cecc367453535e957490ab775d05f32742554 -size 43316 +oid sha256:683d033942db3e1d619228aa34b8db2d294ad156e3332d4d6e09d3a460873944 +size 43714 diff --git a/screenshots/de/features.preferences.impl.user.editprofile_EditUserProfileView_Day_0_de.png b/screenshots/de/features.preferences.impl.user.editprofile_EditUserProfileView_Day_0_de.png index 03cd573859e..6bd90ed7b5d 100644 --- a/screenshots/de/features.preferences.impl.user.editprofile_EditUserProfileView_Day_0_de.png +++ b/screenshots/de/features.preferences.impl.user.editprofile_EditUserProfileView_Day_0_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:371cdd134e7991f65da9fa526b89b1562db79ec91ee12d9aac96b34e43a883f2 -size 22160 +oid sha256:9b2f1a3efba14b4b4e449ac5303e0cd468da4c729f12a17b0d75d39b609eeb39 +size 23724 diff --git a/screenshots/de/features.preferences.impl.user.editprofile_EditUserProfileView_Day_1_de.png b/screenshots/de/features.preferences.impl.user.editprofile_EditUserProfileView_Day_1_de.png index d48a261081e..459beef9de7 100644 --- a/screenshots/de/features.preferences.impl.user.editprofile_EditUserProfileView_Day_1_de.png +++ b/screenshots/de/features.preferences.impl.user.editprofile_EditUserProfileView_Day_1_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1b67896e65b700af7c989250098de7dabc61ee97db5157c1afebd83640192575 -size 70519 +oid sha256:cbf9d22c06327a92b61f618019fea562a9bd063a45a8b501820eedca485a7c5b +size 65348 diff --git a/screenshots/de/features.preferences.impl.user.editprofile_EditUserProfileView_Day_2_de.png b/screenshots/de/features.preferences.impl.user.editprofile_EditUserProfileView_Day_2_de.png new file mode 100644 index 00000000000..790719248b8 --- /dev/null +++ b/screenshots/de/features.preferences.impl.user.editprofile_EditUserProfileView_Day_2_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c54e8c7ea8c2d7cb5c80f938cb975f20fe35f9f28c0debdb712ae924e420ba12 +size 35307 diff --git a/screenshots/de/features.rolesandpermissions.impl.permissions_ChangeRoomPermissionsView_Day_0_de.png b/screenshots/de/features.rolesandpermissions.impl.permissions_ChangeRoomPermissionsView_Day_0_de.png index 4f14062a27e..b4ef5c647d9 100644 --- a/screenshots/de/features.rolesandpermissions.impl.permissions_ChangeRoomPermissionsView_Day_0_de.png +++ b/screenshots/de/features.rolesandpermissions.impl.permissions_ChangeRoomPermissionsView_Day_0_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e5100a5f28d7b50041614ceffa1af0cc39f4499042db9015f1121b525ead0714 -size 65983 +oid sha256:2feb956a7e10edf6957276a759a0da2b59108244ccc8b3eb9f022567d811ec3a +size 57456 diff --git a/screenshots/de/features.rolesandpermissions.impl.permissions_ChangeRoomPermissionsView_Day_1_de.png b/screenshots/de/features.rolesandpermissions.impl.permissions_ChangeRoomPermissionsView_Day_1_de.png index 32209675826..e215718ae60 100644 --- a/screenshots/de/features.rolesandpermissions.impl.permissions_ChangeRoomPermissionsView_Day_1_de.png +++ b/screenshots/de/features.rolesandpermissions.impl.permissions_ChangeRoomPermissionsView_Day_1_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a9bea2e31996df7b7bd5c8c52b68b84c5b97ef5aa57a371665a4f2b204667219 -size 65870 +oid sha256:c1b25c5d57b0a2a1f01fa63f40363bc4d56b108e1d8936790086a064a05d9860 +size 55654 diff --git a/screenshots/de/features.rolesandpermissions.impl.permissions_ChangeRoomPermissionsView_Day_2_de.png b/screenshots/de/features.rolesandpermissions.impl.permissions_ChangeRoomPermissionsView_Day_2_de.png index dc09753419a..ca4cdab9563 100644 --- a/screenshots/de/features.rolesandpermissions.impl.permissions_ChangeRoomPermissionsView_Day_2_de.png +++ b/screenshots/de/features.rolesandpermissions.impl.permissions_ChangeRoomPermissionsView_Day_2_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c8ef02193c4dc5171c0d0fcd79444a3a5b48157d5c6b26cb11d9d97a67321499 -size 55804 +oid sha256:c286a545d54c8e5c4c034dab7359c9d0fb944a6ff1708f879ca33f15f2ca5f0d +size 57283 diff --git a/screenshots/de/features.rolesandpermissions.impl.permissions_ChangeRoomPermissionsView_Day_3_de.png b/screenshots/de/features.rolesandpermissions.impl.permissions_ChangeRoomPermissionsView_Day_3_de.png index bb3df793b88..6832f0b60f0 100644 --- a/screenshots/de/features.rolesandpermissions.impl.permissions_ChangeRoomPermissionsView_Day_3_de.png +++ b/screenshots/de/features.rolesandpermissions.impl.permissions_ChangeRoomPermissionsView_Day_3_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d8bc40679b33ab4c63b9a102388ca87c4c9d48461651cff9e2289604904a2ee0 -size 53277 +oid sha256:9c26673e8de7a4897071caf4c3bad20f7ed64d32f85467c4f8684cce7e37c018 +size 50618 diff --git a/screenshots/de/features.rolesandpermissions.impl.permissions_ChangeRoomPermissionsView_Day_4_de.png b/screenshots/de/features.rolesandpermissions.impl.permissions_ChangeRoomPermissionsView_Day_4_de.png index f8720be3165..ef4bcfbdbf6 100644 --- a/screenshots/de/features.rolesandpermissions.impl.permissions_ChangeRoomPermissionsView_Day_4_de.png +++ b/screenshots/de/features.rolesandpermissions.impl.permissions_ChangeRoomPermissionsView_Day_4_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9fe026d2e48b1759d1c270615d87d23df2d7528d6fcfd2f91f39f0142abea5e6 -size 61953 +oid sha256:66eae2c2d57bd76459d17f91d26a2c311882183688566d9ad1a3d6767272891a +size 48007 diff --git a/screenshots/de/features.rolesandpermissions.impl.permissions_ChangeRoomPermissionsView_Day_5_de.png b/screenshots/de/features.rolesandpermissions.impl.permissions_ChangeRoomPermissionsView_Day_5_de.png new file mode 100644 index 00000000000..7c60317c177 --- /dev/null +++ b/screenshots/de/features.rolesandpermissions.impl.permissions_ChangeRoomPermissionsView_Day_5_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:80e66292743ea24c7b25619de3e51c9ad16e7a8e63c012e1a13f014db67eeba0 +size 57101 diff --git a/screenshots/de/features.rolesandpermissions.impl.permissions_ChangeRoomPermissionsView_Day_6_de.png b/screenshots/de/features.rolesandpermissions.impl.permissions_ChangeRoomPermissionsView_Day_6_de.png new file mode 100644 index 00000000000..5eba80a67ff --- /dev/null +++ b/screenshots/de/features.rolesandpermissions.impl.permissions_ChangeRoomPermissionsView_Day_6_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cbcee06277f0e6161349efa0744f0074839f88c42edefad9a6e0c8c313b682b5 +size 58701 diff --git a/screenshots/de/features.rolesandpermissions.impl.roles_ChangeRolesView_Day_0_de.png b/screenshots/de/features.rolesandpermissions.impl.roles_ChangeRolesView_Day_0_de.png index 39882e6a275..ce06429382e 100644 --- a/screenshots/de/features.rolesandpermissions.impl.roles_ChangeRolesView_Day_0_de.png +++ b/screenshots/de/features.rolesandpermissions.impl.roles_ChangeRolesView_Day_0_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:483f7a76717c263bfd76df2487c548c16d159e5a150149671b540d84b4a5a326 -size 16124 +oid sha256:70b9a96c4dff57091a091d74896aa85ae1d5ed3a45e58dd46e0e4e5cc758150f +size 16149 diff --git a/screenshots/de/features.rolesandpermissions.impl.roles_ChangeRolesView_Day_10_de.png b/screenshots/de/features.rolesandpermissions.impl.roles_ChangeRolesView_Day_10_de.png index d17592a23b0..3494b47e33d 100644 --- a/screenshots/de/features.rolesandpermissions.impl.roles_ChangeRolesView_Day_10_de.png +++ b/screenshots/de/features.rolesandpermissions.impl.roles_ChangeRolesView_Day_10_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c69a3dbfca46e010898fa574df4ca20e0ef2c92810b58413125c911bdb1f88ed -size 53040 +oid sha256:693353e6e2319ba5e5d0fc1c5e73d909de82fca9f65149f7dd9734a1ec05b1c4 +size 53029 diff --git a/screenshots/de/features.rolesandpermissions.impl.roles_ChangeRolesView_Day_11_de.png b/screenshots/de/features.rolesandpermissions.impl.roles_ChangeRolesView_Day_11_de.png index fd537c7a4e2..3fc5b0b10c2 100644 --- a/screenshots/de/features.rolesandpermissions.impl.roles_ChangeRolesView_Day_11_de.png +++ b/screenshots/de/features.rolesandpermissions.impl.roles_ChangeRolesView_Day_11_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:855d5b61a769766d83a20afc32cef6477bb09cb84b29669a8807481373b3f958 -size 55451 +oid sha256:ea2b8ef893a86cff4960d16540a417d9c03b0c7c14952c0f798d95dabb2bcbd4 +size 55482 diff --git a/screenshots/de/features.rolesandpermissions.impl.roles_ChangeRolesView_Day_12_de.png b/screenshots/de/features.rolesandpermissions.impl.roles_ChangeRolesView_Day_12_de.png index 2e3a538aaae..9d47bfbbfb1 100644 --- a/screenshots/de/features.rolesandpermissions.impl.roles_ChangeRolesView_Day_12_de.png +++ b/screenshots/de/features.rolesandpermissions.impl.roles_ChangeRolesView_Day_12_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4b70e827c255d99cc8f976b8ea1dd247c060eccaac1db132d0fa5d2a7bba8dd1 -size 55489 +oid sha256:5061a9d86c7b222f7be0d61d2ff4ca4da7504f50d172189c9c9a8a70baa1a126 +size 55517 diff --git a/screenshots/de/features.rolesandpermissions.impl.roles_ChangeRolesView_Day_13_de.png b/screenshots/de/features.rolesandpermissions.impl.roles_ChangeRolesView_Day_13_de.png index 747cad00c93..09a60754b44 100644 --- a/screenshots/de/features.rolesandpermissions.impl.roles_ChangeRolesView_Day_13_de.png +++ b/screenshots/de/features.rolesandpermissions.impl.roles_ChangeRolesView_Day_13_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:640f53a7cac7a3b6693d462bb86a50db212d749a8603e8edaeed6c874ae573ba -size 66148 +oid sha256:5a6779a089b642b924e927cdf0813dd25707dbaeaaab6bc0d4c255df3bc6f7c2 +size 66133 diff --git a/screenshots/de/features.rolesandpermissions.impl.roles_ChangeRolesView_Day_1_de.png b/screenshots/de/features.rolesandpermissions.impl.roles_ChangeRolesView_Day_1_de.png index 613fc7b48da..57295800c31 100644 --- a/screenshots/de/features.rolesandpermissions.impl.roles_ChangeRolesView_Day_1_de.png +++ b/screenshots/de/features.rolesandpermissions.impl.roles_ChangeRolesView_Day_1_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:08f1af124411ce01ff24b6b47ccb3e8f02ba17ec6bc6aed98acd62f3dbd32bb7 -size 71876 +oid sha256:a6c5d5d9e1b27bf4beee58329fc251dc7a8f2e18173eb517292011d1245a12aa +size 71905 diff --git a/screenshots/de/features.rolesandpermissions.impl.roles_ChangeRolesView_Day_2_de.png b/screenshots/de/features.rolesandpermissions.impl.roles_ChangeRolesView_Day_2_de.png index 749c220c31c..ec0e861e465 100644 --- a/screenshots/de/features.rolesandpermissions.impl.roles_ChangeRolesView_Day_2_de.png +++ b/screenshots/de/features.rolesandpermissions.impl.roles_ChangeRolesView_Day_2_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:19845c2e90dd9a5c3bc13f5ba66b38c02ac0ec961d3dac8713d1db465d826f41 -size 65497 +oid sha256:205cf04d8fe00211a71e45830516ee371a43990bee56f9c836c9f2185bbcea26 +size 65533 diff --git a/screenshots/de/features.rolesandpermissions.impl.roles_ChangeRolesView_Day_3_de.png b/screenshots/de/features.rolesandpermissions.impl.roles_ChangeRolesView_Day_3_de.png index 92aa51f8f40..0ec81bf4403 100644 --- a/screenshots/de/features.rolesandpermissions.impl.roles_ChangeRolesView_Day_3_de.png +++ b/screenshots/de/features.rolesandpermissions.impl.roles_ChangeRolesView_Day_3_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:68887295c717d5a10e594bcfe4d6c84369bf2d468d7f998b2ccd82d27798d56d -size 65331 +oid sha256:042ad0318615d36b091a9ad4221549177a1b76b777998f28650ff47fe1777e35 +size 65365 diff --git a/screenshots/de/features.rolesandpermissions.impl.roles_ChangeRolesView_Day_4_de.png b/screenshots/de/features.rolesandpermissions.impl.roles_ChangeRolesView_Day_4_de.png index 2617a69130d..e92c0c560a8 100644 --- a/screenshots/de/features.rolesandpermissions.impl.roles_ChangeRolesView_Day_4_de.png +++ b/screenshots/de/features.rolesandpermissions.impl.roles_ChangeRolesView_Day_4_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b139e5a7a55132a5bbe260cf300a86f2437feacb62ddf5d4144ed0718545d6c6 -size 59210 +oid sha256:b34465c4795584129f2d94050f8586851233966e5bd8fc836deebceb1bb7d8dd +size 59248 diff --git a/screenshots/de/features.rolesandpermissions.impl.roles_ChangeRolesView_Day_6_de.png b/screenshots/de/features.rolesandpermissions.impl.roles_ChangeRolesView_Day_6_de.png index d536b911897..6c829ca9302 100644 --- a/screenshots/de/features.rolesandpermissions.impl.roles_ChangeRolesView_Day_6_de.png +++ b/screenshots/de/features.rolesandpermissions.impl.roles_ChangeRolesView_Day_6_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c0bc99152df9a9fc1e8ae63bf1d40ded275d178555659981042649555db1c8be -size 65977 +oid sha256:6daac5a3bf35601522c3448d731e85409fa9e13d1a06be2cd907f542d0bf22b4 +size 61828 diff --git a/screenshots/de/features.rolesandpermissions.impl.roles_ChangeRolesView_Day_7_de.png b/screenshots/de/features.rolesandpermissions.impl.roles_ChangeRolesView_Day_7_de.png index 20109519a70..21ec47f5d53 100644 --- a/screenshots/de/features.rolesandpermissions.impl.roles_ChangeRolesView_Day_7_de.png +++ b/screenshots/de/features.rolesandpermissions.impl.roles_ChangeRolesView_Day_7_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ee106780e3dea8be63a63e22c5c9d44e6402a40729f90fe47a2ca2959395ee07 -size 66214 +oid sha256:488e717f60a1d6c80260f248f729bc5817593c4d68cab567066f6929668557d1 +size 66207 diff --git a/screenshots/de/features.rolesandpermissions.impl.roles_ChangeRolesView_Day_8_de.png b/screenshots/de/features.rolesandpermissions.impl.roles_ChangeRolesView_Day_8_de.png index 6f446ed016d..e4217d8080b 100644 --- a/screenshots/de/features.rolesandpermissions.impl.roles_ChangeRolesView_Day_8_de.png +++ b/screenshots/de/features.rolesandpermissions.impl.roles_ChangeRolesView_Day_8_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2214de9f67c410ea11669fc9846286e6ad8ce6210a60fabcb8da3928a0ab6920 -size 55686 +oid sha256:0efae1f7954ae4587ba9a0822c0fbbe50d09fdb0c7a5eba1e4c89fb5bf839c8b +size 55674 diff --git a/screenshots/de/features.rolesandpermissions.impl.roles_ChangeRolesView_Day_9_de.png b/screenshots/de/features.rolesandpermissions.impl.roles_ChangeRolesView_Day_9_de.png index 92aa51f8f40..0ec81bf4403 100644 --- a/screenshots/de/features.rolesandpermissions.impl.roles_ChangeRolesView_Day_9_de.png +++ b/screenshots/de/features.rolesandpermissions.impl.roles_ChangeRolesView_Day_9_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:68887295c717d5a10e594bcfe4d6c84369bf2d468d7f998b2ccd82d27798d56d -size 65331 +oid sha256:042ad0318615d36b091a9ad4221549177a1b76b777998f28650ff47fe1777e35 +size 65365 diff --git a/screenshots/de/features.rolesandpermissions.impl.root_RolesAndPermissionsView_Day_0_de.png b/screenshots/de/features.rolesandpermissions.impl.root_RolesAndPermissionsView_Day_0_de.png index cd690a7726b..f90bd43fda8 100644 --- a/screenshots/de/features.rolesandpermissions.impl.root_RolesAndPermissionsView_Day_0_de.png +++ b/screenshots/de/features.rolesandpermissions.impl.root_RolesAndPermissionsView_Day_0_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:673a833d5c5e9026b680a73017ca6e6dd9f3e33c0b13a0de4967133b85a13bfa -size 33146 +oid sha256:8aea2569247c60180b1569b09efc58dc4c4a93b2848c3bba52ae2278e9a0ddf4 +size 32050 diff --git a/screenshots/de/features.rolesandpermissions.impl.root_RolesAndPermissionsView_Day_1_de.png b/screenshots/de/features.rolesandpermissions.impl.root_RolesAndPermissionsView_Day_1_de.png index bd1a2e7ac3a..d6020405896 100644 --- a/screenshots/de/features.rolesandpermissions.impl.root_RolesAndPermissionsView_Day_1_de.png +++ b/screenshots/de/features.rolesandpermissions.impl.root_RolesAndPermissionsView_Day_1_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bc67b423cec2885955d8b9df9df2a0fae2767c9ab96be944824efcb07dc70b25 -size 35233 +oid sha256:da2490c21a2b8833b53356584ddb4a23f327f6f11671eeaccbfbcf5e8d37f055 +size 34124 diff --git a/screenshots/de/features.rolesandpermissions.impl.root_RolesAndPermissionsView_Day_3_de.png b/screenshots/de/features.rolesandpermissions.impl.root_RolesAndPermissionsView_Day_3_de.png index 98bfbf4ddfc..f5f384d9ab8 100644 --- a/screenshots/de/features.rolesandpermissions.impl.root_RolesAndPermissionsView_Day_3_de.png +++ b/screenshots/de/features.rolesandpermissions.impl.root_RolesAndPermissionsView_Day_3_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:24961d4b8ba165e40edc46675151005c34ec7b6de8cb3755aaab3481e87ca9ac -size 30661 +oid sha256:7f8fa9504a4a1df35f5b7b5c229301e465f6b2fd0c44d00420703931c9cb9e47 +size 30250 diff --git a/screenshots/de/features.rolesandpermissions.impl.root_RolesAndPermissionsView_Day_6_de.png b/screenshots/de/features.rolesandpermissions.impl.root_RolesAndPermissionsView_Day_6_de.png index 98bfbf4ddfc..f5f384d9ab8 100644 --- a/screenshots/de/features.rolesandpermissions.impl.root_RolesAndPermissionsView_Day_6_de.png +++ b/screenshots/de/features.rolesandpermissions.impl.root_RolesAndPermissionsView_Day_6_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:24961d4b8ba165e40edc46675151005c34ec7b6de8cb3755aaab3481e87ca9ac -size 30661 +oid sha256:7f8fa9504a4a1df35f5b7b5c229301e465f6b2fd0c44d00420703931c9cb9e47 +size 30250 diff --git a/screenshots/de/features.rolesandpermissions.impl.root_RolesAndPermissionsView_Day_7_de.png b/screenshots/de/features.rolesandpermissions.impl.root_RolesAndPermissionsView_Day_7_de.png index 198b0c2a5ac..a10ee76fed5 100644 --- a/screenshots/de/features.rolesandpermissions.impl.root_RolesAndPermissionsView_Day_7_de.png +++ b/screenshots/de/features.rolesandpermissions.impl.root_RolesAndPermissionsView_Day_7_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e514066d66b1e868c7229302478e48fa5e9a72fdccbe54d9e179889544f73ea5 -size 28037 +oid sha256:de51dc6fdc942bde0a1d42194feee7829943974b645b792ce32e446fd73b76fe +size 28030 diff --git a/screenshots/de/features.rolesandpermissions.impl.root_RolesAndPermissionsView_Day_8_de.png b/screenshots/de/features.rolesandpermissions.impl.root_RolesAndPermissionsView_Day_8_de.png index 8a01bb44d84..11c1b8b41e4 100644 --- a/screenshots/de/features.rolesandpermissions.impl.root_RolesAndPermissionsView_Day_8_de.png +++ b/screenshots/de/features.rolesandpermissions.impl.root_RolesAndPermissionsView_Day_8_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:66197931c626c563b2c6fcbf1fc8f57366bbc1b9a22c3d4dc18d64457e5a8d24 -size 31131 +oid sha256:945f3e19bc6903b198cc9ccd9af8a776c40590b7591738b962c270138b18b871 +size 30034 diff --git a/screenshots/de/features.roomdetails.impl.edit_RoomDetailsEditView_Day_0_de.png b/screenshots/de/features.roomdetails.impl.edit_RoomDetailsEditView_Day_0_de.png deleted file mode 100644 index 760a4adfd0d..00000000000 --- a/screenshots/de/features.roomdetails.impl.edit_RoomDetailsEditView_Day_0_de.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:1250b03f29403bac32fac8fa6386e7897336f9812e66fc51ca971665b18c1fad -size 30009 diff --git a/screenshots/de/features.roomdetails.impl.edit_RoomDetailsEditView_Day_1_de.png b/screenshots/de/features.roomdetails.impl.edit_RoomDetailsEditView_Day_1_de.png deleted file mode 100644 index 592f5e6efd0..00000000000 --- a/screenshots/de/features.roomdetails.impl.edit_RoomDetailsEditView_Day_1_de.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:305ba31f911770af43df762d462c5c006d781f80f5ccdc63d7adcfd704fae13e -size 24578 diff --git a/screenshots/de/features.roomdetails.impl.edit_RoomDetailsEditView_Day_2_de.png b/screenshots/de/features.roomdetails.impl.edit_RoomDetailsEditView_Day_2_de.png deleted file mode 100644 index 77c7e2e3886..00000000000 --- a/screenshots/de/features.roomdetails.impl.edit_RoomDetailsEditView_Day_2_de.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:c4edb86f9fef95dec9238388e0ddb045fa571ab57a8c0980b40d66cce8bf82e2 -size 31144 diff --git a/screenshots/de/features.roomdetails.impl.edit_RoomDetailsEditView_Day_3_de.png b/screenshots/de/features.roomdetails.impl.edit_RoomDetailsEditView_Day_3_de.png deleted file mode 100644 index 3fc335c9b92..00000000000 --- a/screenshots/de/features.roomdetails.impl.edit_RoomDetailsEditView_Day_3_de.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:8c302044f568ac50a50161ffbd05cbb709d2bf198e87fdc75f81343242871676 -size 55607 diff --git a/screenshots/de/features.roomdetails.impl.edit_RoomDetailsEditView_Day_4_de.png b/screenshots/de/features.roomdetails.impl.edit_RoomDetailsEditView_Day_4_de.png deleted file mode 100644 index e5d2c286209..00000000000 --- a/screenshots/de/features.roomdetails.impl.edit_RoomDetailsEditView_Day_4_de.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:ddb4b7a2e5b473e8a4b75a65bf884360ed35edd93d7801c7c121d57878523620 -size 30052 diff --git a/screenshots/de/features.roomdetails.impl.edit_RoomDetailsEditView_Day_5_de.png b/screenshots/de/features.roomdetails.impl.edit_RoomDetailsEditView_Day_5_de.png deleted file mode 100644 index 3f2924b10de..00000000000 --- a/screenshots/de/features.roomdetails.impl.edit_RoomDetailsEditView_Day_5_de.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:86115b3d58661c9c59d6ebeb815ac58ab215c25fd5655f98525bbc6f74c641f2 -size 30140 diff --git a/screenshots/de/features.roomdetails.impl.edit_RoomDetailsEditView_Day_6_de.png b/screenshots/de/features.roomdetails.impl.edit_RoomDetailsEditView_Day_6_de.png deleted file mode 100644 index c66dec94444..00000000000 --- a/screenshots/de/features.roomdetails.impl.edit_RoomDetailsEditView_Day_6_de.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:12d0c536795bd5922e4af54e9c7bcab1ae3d6c0f61ecb9a5b68898c26c81aa6b -size 27621 diff --git a/screenshots/de/features.roomdetails.impl.edit_RoomDetailsEditView_Day_7_de.png b/screenshots/de/features.roomdetails.impl.edit_RoomDetailsEditView_Day_7_de.png deleted file mode 100644 index df697466c4e..00000000000 --- a/screenshots/de/features.roomdetails.impl.edit_RoomDetailsEditView_Day_7_de.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:e12d9f423c460a85a50297e9ddf99a061ee84e04ba36b0765074658807a30390 -size 29743 diff --git a/screenshots/de/features.roomdetails.impl.edit_RoomDetailsEditView_Day_8_de.png b/screenshots/de/features.roomdetails.impl.edit_RoomDetailsEditView_Day_8_de.png deleted file mode 100644 index 02051a08da3..00000000000 --- a/screenshots/de/features.roomdetails.impl.edit_RoomDetailsEditView_Day_8_de.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:12f8ce1dbeb1a16ab8351486310fdeabba566db4aad460fc4e1e1f7fdbe63bf7 -size 37209 diff --git a/screenshots/de/features.roomdetails.impl.members_RoomMemberListViewBanned_Day_0_de.png b/screenshots/de/features.roomdetails.impl.members_RoomMemberListViewBanned_Day_0_de.png deleted file mode 100644 index e616c17ea96..00000000000 --- a/screenshots/de/features.roomdetails.impl.members_RoomMemberListViewBanned_Day_0_de.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:66d5bac689bdb05818951e2e6faad9b4e313146bef4cdf99ce500d10b7717cd2 -size 12286 diff --git a/screenshots/de/features.roomdetails.impl.members_RoomMemberListViewBanned_Day_1_de.png b/screenshots/de/features.roomdetails.impl.members_RoomMemberListViewBanned_Day_1_de.png deleted file mode 100644 index 9a5c54bfecc..00000000000 --- a/screenshots/de/features.roomdetails.impl.members_RoomMemberListViewBanned_Day_1_de.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:0bd483cd461e077f2c1a67eb3a2a6e865da7af0659e6786ca3252d189cd3f4f3 -size 12528 diff --git a/screenshots/de/features.roomdetails.impl.members_RoomMemberListViewBanned_Day_2_de.png b/screenshots/de/features.roomdetails.impl.members_RoomMemberListViewBanned_Day_2_de.png deleted file mode 100644 index e616c17ea96..00000000000 --- a/screenshots/de/features.roomdetails.impl.members_RoomMemberListViewBanned_Day_2_de.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:66d5bac689bdb05818951e2e6faad9b4e313146bef4cdf99ce500d10b7717cd2 -size 12286 diff --git a/screenshots/de/features.roomdetails.impl.members_RoomMemberListView_Day_0_de.png b/screenshots/de/features.roomdetails.impl.members_RoomMemberListView_Day_0_de.png index da3a20f79fe..17a0e0c2a15 100644 --- a/screenshots/de/features.roomdetails.impl.members_RoomMemberListView_Day_0_de.png +++ b/screenshots/de/features.roomdetails.impl.members_RoomMemberListView_Day_0_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d667737faee1070520e5dd4e62a1838eb37aa9080a993e33276188ca8ac2bdc9 -size 47880 +oid sha256:344c0d1c9ac8e0b15b396b9bae16136db85a1b81b0cb9f959e2f27a8d6e36c6f +size 13650 diff --git a/screenshots/de/features.roomdetails.impl.members_RoomMemberListView_Day_1_de.png b/screenshots/de/features.roomdetails.impl.members_RoomMemberListView_Day_1_de.png index ac1a6b63d7a..e6c3dab94a0 100644 --- a/screenshots/de/features.roomdetails.impl.members_RoomMemberListView_Day_1_de.png +++ b/screenshots/de/features.roomdetails.impl.members_RoomMemberListView_Day_1_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:67e7995d4a975a732223a9f248c8c9ac00173b2783fe05ceb2a8d0281f403e6a -size 54190 +oid sha256:a762a5501000d41b1407045482440beb78742a9b671bdd2400d5c23dfb15586f +size 22798 diff --git a/screenshots/de/features.roomdetails.impl.members_RoomMemberListView_Day_2_de.png b/screenshots/de/features.roomdetails.impl.members_RoomMemberListView_Day_2_de.png index 9a5c54bfecc..5851c87606c 100644 --- a/screenshots/de/features.roomdetails.impl.members_RoomMemberListView_Day_2_de.png +++ b/screenshots/de/features.roomdetails.impl.members_RoomMemberListView_Day_2_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0bd483cd461e077f2c1a67eb3a2a6e865da7af0659e6786ca3252d189cd3f4f3 -size 12528 +oid sha256:4724101065e860281aa3f7d3b8e3b535be689d4e79bf225f96fb15cc22c6cf53 +size 55567 diff --git a/screenshots/de/features.roomdetails.impl.members_RoomMemberListView_Day_3_de.png b/screenshots/de/features.roomdetails.impl.members_RoomMemberListView_Day_3_de.png index 58d5fd88bbd..18298e6114b 100644 --- a/screenshots/de/features.roomdetails.impl.members_RoomMemberListView_Day_3_de.png +++ b/screenshots/de/features.roomdetails.impl.members_RoomMemberListView_Day_3_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7d074ac8751c7f125534134d3bb06cc19c6f5ce17da390ddd633f264f9806ee8 -size 13860 +oid sha256:746a6d203bc1cda887c23af882ec45114802c484e6723ee6c0708ec5930ff7aa +size 31750 diff --git a/screenshots/de/features.roomdetails.impl.members_RoomMemberListView_Day_4_de.png b/screenshots/de/features.roomdetails.impl.members_RoomMemberListView_Day_4_de.png index 9a5c54bfecc..9f2bcc511b4 100644 --- a/screenshots/de/features.roomdetails.impl.members_RoomMemberListView_Day_4_de.png +++ b/screenshots/de/features.roomdetails.impl.members_RoomMemberListView_Day_4_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0bd483cd461e077f2c1a67eb3a2a6e865da7af0659e6786ca3252d189cd3f4f3 -size 12528 +oid sha256:47289c65fd7948a9949655e802143bd531bc69119ee4fafcb385e0d2b74b390a +size 56808 diff --git a/screenshots/de/features.roomdetails.impl.members_RoomMemberListView_Day_5_de.png b/screenshots/de/features.roomdetails.impl.members_RoomMemberListView_Day_5_de.png index 79a25499ad1..2ea9a945581 100644 --- a/screenshots/de/features.roomdetails.impl.members_RoomMemberListView_Day_5_de.png +++ b/screenshots/de/features.roomdetails.impl.members_RoomMemberListView_Day_5_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8f3641f966fcf71259afeedc2c4b32d3e2c581af23c9c4220c9b80152501e77f -size 7949 +oid sha256:aee6c8b8303af4242c9672867eaa0af30013263d4170e60f2483ad02b43f712b +size 19052 diff --git a/screenshots/de/features.roomdetails.impl.members_RoomMemberListView_Day_6_de.png b/screenshots/de/features.roomdetails.impl.members_RoomMemberListView_Day_6_de.png new file mode 100644 index 00000000000..2a1647145f0 --- /dev/null +++ b/screenshots/de/features.roomdetails.impl.members_RoomMemberListView_Day_6_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b5ce88089419f3aa972b12f52bd15720ccf5b6f6cf17459750b16c4ff444a3cc +size 35837 diff --git a/screenshots/de/features.roomdetails.impl.members_RoomMemberListView_Day_7_de.png b/screenshots/de/features.roomdetails.impl.members_RoomMemberListView_Day_7_de.png deleted file mode 100644 index 7757f4b08aa..00000000000 --- a/screenshots/de/features.roomdetails.impl.members_RoomMemberListView_Day_7_de.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:13c4c05dd4a7e93b9fbcd5c361fdda4c17cb1d5b9918511bf402cf49c0dc27db -size 25164 diff --git a/screenshots/de/features.roomdetails.impl.members_RoomMemberListView_Day_8_de.png b/screenshots/de/features.roomdetails.impl.members_RoomMemberListView_Day_8_de.png deleted file mode 100644 index 3ba65e7151e..00000000000 --- a/screenshots/de/features.roomdetails.impl.members_RoomMemberListView_Day_8_de.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:251848f9afc246fb2ec6f36da893878360e24634efdcb3920aac174768f9ad6e -size 12298 diff --git a/screenshots/de/features.roomdetails.impl.members_RoomMemberListView_Day_9_de.png b/screenshots/de/features.roomdetails.impl.members_RoomMemberListView_Day_9_de.png deleted file mode 100644 index dbc779e7a6d..00000000000 --- a/screenshots/de/features.roomdetails.impl.members_RoomMemberListView_Day_9_de.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:7d518add734e55b81cbc35e77d059c2ef1777488f756b5da6cb8504f30c399dd -size 21660 diff --git a/screenshots/de/features.roomdetails.impl.securityandprivacy.editroomaddress_EditRoomAddressView_Day_0_de.png b/screenshots/de/features.roomdetails.impl.securityandprivacy.editroomaddress_EditRoomAddressView_Day_0_de.png deleted file mode 100644 index 92a9ecfc08d..00000000000 --- a/screenshots/de/features.roomdetails.impl.securityandprivacy.editroomaddress_EditRoomAddressView_Day_0_de.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:4463c9d76c95a063e481cd12d8a5c8432b06acf4072927cbe1cf659ca22759e3 -size 26490 diff --git a/screenshots/de/features.roomdetails.impl.securityandprivacy.editroomaddress_EditRoomAddressView_Day_1_de.png b/screenshots/de/features.roomdetails.impl.securityandprivacy.editroomaddress_EditRoomAddressView_Day_1_de.png deleted file mode 100644 index 1232360bfde..00000000000 --- a/screenshots/de/features.roomdetails.impl.securityandprivacy.editroomaddress_EditRoomAddressView_Day_1_de.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:16187cb579ae21c620fc41fc70b3ae17136ea423d7793263c4b943ca4b3e54cb -size 31781 diff --git a/screenshots/de/features.roomdetails.impl.securityandprivacy.editroomaddress_EditRoomAddressView_Day_2_de.png b/screenshots/de/features.roomdetails.impl.securityandprivacy.editroomaddress_EditRoomAddressView_Day_2_de.png deleted file mode 100644 index 6d9d44a999c..00000000000 --- a/screenshots/de/features.roomdetails.impl.securityandprivacy.editroomaddress_EditRoomAddressView_Day_2_de.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:59c6624f279aaa202ae5e85488dfa28893c13f02d2e76b0fbe6586b35a55181d -size 32515 diff --git a/screenshots/de/features.roomdetails.impl.securityandprivacy.editroomaddress_EditRoomAddressView_Day_3_de.png b/screenshots/de/features.roomdetails.impl.securityandprivacy.editroomaddress_EditRoomAddressView_Day_3_de.png deleted file mode 100644 index 7451aa805aa..00000000000 --- a/screenshots/de/features.roomdetails.impl.securityandprivacy.editroomaddress_EditRoomAddressView_Day_3_de.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:805d79eefc2a5cb1dd4fbb7294301ad785bb9cfe04c8c80c102c4f8d6f51fa4d -size 26384 diff --git a/screenshots/de/features.roomdetails.impl.securityandprivacy.editroomaddress_EditRoomAddressView_Day_4_de.png b/screenshots/de/features.roomdetails.impl.securityandprivacy.editroomaddress_EditRoomAddressView_Day_4_de.png deleted file mode 100644 index ab750ae07a3..00000000000 --- a/screenshots/de/features.roomdetails.impl.securityandprivacy.editroomaddress_EditRoomAddressView_Day_4_de.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:c4725f3d6a5c06f692fec640eddfd5f6ecd1cb3fb38c9f137ffbdd7df3cadc27 -size 28437 diff --git a/screenshots/de/features.roomdetails.impl.securityandprivacy_SecurityAndPrivacyViewDark_0_de.png b/screenshots/de/features.roomdetails.impl.securityandprivacy_SecurityAndPrivacyViewDark_0_de.png deleted file mode 100644 index cd516fe53be..00000000000 --- a/screenshots/de/features.roomdetails.impl.securityandprivacy_SecurityAndPrivacyViewDark_0_de.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:bce82fc99877bf4baf0f681ebfa1e424a653ee9d5d13d73345a9eb9d8e1a04c8 -size 49084 diff --git a/screenshots/de/features.roomdetails.impl.securityandprivacy_SecurityAndPrivacyViewDark_1_de.png b/screenshots/de/features.roomdetails.impl.securityandprivacy_SecurityAndPrivacyViewDark_1_de.png deleted file mode 100644 index da6126ac258..00000000000 --- a/screenshots/de/features.roomdetails.impl.securityandprivacy_SecurityAndPrivacyViewDark_1_de.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:d26a66215dd6e4c5327603d962c484ecd237c0e39efa2526831548e4ba7d8a3c -size 66264 diff --git a/screenshots/de/features.roomdetails.impl.securityandprivacy_SecurityAndPrivacyViewDark_2_de.png b/screenshots/de/features.roomdetails.impl.securityandprivacy_SecurityAndPrivacyViewDark_2_de.png deleted file mode 100644 index d45d21d1290..00000000000 --- a/screenshots/de/features.roomdetails.impl.securityandprivacy_SecurityAndPrivacyViewDark_2_de.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:2146fc5949ddb6ec451c1d375696401c358cb24415351667786ef079516797e5 -size 66390 diff --git a/screenshots/de/features.roomdetails.impl.securityandprivacy_SecurityAndPrivacyViewDark_3_de.png b/screenshots/de/features.roomdetails.impl.securityandprivacy_SecurityAndPrivacyViewDark_3_de.png deleted file mode 100644 index ea269fe4c90..00000000000 --- a/screenshots/de/features.roomdetails.impl.securityandprivacy_SecurityAndPrivacyViewDark_3_de.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:42cac3e2c1a32c170062e4933e0df0b4df56767b7b1ee9c70c6ba515e5fe9364 -size 66816 diff --git a/screenshots/de/features.roomdetails.impl.securityandprivacy_SecurityAndPrivacyViewDark_4_de.png b/screenshots/de/features.roomdetails.impl.securityandprivacy_SecurityAndPrivacyViewDark_4_de.png deleted file mode 100644 index 6a2ec9b414f..00000000000 --- a/screenshots/de/features.roomdetails.impl.securityandprivacy_SecurityAndPrivacyViewDark_4_de.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:e96600ec5b77af84ae80b334700b73a353e19895f97479afacb3ea6667b25c04 -size 66207 diff --git a/screenshots/de/features.roomdetails.impl.securityandprivacy_SecurityAndPrivacyViewDark_5_de.png b/screenshots/de/features.roomdetails.impl.securityandprivacy_SecurityAndPrivacyViewDark_5_de.png deleted file mode 100644 index 6a254e1545c..00000000000 --- a/screenshots/de/features.roomdetails.impl.securityandprivacy_SecurityAndPrivacyViewDark_5_de.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:cd52fe9bdae5629cae86e251004bb77cf0a503869839fc78582a8db478fb29fd -size 48853 diff --git a/screenshots/de/features.roomdetails.impl.securityandprivacy_SecurityAndPrivacyViewDark_6_de.png b/screenshots/de/features.roomdetails.impl.securityandprivacy_SecurityAndPrivacyViewDark_6_de.png deleted file mode 100644 index 6a254e1545c..00000000000 --- a/screenshots/de/features.roomdetails.impl.securityandprivacy_SecurityAndPrivacyViewDark_6_de.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:cd52fe9bdae5629cae86e251004bb77cf0a503869839fc78582a8db478fb29fd -size 48853 diff --git a/screenshots/de/features.roomdetails.impl.securityandprivacy_SecurityAndPrivacyViewDark_7_de.png b/screenshots/de/features.roomdetails.impl.securityandprivacy_SecurityAndPrivacyViewDark_7_de.png deleted file mode 100644 index ea72553cc63..00000000000 --- a/screenshots/de/features.roomdetails.impl.securityandprivacy_SecurityAndPrivacyViewDark_7_de.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:400c4e4565f0278ec0ce464d78c05faf5bec81e69b27d0d74962c8b12e99b22d -size 50052 diff --git a/screenshots/de/features.roomdetails.impl.securityandprivacy_SecurityAndPrivacyViewDark_8_de.png b/screenshots/de/features.roomdetails.impl.securityandprivacy_SecurityAndPrivacyViewDark_8_de.png deleted file mode 100644 index 032ce0414b4..00000000000 --- a/screenshots/de/features.roomdetails.impl.securityandprivacy_SecurityAndPrivacyViewDark_8_de.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:aad25334fd43f36325106f39977eec19ffcc0695d752ecc226ef4705c2d073ce -size 42798 diff --git a/screenshots/de/features.roomdetails.impl.securityandprivacy_SecurityAndPrivacyViewDark_9_de.png b/screenshots/de/features.roomdetails.impl.securityandprivacy_SecurityAndPrivacyViewDark_9_de.png deleted file mode 100644 index 10ff645a54a..00000000000 --- a/screenshots/de/features.roomdetails.impl.securityandprivacy_SecurityAndPrivacyViewDark_9_de.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:a6849deebbd1181d6fa359fd0a88171f53b032d234732d1823ebb2339f094174 -size 65846 diff --git a/screenshots/de/features.roomdetails.impl.securityandprivacy_SecurityAndPrivacyViewLight_0_de.png b/screenshots/de/features.roomdetails.impl.securityandprivacy_SecurityAndPrivacyViewLight_0_de.png deleted file mode 100644 index 5c00e6287bd..00000000000 --- a/screenshots/de/features.roomdetails.impl.securityandprivacy_SecurityAndPrivacyViewLight_0_de.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:3e881dee5efc987b70134fc688bb57755f8aab2dfcdee44a2a32caea09e91cee -size 51011 diff --git a/screenshots/de/features.roomdetails.impl.securityandprivacy_SecurityAndPrivacyViewLight_1_de.png b/screenshots/de/features.roomdetails.impl.securityandprivacy_SecurityAndPrivacyViewLight_1_de.png deleted file mode 100644 index 6097402d232..00000000000 --- a/screenshots/de/features.roomdetails.impl.securityandprivacy_SecurityAndPrivacyViewLight_1_de.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:de4e33e81d42e013e2a1a633d81ad410af38025cd007d0a5502316a3c73cf840 -size 68986 diff --git a/screenshots/de/features.roomdetails.impl.securityandprivacy_SecurityAndPrivacyViewLight_2_de.png b/screenshots/de/features.roomdetails.impl.securityandprivacy_SecurityAndPrivacyViewLight_2_de.png deleted file mode 100644 index 0f3b0876cb5..00000000000 --- a/screenshots/de/features.roomdetails.impl.securityandprivacy_SecurityAndPrivacyViewLight_2_de.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:92413621be8733a417b97b56cb8d518f07eeec4791cba3ff6e3f19ae85719cf0 -size 69200 diff --git a/screenshots/de/features.roomdetails.impl.securityandprivacy_SecurityAndPrivacyViewLight_3_de.png b/screenshots/de/features.roomdetails.impl.securityandprivacy_SecurityAndPrivacyViewLight_3_de.png deleted file mode 100644 index f0a81c96552..00000000000 --- a/screenshots/de/features.roomdetails.impl.securityandprivacy_SecurityAndPrivacyViewLight_3_de.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:46fd40c5f6ef2a796503398468fed8cb945d20fb016c14d85409726e66a59078 -size 69721 diff --git a/screenshots/de/features.roomdetails.impl.securityandprivacy_SecurityAndPrivacyViewLight_4_de.png b/screenshots/de/features.roomdetails.impl.securityandprivacy_SecurityAndPrivacyViewLight_4_de.png deleted file mode 100644 index e57def30b75..00000000000 --- a/screenshots/de/features.roomdetails.impl.securityandprivacy_SecurityAndPrivacyViewLight_4_de.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:378bcab0bc462571f1b80489b40cdd03b977b17b8864a684423dfe84f1fd1cf2 -size 68857 diff --git a/screenshots/de/features.roomdetails.impl.securityandprivacy_SecurityAndPrivacyViewLight_5_de.png b/screenshots/de/features.roomdetails.impl.securityandprivacy_SecurityAndPrivacyViewLight_5_de.png deleted file mode 100644 index efddf6337d2..00000000000 --- a/screenshots/de/features.roomdetails.impl.securityandprivacy_SecurityAndPrivacyViewLight_5_de.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:2678096406dec316cc4e961102ba38b30de6a3b128fa279db1228225f7cf60fa -size 50974 diff --git a/screenshots/de/features.roomdetails.impl.securityandprivacy_SecurityAndPrivacyViewLight_6_de.png b/screenshots/de/features.roomdetails.impl.securityandprivacy_SecurityAndPrivacyViewLight_6_de.png deleted file mode 100644 index efddf6337d2..00000000000 --- a/screenshots/de/features.roomdetails.impl.securityandprivacy_SecurityAndPrivacyViewLight_6_de.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:2678096406dec316cc4e961102ba38b30de6a3b128fa279db1228225f7cf60fa -size 50974 diff --git a/screenshots/de/features.roomdetails.impl.securityandprivacy_SecurityAndPrivacyViewLight_7_de.png b/screenshots/de/features.roomdetails.impl.securityandprivacy_SecurityAndPrivacyViewLight_7_de.png deleted file mode 100644 index 9a7c14f9cda..00000000000 --- a/screenshots/de/features.roomdetails.impl.securityandprivacy_SecurityAndPrivacyViewLight_7_de.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:6193d8ea073e83fe4e32ace278357f1ddf78bb48e0dfda54eb106421c88369e2 -size 52362 diff --git a/screenshots/de/features.roomdetails.impl.securityandprivacy_SecurityAndPrivacyViewLight_8_de.png b/screenshots/de/features.roomdetails.impl.securityandprivacy_SecurityAndPrivacyViewLight_8_de.png deleted file mode 100644 index e8df5934167..00000000000 --- a/screenshots/de/features.roomdetails.impl.securityandprivacy_SecurityAndPrivacyViewLight_8_de.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:30625120454624c48057d57dd2dd17a7e254cdfa2228cbc9776a7dc0be8ec07c -size 44360 diff --git a/screenshots/de/features.roomdetails.impl.securityandprivacy_SecurityAndPrivacyViewLight_9_de.png b/screenshots/de/features.roomdetails.impl.securityandprivacy_SecurityAndPrivacyViewLight_9_de.png deleted file mode 100644 index c6ebfd2112c..00000000000 --- a/screenshots/de/features.roomdetails.impl.securityandprivacy_SecurityAndPrivacyViewLight_9_de.png +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:9b26dd32028051acf1fa6d7b2d335a08efd80a0a0fbc0c729a3375b57ef072d8 -size 68465 diff --git a/screenshots/de/features.roomdetails.impl_RoomDetailsDark_0_de.png b/screenshots/de/features.roomdetails.impl_RoomDetailsDark_0_de.png index 5f49a124bb4..898a72ea3f6 100644 --- a/screenshots/de/features.roomdetails.impl_RoomDetailsDark_0_de.png +++ b/screenshots/de/features.roomdetails.impl_RoomDetailsDark_0_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a70776483d27d5c6851413cc05953eda817f81f57c7e2231b6c42528f4902abf -size 46405 +oid sha256:b8697c4c08bce7b3e51730641c438a88a61a86d0f4a0eee4945d1a8645cd229c +size 46344 diff --git a/screenshots/de/features.roomdetails.impl_RoomDetailsDark_10_de.png b/screenshots/de/features.roomdetails.impl_RoomDetailsDark_10_de.png index b965446ab32..027896e5db5 100644 --- a/screenshots/de/features.roomdetails.impl_RoomDetailsDark_10_de.png +++ b/screenshots/de/features.roomdetails.impl_RoomDetailsDark_10_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:16b68ee8b268178dbd74e3909991297e73f7ffc5916997e906951232b72fdb1b -size 45298 +oid sha256:b50eb0bb60ecd651d06348e2aec20b3dd44666cca9102b0d2403471905bf47a6 +size 45238 diff --git a/screenshots/de/features.roomdetails.impl_RoomDetailsDark_11_de.png b/screenshots/de/features.roomdetails.impl_RoomDetailsDark_11_de.png index 67f5e6f8375..a24498e869e 100644 --- a/screenshots/de/features.roomdetails.impl_RoomDetailsDark_11_de.png +++ b/screenshots/de/features.roomdetails.impl_RoomDetailsDark_11_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e9b899279b2116f3b1c72fcc29c5a53d142efeabc4eed30d2504b9a610a70c52 -size 43721 +oid sha256:548e6a8fd8f8fdb037f206412b765d89524f40711d1aa1e25fec7693761b098c +size 43758 diff --git a/screenshots/de/features.roomdetails.impl_RoomDetailsDark_12_de.png b/screenshots/de/features.roomdetails.impl_RoomDetailsDark_12_de.png index 541990d0bb5..189a57b5f76 100644 --- a/screenshots/de/features.roomdetails.impl_RoomDetailsDark_12_de.png +++ b/screenshots/de/features.roomdetails.impl_RoomDetailsDark_12_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:43b1e9b011a6304b41957728b3dadac37f3fe3cc020d2e8eb040a0d36c4035fe -size 45613 +oid sha256:5c4eec1f4dc41a34d4dcb6bb19f06e82df3af61d5502a1d69390c92a8dfe61ae +size 45547 diff --git a/screenshots/de/features.roomdetails.impl_RoomDetailsDark_13_de.png b/screenshots/de/features.roomdetails.impl_RoomDetailsDark_13_de.png index c52229d01a7..9e19611d2b8 100644 --- a/screenshots/de/features.roomdetails.impl_RoomDetailsDark_13_de.png +++ b/screenshots/de/features.roomdetails.impl_RoomDetailsDark_13_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:15aad49bbbfa213517101643f3a8cc1395f7d344ce3643b3a186bd68979a414f -size 45522 +oid sha256:aa53be2c34487dbd6d256ac08bc0472e5a9041fcdfd4312b41b1587e1ade02c9 +size 45460 diff --git a/screenshots/de/features.roomdetails.impl_RoomDetailsDark_14_de.png b/screenshots/de/features.roomdetails.impl_RoomDetailsDark_14_de.png index 71bab8f8199..d34c5f2d0eb 100644 --- a/screenshots/de/features.roomdetails.impl_RoomDetailsDark_14_de.png +++ b/screenshots/de/features.roomdetails.impl_RoomDetailsDark_14_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1d105ccd7161e1a17d9479960a834b416934f0f11f99010c1b3bf5e2755c5392 -size 46093 +oid sha256:1b0f4b25c48f814d0f65a9ebb90e624fd063e85c38638c34b5c2112c26d0a19e +size 46030 diff --git a/screenshots/de/features.roomdetails.impl_RoomDetailsDark_15_de.png b/screenshots/de/features.roomdetails.impl_RoomDetailsDark_15_de.png index 115d097fd85..daea8bdf49f 100644 --- a/screenshots/de/features.roomdetails.impl_RoomDetailsDark_15_de.png +++ b/screenshots/de/features.roomdetails.impl_RoomDetailsDark_15_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2307924f559a2e8600f2cc2e9cc3460085644a6234e12e53dc2d8940c7bfc597 -size 46626 +oid sha256:694675fede76219bec32f4eb90778dd36a71c6949bbcb5b4e324ac95801dcbbe +size 46564 diff --git a/screenshots/de/features.roomdetails.impl_RoomDetailsDark_16_de.png b/screenshots/de/features.roomdetails.impl_RoomDetailsDark_16_de.png index eab1cb43104..8746083afcc 100644 --- a/screenshots/de/features.roomdetails.impl_RoomDetailsDark_16_de.png +++ b/screenshots/de/features.roomdetails.impl_RoomDetailsDark_16_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:bfc3e3ebc8c77b403b104cdc78585add322610adf3070d08fb2e86e0afc53db5 -size 45871 +oid sha256:26f73d4bc06ff2bb9f0e322964c1131dde1f6c546c5ff9b7209a6dbcb5dc7273 +size 45810 diff --git a/screenshots/de/features.roomdetails.impl_RoomDetailsDark_17_de.png b/screenshots/de/features.roomdetails.impl_RoomDetailsDark_17_de.png index 070da55723d..af833499404 100644 --- a/screenshots/de/features.roomdetails.impl_RoomDetailsDark_17_de.png +++ b/screenshots/de/features.roomdetails.impl_RoomDetailsDark_17_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f42293fc8dd0382b435aa19a7d4b2cbe7c66e88d7782d062549e5e251c831b7f -size 45104 +oid sha256:7ed62b88643773270a7a045e9c80c29fd3f939c312ec4752cbbfac0a7d001f4f +size 45039 diff --git a/screenshots/de/features.roomdetails.impl_RoomDetailsDark_18_de.png b/screenshots/de/features.roomdetails.impl_RoomDetailsDark_18_de.png index 5e861cc01e3..0f1444ad40a 100644 --- a/screenshots/de/features.roomdetails.impl_RoomDetailsDark_18_de.png +++ b/screenshots/de/features.roomdetails.impl_RoomDetailsDark_18_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:43f87f56cd260703a4770c42cb9fdb4261718e90b30f534771f5b291a187dcbb -size 42234 +oid sha256:3c320a405aa9fdaaffa5ead648fa33d42f7c946c2a07fad2c803b084319f98bd +size 42275 diff --git a/screenshots/de/features.roomdetails.impl_RoomDetailsDark_19_de.png b/screenshots/de/features.roomdetails.impl_RoomDetailsDark_19_de.png index c33209683f5..9835d06f8bd 100644 --- a/screenshots/de/features.roomdetails.impl_RoomDetailsDark_19_de.png +++ b/screenshots/de/features.roomdetails.impl_RoomDetailsDark_19_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c977b4094b6e6740aa662b6e34e82f193b972a067e27a287caf60f75a7b82c12 -size 42191 +oid sha256:11a4d98f789ff1ad1e941a3155b4043e02b06858b319c03c6f1d2b419ed5edb3 +size 42231 diff --git a/screenshots/de/features.roomdetails.impl_RoomDetailsDark_1_de.png b/screenshots/de/features.roomdetails.impl_RoomDetailsDark_1_de.png index c80b9b157ee..58bf8b1e3a4 100644 --- a/screenshots/de/features.roomdetails.impl_RoomDetailsDark_1_de.png +++ b/screenshots/de/features.roomdetails.impl_RoomDetailsDark_1_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b38a8012219d7c5fe55e15187ca9db001c33d7883de2e6689bf80a2d44cb6a6a -size 41968 +oid sha256:432bd93e477130a98f2a4111de5051e0f19151594843365555d1428b03b991db +size 41923 diff --git a/screenshots/de/features.roomdetails.impl_RoomDetailsDark_2_de.png b/screenshots/de/features.roomdetails.impl_RoomDetailsDark_2_de.png index 0f8293bf72e..0c18888edd6 100644 --- a/screenshots/de/features.roomdetails.impl_RoomDetailsDark_2_de.png +++ b/screenshots/de/features.roomdetails.impl_RoomDetailsDark_2_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4c6951c25bd3df794fb978257d017dbec5537af0cb721d64b2234ca3485c2ad8 -size 39606 +oid sha256:8ff5afe13a98cec38fc19ff957fac1468ab440c8c2fca2e0d157e447a9739bc0 +size 39554 diff --git a/screenshots/de/features.roomdetails.impl_RoomDetailsDark_3_de.png b/screenshots/de/features.roomdetails.impl_RoomDetailsDark_3_de.png index 8a22517ec70..8c4387fd4a5 100644 --- a/screenshots/de/features.roomdetails.impl_RoomDetailsDark_3_de.png +++ b/screenshots/de/features.roomdetails.impl_RoomDetailsDark_3_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e0962c76eb96674ad2fbfa3a4950c408dd1478f0b7f20df8423c24be9d4315f1 -size 45774 +oid sha256:a6e87fbf054dca331f6380f59fa40a1dbecc40c25bcb29ae09817360527a8f10 +size 45629 diff --git a/screenshots/de/features.roomdetails.impl_RoomDetailsDark_4_de.png b/screenshots/de/features.roomdetails.impl_RoomDetailsDark_4_de.png index e7f8df1feb9..89d60e7c71a 100644 --- a/screenshots/de/features.roomdetails.impl_RoomDetailsDark_4_de.png +++ b/screenshots/de/features.roomdetails.impl_RoomDetailsDark_4_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2eab31818c13f116be79131e067f52e8b08024488d89d471ec0d5224a99adead -size 44850 +oid sha256:687e7b880a751047ec0cfffcb85b4c0cbfb04f52958cd731036f810b05d8784e +size 44805 diff --git a/screenshots/de/features.roomdetails.impl_RoomDetailsDark_5_de.png b/screenshots/de/features.roomdetails.impl_RoomDetailsDark_5_de.png index 8b5b874586e..def514b66ff 100644 --- a/screenshots/de/features.roomdetails.impl_RoomDetailsDark_5_de.png +++ b/screenshots/de/features.roomdetails.impl_RoomDetailsDark_5_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:93c0de89ece04e338143639036cc349250a2eb874585dc903248bdc00bbdcd9d -size 41876 +oid sha256:1343c24e5eea07d5f222d96fe7ee7dc6b5c42b3033c6b707758753d66489180c +size 41916 diff --git a/screenshots/de/features.roomdetails.impl_RoomDetailsDark_6_de.png b/screenshots/de/features.roomdetails.impl_RoomDetailsDark_6_de.png index 88b08d21ec3..42dadff2cde 100644 --- a/screenshots/de/features.roomdetails.impl_RoomDetailsDark_6_de.png +++ b/screenshots/de/features.roomdetails.impl_RoomDetailsDark_6_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c9fd895adb29700fe7b44e508048904b56f914a9d59ee8b747aeef1605312058 -size 45348 +oid sha256:bfd031259f414e7bcda1934e152f47d062114b76b5e1b3b38329291f6086d999 +size 45315 diff --git a/screenshots/de/features.roomdetails.impl_RoomDetailsDark_7_de.png b/screenshots/de/features.roomdetails.impl_RoomDetailsDark_7_de.png index 12899a80b8b..206579177d9 100644 --- a/screenshots/de/features.roomdetails.impl_RoomDetailsDark_7_de.png +++ b/screenshots/de/features.roomdetails.impl_RoomDetailsDark_7_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5fb30412d08e04187d202b9d31250fbc14ec62064ec6288e8a00b56ca7673d63 -size 46505 +oid sha256:2862e36d811b09539ef1fa6b80690c0dca99a845a599682087ee4df223c76308 +size 46436 diff --git a/screenshots/de/features.roomdetails.impl_RoomDetailsDark_8_de.png b/screenshots/de/features.roomdetails.impl_RoomDetailsDark_8_de.png index 27b306ff9e1..f6fe12c78af 100644 --- a/screenshots/de/features.roomdetails.impl_RoomDetailsDark_8_de.png +++ b/screenshots/de/features.roomdetails.impl_RoomDetailsDark_8_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:40043cedd9a088e853536b94f9e051a67fdd2216f4f2f8c97ba837b8503288ad -size 45573 +oid sha256:d78af3f2a5a18d43a2e2c7aef496b0a9e826448db27ca45f6aa2264385194aea +size 45511 diff --git a/screenshots/de/features.roomdetails.impl_RoomDetailsDark_9_de.png b/screenshots/de/features.roomdetails.impl_RoomDetailsDark_9_de.png index d70c05fa237..cd5134cc6b3 100644 --- a/screenshots/de/features.roomdetails.impl_RoomDetailsDark_9_de.png +++ b/screenshots/de/features.roomdetails.impl_RoomDetailsDark_9_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:702a543d1d4789700620def47079108a2aae90a8dd4211456f3bc3210ac70a1b -size 44526 +oid sha256:49b6ff5a0187daf1902e3b79367edaf65a3cfb3a330bc7166914dbd9c8ca9606 +size 44460 diff --git a/screenshots/de/features.roomdetails.impl_RoomDetails_0_de.png b/screenshots/de/features.roomdetails.impl_RoomDetails_0_de.png index 51741707291..48b94962264 100644 --- a/screenshots/de/features.roomdetails.impl_RoomDetails_0_de.png +++ b/screenshots/de/features.roomdetails.impl_RoomDetails_0_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9d8c94f1af798243115ceb6a29b6417bd70df97b4b9cbced5ef08555e1ea7cb9 -size 47520 +oid sha256:6fe2a7ab133e43acdc59731090b26cc91393e246c45fcc55e8f38df6e1f17521 +size 47500 diff --git a/screenshots/de/features.roomdetails.impl_RoomDetails_10_de.png b/screenshots/de/features.roomdetails.impl_RoomDetails_10_de.png index cfefa7b6194..637ac4455f3 100644 --- a/screenshots/de/features.roomdetails.impl_RoomDetails_10_de.png +++ b/screenshots/de/features.roomdetails.impl_RoomDetails_10_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d27908e15aab3fd35f2ec363a9e76626fb49208a4b54120157c7f96f4b64012a -size 46327 +oid sha256:9a106b162fbf7e67b0534b71140e34ebacf801c08d1ebe17bbeb760b460f03df +size 46303 diff --git a/screenshots/de/features.roomdetails.impl_RoomDetails_11_de.png b/screenshots/de/features.roomdetails.impl_RoomDetails_11_de.png index 6e7ada6e4a3..a9a740f83b2 100644 --- a/screenshots/de/features.roomdetails.impl_RoomDetails_11_de.png +++ b/screenshots/de/features.roomdetails.impl_RoomDetails_11_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:29735b713166068b12fc879b6d9f4a56ef2eee8fa2eb2ad1efc7b653ef32b0ff -size 44812 +oid sha256:4dda5a34c3e225141274c0452fc20d9519c14f34fad41e8dec50e786d8104918 +size 44838 diff --git a/screenshots/de/features.roomdetails.impl_RoomDetails_12_de.png b/screenshots/de/features.roomdetails.impl_RoomDetails_12_de.png index 180fe6fe50a..f69d34b445e 100644 --- a/screenshots/de/features.roomdetails.impl_RoomDetails_12_de.png +++ b/screenshots/de/features.roomdetails.impl_RoomDetails_12_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a27da9ad0f281dbca24c2a29a40344169898eead8828e25fedbc79c1c7d3ab71 -size 46673 +oid sha256:c3c749f1109ea691f01daaa4cce07f4c131c5d13bce64c4116fe8b848966e590 +size 46648 diff --git a/screenshots/de/features.roomdetails.impl_RoomDetails_13_de.png b/screenshots/de/features.roomdetails.impl_RoomDetails_13_de.png index 228a962ed57..d87e06a028b 100644 --- a/screenshots/de/features.roomdetails.impl_RoomDetails_13_de.png +++ b/screenshots/de/features.roomdetails.impl_RoomDetails_13_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:49de7c2ab75c132d37921a0a1d2c2906988eacd00e2c4e7dc938adcea7a44f1c -size 46579 +oid sha256:57053f9c361cf69db35174e3325b0b4753979f011b938648efc37500f0c1a185 +size 46560 diff --git a/screenshots/de/features.roomdetails.impl_RoomDetails_14_de.png b/screenshots/de/features.roomdetails.impl_RoomDetails_14_de.png index 5a671e051c8..fad796a3c0f 100644 --- a/screenshots/de/features.roomdetails.impl_RoomDetails_14_de.png +++ b/screenshots/de/features.roomdetails.impl_RoomDetails_14_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b5f8c8b5d6737d727593fa329a6967678772423a015b84fec12da940f299416c -size 47115 +oid sha256:65382b546da02a6bd3cccfbaaf49eb08288448984a1d701587c8baf4fa538b74 +size 47093 diff --git a/screenshots/de/features.roomdetails.impl_RoomDetails_15_de.png b/screenshots/de/features.roomdetails.impl_RoomDetails_15_de.png index da5fcd6b7b8..b524ebe3d96 100644 --- a/screenshots/de/features.roomdetails.impl_RoomDetails_15_de.png +++ b/screenshots/de/features.roomdetails.impl_RoomDetails_15_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c98e276e97214339d1a6b19a2d79ceb79f2d5267faf2c53c2332465a0bccc93c -size 47707 +oid sha256:e770eb2039774eca98cbe08aa46af19cc72aab7ae764f969cd1cfa7fe23dc98a +size 47687 diff --git a/screenshots/de/features.roomdetails.impl_RoomDetails_16_de.png b/screenshots/de/features.roomdetails.impl_RoomDetails_16_de.png index 324b1bc1a54..69936684995 100644 --- a/screenshots/de/features.roomdetails.impl_RoomDetails_16_de.png +++ b/screenshots/de/features.roomdetails.impl_RoomDetails_16_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e32bd7817e91bd16ce41e3afb76420ecd18729b673ae453f1949147a9d982898 -size 46929 +oid sha256:762116046969a7126042982e89b1b79dc82a4d1ff8763dcbf5b8343cdeca4612 +size 46909 diff --git a/screenshots/de/features.roomdetails.impl_RoomDetails_17_de.png b/screenshots/de/features.roomdetails.impl_RoomDetails_17_de.png index 143b1e151a2..0597569c932 100644 --- a/screenshots/de/features.roomdetails.impl_RoomDetails_17_de.png +++ b/screenshots/de/features.roomdetails.impl_RoomDetails_17_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7fc6cfb807c92f13c9ea01b72c4066a134a44bd9ffffeedb49fdd91e351f694c -size 46417 +oid sha256:c1c87b29315c1aec93f8c0c231f241deed8dbd37384468474c4db0b976d6dda4 +size 46395 diff --git a/screenshots/de/features.roomdetails.impl_RoomDetails_18_de.png b/screenshots/de/features.roomdetails.impl_RoomDetails_18_de.png index 1b9f0f20c5e..97c48d314e9 100644 --- a/screenshots/de/features.roomdetails.impl_RoomDetails_18_de.png +++ b/screenshots/de/features.roomdetails.impl_RoomDetails_18_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0981aedebb47c08cd7251bc596be8ea8946698118b8a23a23b4f36ebf7d538f2 -size 43299 +oid sha256:d5033268749c18000be1899d82ebffdc412a0c810855dc542202bf39c354060a +size 43327 diff --git a/screenshots/de/features.roomdetails.impl_RoomDetails_19_de.png b/screenshots/de/features.roomdetails.impl_RoomDetails_19_de.png index 4620074a3e6..49314777bce 100644 --- a/screenshots/de/features.roomdetails.impl_RoomDetails_19_de.png +++ b/screenshots/de/features.roomdetails.impl_RoomDetails_19_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:734259ef768f27d1f0d29defb9aa0560b126ec1e184f62c448c813a02552624c -size 43187 +oid sha256:82c7d37bdf17f5115f3e839e61085db472cbc2144e34b0c42db8f68c6868e269 +size 43215 diff --git a/screenshots/de/features.roomdetails.impl_RoomDetails_1_de.png b/screenshots/de/features.roomdetails.impl_RoomDetails_1_de.png index 25b06a9e299..008555c9980 100644 --- a/screenshots/de/features.roomdetails.impl_RoomDetails_1_de.png +++ b/screenshots/de/features.roomdetails.impl_RoomDetails_1_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3bc9395124a3cc86c4d6dc85708e952faf8c21d7d77a56dda56874bc3cf83941 -size 43154 +oid sha256:4a79e3d57386eb05835352d931fd264bcf0303bd21fa15cf44e9a3690d1e537b +size 43131 diff --git a/screenshots/de/features.roomdetails.impl_RoomDetails_2_de.png b/screenshots/de/features.roomdetails.impl_RoomDetails_2_de.png index 6d52eabf171..ea3581bc363 100644 --- a/screenshots/de/features.roomdetails.impl_RoomDetails_2_de.png +++ b/screenshots/de/features.roomdetails.impl_RoomDetails_2_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8406d0c3f7aaa773429c0c5bf68e8b4420ec8e4e2202d941ec33013d1b76c376 -size 40727 +oid sha256:5bcdcdad130bc08d5242a6f83286300455f7e42dd3eee37451dfb10d7b9d7655 +size 40708 diff --git a/screenshots/de/features.roomdetails.impl_RoomDetails_3_de.png b/screenshots/de/features.roomdetails.impl_RoomDetails_3_de.png index aa1f1b79fae..6bce89b21b8 100644 --- a/screenshots/de/features.roomdetails.impl_RoomDetails_3_de.png +++ b/screenshots/de/features.roomdetails.impl_RoomDetails_3_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d245e4cb448f87a3261e28e982543a50e0425c20ecea26309f736effc90a670e -size 46677 +oid sha256:5bb57d0c50fb54e3cf145cc614312a4c165118dfbfd7795b7bbfc06f4d4713d4 +size 46610 diff --git a/screenshots/de/features.roomdetails.impl_RoomDetails_4_de.png b/screenshots/de/features.roomdetails.impl_RoomDetails_4_de.png index ee1f81c202d..0b00f96dc66 100644 --- a/screenshots/de/features.roomdetails.impl_RoomDetails_4_de.png +++ b/screenshots/de/features.roomdetails.impl_RoomDetails_4_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:57e6188e4e841a9535ff5f38ff5fd1f2043eac3aa605ebc94cbfefbd7373dd31 -size 45850 +oid sha256:fee173f492c24aa69a4c64afba1f4cb856d742ac089a2bf25526a28e8fdaedae +size 45826 diff --git a/screenshots/de/features.roomdetails.impl_RoomDetails_5_de.png b/screenshots/de/features.roomdetails.impl_RoomDetails_5_de.png index 76fe0591bea..6bf05b9e6bf 100644 --- a/screenshots/de/features.roomdetails.impl_RoomDetails_5_de.png +++ b/screenshots/de/features.roomdetails.impl_RoomDetails_5_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:848d125ca33668838a9f24dcddd93e579f3b19e28e1436b2684da4311c67bdd9 -size 42845 +oid sha256:2ed7b3bb40e0dfb76872d85c07e147f55964622bf102435ae8e98524c52ede06 +size 42872 diff --git a/screenshots/de/features.roomdetails.impl_RoomDetails_6_de.png b/screenshots/de/features.roomdetails.impl_RoomDetails_6_de.png index aaf48f7fd4f..6649e7ee0c7 100644 --- a/screenshots/de/features.roomdetails.impl_RoomDetails_6_de.png +++ b/screenshots/de/features.roomdetails.impl_RoomDetails_6_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e353bf4a444ea89a065930c8adfcd9337ceca32cc8f048ffec3b751fce72abef -size 46519 +oid sha256:016e70e04d4fa9512a72ab00f33578b59b3cb5ef27881558cc4842cf5e89b025 +size 46503 diff --git a/screenshots/de/features.roomdetails.impl_RoomDetails_7_de.png b/screenshots/de/features.roomdetails.impl_RoomDetails_7_de.png index 6615d4c787e..59d5e3ae1e4 100644 --- a/screenshots/de/features.roomdetails.impl_RoomDetails_7_de.png +++ b/screenshots/de/features.roomdetails.impl_RoomDetails_7_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2d45174b0aceaf7594875c3039324d21d7f5fe18eaeef32d2e40826d0c59a102 -size 47787 +oid sha256:a15fae40d21bb7729f1a0dd383533d1941423d0812cf51e67adc56f5054f09d7 +size 47769 diff --git a/screenshots/de/features.roomdetails.impl_RoomDetails_8_de.png b/screenshots/de/features.roomdetails.impl_RoomDetails_8_de.png index db3dce5d97d..83df8713ae4 100644 --- a/screenshots/de/features.roomdetails.impl_RoomDetails_8_de.png +++ b/screenshots/de/features.roomdetails.impl_RoomDetails_8_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6efbbc7358ef22b3b32c0ee7517483d5a83cfd0aa261e2391cb85867e18528d3 -size 46691 +oid sha256:908aa907fa85dc18825e3ecb5da8ad12916577df28045b256c95a21b524575a8 +size 46670 diff --git a/screenshots/de/features.roomdetails.impl_RoomDetails_9_de.png b/screenshots/de/features.roomdetails.impl_RoomDetails_9_de.png index a3eb15f711f..746f9ff2c7e 100644 --- a/screenshots/de/features.roomdetails.impl_RoomDetails_9_de.png +++ b/screenshots/de/features.roomdetails.impl_RoomDetails_9_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2323c44e1e07f3a98fc5ba91c4cc4b5fe34780a83ef94028ca94bbf17bcf6fec -size 45558 +oid sha256:7776bbd14000795414995fc0428a80659cb6b45d06b35c6537eb66fd69b341c4 +size 45529 diff --git a/screenshots/de/features.roomdetailsedit.impl_RoomDetailsEditView_Day_0_de.png b/screenshots/de/features.roomdetailsedit.impl_RoomDetailsEditView_Day_0_de.png new file mode 100644 index 00000000000..fc8480727b8 --- /dev/null +++ b/screenshots/de/features.roomdetailsedit.impl_RoomDetailsEditView_Day_0_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:05578b1d3db4deb866bc0bffd899c2a59b498f8e87b046bfc5c0d56d1dbc4c2d +size 30020 diff --git a/screenshots/de/features.roomdetailsedit.impl_RoomDetailsEditView_Day_1_de.png b/screenshots/de/features.roomdetailsedit.impl_RoomDetailsEditView_Day_1_de.png new file mode 100644 index 00000000000..8f3b1f77d9f --- /dev/null +++ b/screenshots/de/features.roomdetailsedit.impl_RoomDetailsEditView_Day_1_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e2a8edde33dcd34fa936c57e0d9d71a6fedfea12f2a22dfd1e1d09c496d624e2 +size 24329 diff --git a/screenshots/de/features.roomdetailsedit.impl_RoomDetailsEditView_Day_2_de.png b/screenshots/de/features.roomdetailsedit.impl_RoomDetailsEditView_Day_2_de.png new file mode 100644 index 00000000000..f5220b6f4df --- /dev/null +++ b/screenshots/de/features.roomdetailsedit.impl_RoomDetailsEditView_Day_2_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b4b047dac7afc81637645deddc9f5798c1c29d4f58af220d258df1880d5cf461 +size 30453 diff --git a/screenshots/de/features.roomdetailsedit.impl_RoomDetailsEditView_Day_3_de.png b/screenshots/de/features.roomdetailsedit.impl_RoomDetailsEditView_Day_3_de.png new file mode 100644 index 00000000000..1a3d8adc291 --- /dev/null +++ b/screenshots/de/features.roomdetailsedit.impl_RoomDetailsEditView_Day_3_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4315d98ad25d51464e166b387b4300fd221315d9e6be17b735296c27e6ac0613 +size 52709 diff --git a/screenshots/de/features.roomdetailsedit.impl_RoomDetailsEditView_Day_4_de.png b/screenshots/de/features.roomdetailsedit.impl_RoomDetailsEditView_Day_4_de.png new file mode 100644 index 00000000000..b0e3928a6c8 --- /dev/null +++ b/screenshots/de/features.roomdetailsedit.impl_RoomDetailsEditView_Day_4_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4da0c481bf69057e27beb209e281b5d3feb0c474bb7b145c4ea6e4c1193b9d76 +size 45544 diff --git a/screenshots/de/features.roomdetailsedit.impl_RoomDetailsEditView_Day_5_de.png b/screenshots/de/features.roomdetailsedit.impl_RoomDetailsEditView_Day_5_de.png new file mode 100644 index 00000000000..afd8927f7af --- /dev/null +++ b/screenshots/de/features.roomdetailsedit.impl_RoomDetailsEditView_Day_5_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0ced60024867e61852b6a515d3b457dc3e3f0bf4f2565b105e529cd6cee05873 +size 30241 diff --git a/screenshots/de/features.roomdetailsedit.impl_RoomDetailsEditView_Day_6_de.png b/screenshots/de/features.roomdetailsedit.impl_RoomDetailsEditView_Day_6_de.png new file mode 100644 index 00000000000..45e07ce0050 --- /dev/null +++ b/screenshots/de/features.roomdetailsedit.impl_RoomDetailsEditView_Day_6_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:34ad9afc2f63d8d25263ec095bec2eca2b9c5eb9c0a2baf0e500e8f06fafd393 +size 30220 diff --git a/screenshots/de/features.roomdetailsedit.impl_RoomDetailsEditView_Day_7_de.png b/screenshots/de/features.roomdetailsedit.impl_RoomDetailsEditView_Day_7_de.png new file mode 100644 index 00000000000..0c516f1f36b --- /dev/null +++ b/screenshots/de/features.roomdetailsedit.impl_RoomDetailsEditView_Day_7_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5a086a64f8292be4e439d17b081fa98fa6dd571bb8707b7d29256b7a3115a680 +size 25684 diff --git a/screenshots/de/features.roomdetailsedit.impl_RoomDetailsEditView_Day_8_de.png b/screenshots/de/features.roomdetailsedit.impl_RoomDetailsEditView_Day_8_de.png new file mode 100644 index 00000000000..1deac439e15 --- /dev/null +++ b/screenshots/de/features.roomdetailsedit.impl_RoomDetailsEditView_Day_8_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b3a431355948e88681bc67407ced464ea3d35ee225820e49af6cc1b19df2fa9a +size 31127 diff --git a/screenshots/de/features.roomdetailsedit.impl_RoomDetailsEditView_Day_9_de.png b/screenshots/de/features.roomdetailsedit.impl_RoomDetailsEditView_Day_9_de.png new file mode 100644 index 00000000000..f3a2515aad4 --- /dev/null +++ b/screenshots/de/features.roomdetailsedit.impl_RoomDetailsEditView_Day_9_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e7511a9c88430b8ef33575754546593bbba0de0653c7b915eb4fd08e28d46f35 +size 32623 diff --git a/screenshots/de/features.roommembermoderation.impl_RoomMemberModerationView_Day_0_de.png b/screenshots/de/features.roommembermoderation.impl_RoomMemberModerationView_Day_0_de.png index 90d52aa97c8..a5638d89f3d 100644 --- a/screenshots/de/features.roommembermoderation.impl_RoomMemberModerationView_Day_0_de.png +++ b/screenshots/de/features.roommembermoderation.impl_RoomMemberModerationView_Day_0_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:22352da5dc1213be58d28663f6696ca0c0588422576e0e018fae7887edbbd25c -size 18458 +oid sha256:90712ac1929c94927172c10a09ed8c9955e44aa4d87d02020f9bc8781d87f30c +size 18775 diff --git a/screenshots/de/features.roommembermoderation.impl_RoomMemberModerationView_Day_1_de.png b/screenshots/de/features.roommembermoderation.impl_RoomMemberModerationView_Day_1_de.png index a3fffe85d94..9c94b62b9c2 100644 --- a/screenshots/de/features.roommembermoderation.impl_RoomMemberModerationView_Day_1_de.png +++ b/screenshots/de/features.roommembermoderation.impl_RoomMemberModerationView_Day_1_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e23b88a9cbcbb626dc222660560a9e936b4f0060b6978296d42a7913d19d68bd -size 22298 +oid sha256:e2871173d98a9add3a41617e6033ce4a293e7ee683997a2ffe9e60f4ecc5513b +size 22477 diff --git a/screenshots/de/features.roommembermoderation.impl_RoomMemberModerationView_Day_2_de.png b/screenshots/de/features.roommembermoderation.impl_RoomMemberModerationView_Day_2_de.png index 2a137dda7d8..207a8409785 100644 --- a/screenshots/de/features.roommembermoderation.impl_RoomMemberModerationView_Day_2_de.png +++ b/screenshots/de/features.roommembermoderation.impl_RoomMemberModerationView_Day_2_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4b9761ab02b0421f77a4f9876482817d9be98553ef72291e5d7a4679a09b27c6 -size 27750 +oid sha256:25800bbb0ebf715f7d8977ecd466c545c7b603703e145ba8d87e5572f4bf5421 +size 28052 diff --git a/screenshots/de/features.roommembermoderation.impl_RoomMemberModerationView_Day_3_de.png b/screenshots/de/features.roommembermoderation.impl_RoomMemberModerationView_Day_3_de.png index 4ffe9e5c534..348bc07623d 100644 --- a/screenshots/de/features.roommembermoderation.impl_RoomMemberModerationView_Day_3_de.png +++ b/screenshots/de/features.roommembermoderation.impl_RoomMemberModerationView_Day_3_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:aa3fe7ed313bbfdaee18fc9b653008ab7e9bedbe806647b0e8842ed17fc450e3 -size 28168 +oid sha256:3e3c9507e122bf1e90a459ec0a772baaf3811f7f7d41e9e48f20bc2528aee5d1 +size 28467 diff --git a/screenshots/de/features.securityandprivacy.impl.editroomaddress_EditRoomAddressView_Day_0_de.png b/screenshots/de/features.securityandprivacy.impl.editroomaddress_EditRoomAddressView_Day_0_de.png new file mode 100644 index 00000000000..92583b6be9b --- /dev/null +++ b/screenshots/de/features.securityandprivacy.impl.editroomaddress_EditRoomAddressView_Day_0_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:92b0b1674e1c7a21aa8655de176df36e13f83527eac71668d5704ac883326b8e +size 31643 diff --git a/screenshots/de/features.securityandprivacy.impl.editroomaddress_EditRoomAddressView_Day_1_de.png b/screenshots/de/features.securityandprivacy.impl.editroomaddress_EditRoomAddressView_Day_1_de.png new file mode 100644 index 00000000000..8dc8cf1e8b9 --- /dev/null +++ b/screenshots/de/features.securityandprivacy.impl.editroomaddress_EditRoomAddressView_Day_1_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d37a361c48255eaf0ec25d3f6aa43c1da2fca4e3bf805407f32c2dd5697a4340 +size 36154 diff --git a/screenshots/de/features.securityandprivacy.impl.editroomaddress_EditRoomAddressView_Day_2_de.png b/screenshots/de/features.securityandprivacy.impl.editroomaddress_EditRoomAddressView_Day_2_de.png new file mode 100644 index 00000000000..e087600f05d --- /dev/null +++ b/screenshots/de/features.securityandprivacy.impl.editroomaddress_EditRoomAddressView_Day_2_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c299ab24b5ea713c4844cc558b900f709be44e05638c02cfd3baa37784425ee8 +size 36839 diff --git a/screenshots/de/features.securityandprivacy.impl.editroomaddress_EditRoomAddressView_Day_3_de.png b/screenshots/de/features.securityandprivacy.impl.editroomaddress_EditRoomAddressView_Day_3_de.png new file mode 100644 index 00000000000..11b1caadee8 --- /dev/null +++ b/screenshots/de/features.securityandprivacy.impl.editroomaddress_EditRoomAddressView_Day_3_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:68b6dd8e7eea116b88b51b97efa4e73fa4a2675785f227d88a79f2ebfa664d52 +size 31498 diff --git a/screenshots/de/features.securityandprivacy.impl.editroomaddress_EditRoomAddressView_Day_4_de.png b/screenshots/de/features.securityandprivacy.impl.editroomaddress_EditRoomAddressView_Day_4_de.png new file mode 100644 index 00000000000..ed9e845bf32 --- /dev/null +++ b/screenshots/de/features.securityandprivacy.impl.editroomaddress_EditRoomAddressView_Day_4_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c0e2149fe04be272b692451d28779737616fc79e00f7a98c7ff6db1a29c2a0f6 +size 32956 diff --git a/screenshots/de/features.securityandprivacy.impl.manageauthorizedspaces_ManageAuthorizedSpacesView_Day_0_de.png b/screenshots/de/features.securityandprivacy.impl.manageauthorizedspaces_ManageAuthorizedSpacesView_Day_0_de.png new file mode 100644 index 00000000000..b990d04681b --- /dev/null +++ b/screenshots/de/features.securityandprivacy.impl.manageauthorizedspaces_ManageAuthorizedSpacesView_Day_0_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:07aabff0edce8dcf8ec265a984b80471bc9992c4673e14cea580b529361d826f +size 51274 diff --git a/screenshots/de/features.securityandprivacy.impl.manageauthorizedspaces_ManageAuthorizedSpacesView_Day_1_de.png b/screenshots/de/features.securityandprivacy.impl.manageauthorizedspaces_ManageAuthorizedSpacesView_Day_1_de.png new file mode 100644 index 00000000000..b990d04681b --- /dev/null +++ b/screenshots/de/features.securityandprivacy.impl.manageauthorizedspaces_ManageAuthorizedSpacesView_Day_1_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:07aabff0edce8dcf8ec265a984b80471bc9992c4673e14cea580b529361d826f +size 51274 diff --git a/screenshots/de/features.securityandprivacy.impl.manageauthorizedspaces_ManageAuthorizedSpacesView_Day_2_de.png b/screenshots/de/features.securityandprivacy.impl.manageauthorizedspaces_ManageAuthorizedSpacesView_Day_2_de.png new file mode 100644 index 00000000000..b05eab9cf44 --- /dev/null +++ b/screenshots/de/features.securityandprivacy.impl.manageauthorizedspaces_ManageAuthorizedSpacesView_Day_2_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9831e628d692bf5950ee80b9ded876b1ea0f4b4aa06992665ba37322389959d9 +size 51859 diff --git a/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_0_de.png b/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_0_de.png new file mode 100644 index 00000000000..5d1f16dd7f3 --- /dev/null +++ b/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_0_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b63be55a00b8674d61d3a0e3130b67798831b823e006ecb9862c967bcc14af73 +size 48887 diff --git a/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_10_de.png b/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_10_de.png new file mode 100644 index 00000000000..8db17f8870a --- /dev/null +++ b/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_10_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0ffbf38bbc9531fac752db135cc15a47c668c082ad44d99295c338c1360a762a +size 21528 diff --git a/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_11_de.png b/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_11_de.png new file mode 100644 index 00000000000..a45c969ded8 --- /dev/null +++ b/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_11_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ac0ed373337031699ee229e701ab80d7840a52fca701da27e558a5bf2fa52733 +size 46050 diff --git a/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_12_de.png b/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_12_de.png new file mode 100644 index 00000000000..b46cd6611a2 --- /dev/null +++ b/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_12_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:30310d98ac621fb1b112deec973f9bcd607902ffaa306dfb46c4f1b3f6033618 +size 45839 diff --git a/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_13_de.png b/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_13_de.png new file mode 100644 index 00000000000..ee9f81265ba --- /dev/null +++ b/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_13_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1ef23c8c965b0a30ad3ff9fa4967e11116e7d65147b09964327754b2e18519aa +size 46031 diff --git a/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_14_de.png b/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_14_de.png new file mode 100644 index 00000000000..3433c03e147 --- /dev/null +++ b/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_14_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:05fa0b8de55786d8e1fa17b3eef33ed0559ff511d27fdf18254330ab4d50814d +size 29573 diff --git a/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_15_de.png b/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_15_de.png new file mode 100644 index 00000000000..5f01844e684 --- /dev/null +++ b/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_15_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:471dac9ac225468f8778a7126510588dcc9373da1243b9a9b852f3d066be7cd4 +size 62487 diff --git a/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_16_de.png b/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_16_de.png new file mode 100644 index 00000000000..5f01844e684 --- /dev/null +++ b/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_16_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:471dac9ac225468f8778a7126510588dcc9373da1243b9a9b852f3d066be7cd4 +size 62487 diff --git a/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_17_de.png b/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_17_de.png new file mode 100644 index 00000000000..e5b6aea68e5 --- /dev/null +++ b/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_17_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:84ee198afa3631015c7e97ba63085cff41ff35cdc5d27730916a0b63e541bf92 +size 45701 diff --git a/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_18_de.png b/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_18_de.png new file mode 100644 index 00000000000..ee9f81265ba --- /dev/null +++ b/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_18_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1ef23c8c965b0a30ad3ff9fa4967e11116e7d65147b09964327754b2e18519aa +size 46031 diff --git a/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_19_de.png b/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_19_de.png new file mode 100644 index 00000000000..64d8d2dfff0 --- /dev/null +++ b/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_19_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5a76f9c1b0e509ff6921c2f0ce5c4e94513ecdf037c89845cbd1198326a85084 +size 46483 diff --git a/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_1_de.png b/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_1_de.png new file mode 100644 index 00000000000..9f3b06d0020 --- /dev/null +++ b/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_1_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3f4c9ca3bd72b801faa443e486c014e13028b41b1457438bdc916e49c94fd2a9 +size 62858 diff --git a/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_20_de.png b/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_20_de.png new file mode 100644 index 00000000000..ea75d6dc6c1 --- /dev/null +++ b/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_20_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:14cc7e1ded2ac32c9ab30cdab5f41ab0623bd30e5ecaa5a92e332d4442682931 +size 42427 diff --git a/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_21_de.png b/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_21_de.png new file mode 100644 index 00000000000..9f366decb45 --- /dev/null +++ b/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_21_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:51a4539dc71b21f5a5ed0e2ecb81e0cf5284e646ae30f13dee117b3efca1b58a +size 35316 diff --git a/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_22_de.png b/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_22_de.png new file mode 100644 index 00000000000..3875c8e4b62 --- /dev/null +++ b/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_22_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7623c8df791a67f10a417053c3cc77b23504b9761dc173fbd297a1501d7516b0 +size 42730 diff --git a/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_23_de.png b/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_23_de.png new file mode 100644 index 00000000000..b4c56047727 --- /dev/null +++ b/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_23_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4a0828e126ac554d0dc8bb1411afd6678d4b27af584370b363bfb790469a5580 +size 46430 diff --git a/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_2_de.png b/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_2_de.png new file mode 100644 index 00000000000..ce55465ab0c --- /dev/null +++ b/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_2_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c3a7498158997a5a2066a485517f6c93d17c26379247af55e9e474ad56348c17 +size 62658 diff --git a/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_3_de.png b/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_3_de.png new file mode 100644 index 00000000000..b0c90290c71 --- /dev/null +++ b/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_3_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:eb30cc79a305d9cf053f5771e4c8cc884f9f2b43a511479d5cceba9b2fb2af72 +size 62967 diff --git a/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_4_de.png b/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_4_de.png new file mode 100644 index 00000000000..6754d010653 --- /dev/null +++ b/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_4_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3fc9f4c6d46b3367599b13d91f3c2999c3e5ca9c4e9a23ec682323eba5a264af +size 57356 diff --git a/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_5_de.png b/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_5_de.png new file mode 100644 index 00000000000..34571c85fb1 --- /dev/null +++ b/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_5_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a48e68089cc50619635388edf34c22e9731025fc41e100b92705c44d16b06da2 +size 64405 diff --git a/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_6_de.png b/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_6_de.png new file mode 100644 index 00000000000..34571c85fb1 --- /dev/null +++ b/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_6_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a48e68089cc50619635388edf34c22e9731025fc41e100b92705c44d16b06da2 +size 64405 diff --git a/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_7_de.png b/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_7_de.png new file mode 100644 index 00000000000..4bcceb16d28 --- /dev/null +++ b/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_7_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5cfdf54e90f9ce10681e864c930c2faede907295b66cc76ab492eb4cf26b48c3 +size 64600 diff --git a/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_8_de.png b/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_8_de.png new file mode 100644 index 00000000000..162d08a1321 --- /dev/null +++ b/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_8_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:be7f479ac2ef41098a6c5e14dfb691f732bf4dc6913aa8dc45bc0325f6790a63 +size 62843 diff --git a/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_9_de.png b/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_9_de.png new file mode 100644 index 00000000000..69f766576fe --- /dev/null +++ b/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_9_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:66bc0585bfb5451813c1be93976c12c6b0641f663b338933f310008a2d7130db +size 60369 diff --git a/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_0_de.png b/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_0_de.png new file mode 100644 index 00000000000..4ad76fb2fde --- /dev/null +++ b/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_0_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5e384b9256cf2a479fd20cf35a12dcddfebf717aa984e08803a48e30f167835d +size 50507 diff --git a/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_10_de.png b/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_10_de.png new file mode 100644 index 00000000000..23f4566d1cd --- /dev/null +++ b/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_10_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:77936d932f152408acbcc08c2d0e468ff71f228de412b76d64326741f00bfe74 +size 22192 diff --git a/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_11_de.png b/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_11_de.png new file mode 100644 index 00000000000..ba206f3135c --- /dev/null +++ b/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_11_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f0912cbfc808cf5a0a0e16b7b20cb2b7b3a6309e5f37dc70fdd58c4d451b6df6 +size 47590 diff --git a/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_12_de.png b/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_12_de.png new file mode 100644 index 00000000000..59841e7d26d --- /dev/null +++ b/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_12_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3ffffc75a7d777b38b51188cb7aeca5ee443dec7c0a63929ab65ce65d52ec8e0 +size 47334 diff --git a/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_13_de.png b/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_13_de.png new file mode 100644 index 00000000000..f49c75354af --- /dev/null +++ b/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_13_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d59f6556c7f4c39ccd2e5dc552c2018321651e0a6325740da66c07078afed396 +size 47590 diff --git a/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_14_de.png b/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_14_de.png new file mode 100644 index 00000000000..7fed91d2dea --- /dev/null +++ b/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_14_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c64e1a0dfdc09e58406dda836cdbb61453fb1a020c326bc4bf3f1046357e7225 +size 30804 diff --git a/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_15_de.png b/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_15_de.png new file mode 100644 index 00000000000..59a51fb3178 --- /dev/null +++ b/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_15_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d7c165d765ebba90862eabb42b1fca2ec2132e292c2b27bc67b915b34a8c7718 +size 64534 diff --git a/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_16_de.png b/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_16_de.png new file mode 100644 index 00000000000..59a51fb3178 --- /dev/null +++ b/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_16_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d7c165d765ebba90862eabb42b1fca2ec2132e292c2b27bc67b915b34a8c7718 +size 64534 diff --git a/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_17_de.png b/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_17_de.png new file mode 100644 index 00000000000..d0ac6369c4c --- /dev/null +++ b/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_17_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:25a36fac5577512763276853f331d1c6b7ca66d5363f2974cf2a93ecc081dabe +size 47262 diff --git a/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_18_de.png b/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_18_de.png new file mode 100644 index 00000000000..f49c75354af --- /dev/null +++ b/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_18_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d59f6556c7f4c39ccd2e5dc552c2018321651e0a6325740da66c07078afed396 +size 47590 diff --git a/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_19_de.png b/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_19_de.png new file mode 100644 index 00000000000..85126df232f --- /dev/null +++ b/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_19_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ad979d8842f604873e47914d96892f0e68579b1f0e72f1527266340a71ad2768 +size 48204 diff --git a/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_1_de.png b/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_1_de.png new file mode 100644 index 00000000000..864c32b2356 --- /dev/null +++ b/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_1_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1609ad886bda6464023ce5bbe5d0481f4d261aab4b3bace7c533a1bffcf4589f +size 65203 diff --git a/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_20_de.png b/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_20_de.png new file mode 100644 index 00000000000..a250e63f31e --- /dev/null +++ b/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_20_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d6fe5fbc711c2c1b323f4ac05b99273250d937616addd16537b6e59c2677460b +size 44040 diff --git a/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_21_de.png b/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_21_de.png new file mode 100644 index 00000000000..53176f2955d --- /dev/null +++ b/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_21_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5c87795a614f80b51cced1fdd3bca44fa8cc340c87e0f407fb55630adb24579b +size 37378 diff --git a/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_22_de.png b/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_22_de.png new file mode 100644 index 00000000000..0ba632c505b --- /dev/null +++ b/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_22_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0d67d9ece1f3e2184e703aaa10ffffae4c4393a252bf005d881fd04a766af53e +size 44994 diff --git a/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_23_de.png b/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_23_de.png new file mode 100644 index 00000000000..28eb79f8ed3 --- /dev/null +++ b/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_23_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9bb901470cae356324c49c51d09e3bdb73e39faa6db0d18ea9bef4903d9dc95b +size 48562 diff --git a/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_2_de.png b/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_2_de.png new file mode 100644 index 00000000000..eeb39eda6c7 --- /dev/null +++ b/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_2_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2e7e0e331e495c0ab367348836811d1e93785f20a3307a6afdef6383b40f0227 +size 64964 diff --git a/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_3_de.png b/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_3_de.png new file mode 100644 index 00000000000..fc11352ad2b --- /dev/null +++ b/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_3_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:701b0b0bf9e0b0b33122f719de44c92a93a569375a176ad486b54f2789b8cb62 +size 65423 diff --git a/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_4_de.png b/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_4_de.png new file mode 100644 index 00000000000..751d75ff7ef --- /dev/null +++ b/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_4_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b569e7f7d33f47d2ea98088d9fe9a84986eba40081a78c3eaf9646115cf52a65 +size 59854 diff --git a/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_5_de.png b/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_5_de.png new file mode 100644 index 00000000000..7317dfdf1d8 --- /dev/null +++ b/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_5_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8ff12fcb931ec685028ef6f9826d9b4626aa3fffd7f496e99dd05e5445163d3e +size 66589 diff --git a/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_6_de.png b/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_6_de.png new file mode 100644 index 00000000000..7317dfdf1d8 --- /dev/null +++ b/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_6_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8ff12fcb931ec685028ef6f9826d9b4626aa3fffd7f496e99dd05e5445163d3e +size 66589 diff --git a/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_7_de.png b/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_7_de.png new file mode 100644 index 00000000000..ab704a63216 --- /dev/null +++ b/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_7_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b268474bc233b94aece069148e14ddd64e936a0980e0f2087955563042b6faf9 +size 66999 diff --git a/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_8_de.png b/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_8_de.png new file mode 100644 index 00000000000..2985f52e972 --- /dev/null +++ b/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_8_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:3d741419ca1227e56f91420928d86294daebee5a766ca91170a379f20aecdf6c +size 65202 diff --git a/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_9_de.png b/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_9_de.png new file mode 100644 index 00000000000..7aa8786d05a --- /dev/null +++ b/screenshots/de/features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_9_de.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:edb49a6728feb9337beeebccf376de82608c69e2daf98a368f4326297645ff23 +size 62803 diff --git a/screenshots/de/features.space.impl.leave_LeaveSpaceView_Day_9_de.png b/screenshots/de/features.space.impl.leave_LeaveSpaceView_Day_9_de.png index 7fdf34a72de..b6ea8e0124b 100644 --- a/screenshots/de/features.space.impl.leave_LeaveSpaceView_Day_9_de.png +++ b/screenshots/de/features.space.impl.leave_LeaveSpaceView_Day_9_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:90f702d73cbcbb3e5edcef7cf685d7fa03b0b72caca50f3e8de11311d901e765 -size 30323 +oid sha256:b555c8a2b269a132e1344e2959ac15eb2dff7ad485a929bdefc0abefb5c7e9b7 +size 38028 diff --git a/screenshots/de/features.startchat.impl.components_SearchMultipleUsersResultItem_de.png b/screenshots/de/features.startchat.impl.components_SearchMultipleUsersResultItem_de.png index b982015aa8c..28418751a57 100644 --- a/screenshots/de/features.startchat.impl.components_SearchMultipleUsersResultItem_de.png +++ b/screenshots/de/features.startchat.impl.components_SearchMultipleUsersResultItem_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9095e96b3bec5fe4a0ae7c7e8d92da5c3b6cf1124d861841bb0beebf1a05c341 -size 101077 +oid sha256:ecea6fc10d3d405e26f5ed488bc858b78e7dab1d6cafb76cc7a2feed3d3eddc6 +size 96954 diff --git a/screenshots/de/features.startchat.impl.components_SearchSingleUserResultItem_de.png b/screenshots/de/features.startchat.impl.components_SearchSingleUserResultItem_de.png index 17b20844ec6..0bae881bb1b 100644 --- a/screenshots/de/features.startchat.impl.components_SearchSingleUserResultItem_de.png +++ b/screenshots/de/features.startchat.impl.components_SearchSingleUserResultItem_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2ce695e52de4d6bfdb0aaed99f8ece00e6029216f5e410a86d714f84e367ed37 -size 51258 +oid sha256:feab59ca75900214799f9a81f50a96a5622224bacf6c236857d7c7ae267108e8 +size 52398 diff --git a/screenshots/de/features.startchat.impl.components_UserListView_Day_0_de.png b/screenshots/de/features.startchat.impl.components_UserListView_Day_0_de.png index 1f2648a705d..20761a5b3e1 100644 --- a/screenshots/de/features.startchat.impl.components_UserListView_Day_0_de.png +++ b/screenshots/de/features.startchat.impl.components_UserListView_Day_0_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f097b3df60f86719f7afeff0c47139d1ebf918b181bae9f8e1735368ef9bd503 -size 9248 +oid sha256:03ef449ecfe5699f1135a83e128d6efb93efc53a84036ff364c6ea489ef9a781 +size 9269 diff --git a/screenshots/de/features.startchat.impl.components_UserListView_Day_1_de.png b/screenshots/de/features.startchat.impl.components_UserListView_Day_1_de.png index bf860dc9ed3..1ff42f7afed 100644 --- a/screenshots/de/features.startchat.impl.components_UserListView_Day_1_de.png +++ b/screenshots/de/features.startchat.impl.components_UserListView_Day_1_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6cdf90ae9ef087c52d69297d9c98e81f49a63ff92029a1925dbe3f666106536b -size 21801 +oid sha256:058ac0f03151792e2a55090a4f170e5332e94339c56fbf029b05b6050c155549 +size 21841 diff --git a/screenshots/de/features.startchat.impl.components_UserListView_Day_2_de.png b/screenshots/de/features.startchat.impl.components_UserListView_Day_2_de.png index 79a25499ad1..05a41f4fd05 100644 --- a/screenshots/de/features.startchat.impl.components_UserListView_Day_2_de.png +++ b/screenshots/de/features.startchat.impl.components_UserListView_Day_2_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:8f3641f966fcf71259afeedc2c4b32d3e2c581af23c9c4220c9b80152501e77f -size 7949 +oid sha256:f29343d52eb4641591c6e1fba1d889e5ff5d39839708cf79697fc1745ed90c67 +size 8077 diff --git a/screenshots/de/features.startchat.impl.components_UserListView_Day_7_de.png b/screenshots/de/features.startchat.impl.components_UserListView_Day_7_de.png index 3ba65e7151e..80bfc28b09b 100644 --- a/screenshots/de/features.startchat.impl.components_UserListView_Day_7_de.png +++ b/screenshots/de/features.startchat.impl.components_UserListView_Day_7_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:251848f9afc246fb2ec6f36da893878360e24634efdcb3920aac174768f9ad6e -size 12298 +oid sha256:dc767435f491d1700a220497c0cc6d73da3d823e9f042bb6e9c65eaa7fcc018c +size 12375 diff --git a/screenshots/de/features.startchat.impl.components_UserListView_Day_9_de.png b/screenshots/de/features.startchat.impl.components_UserListView_Day_9_de.png index 209c1220512..1f2c501d1a3 100644 --- a/screenshots/de/features.startchat.impl.components_UserListView_Day_9_de.png +++ b/screenshots/de/features.startchat.impl.components_UserListView_Day_9_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5ffb21cbd09d472fe88da527a064a57862e40ffbc3208247398ab3db45ddcc14 -size 37168 +oid sha256:59d6fcb404f29561e3e56ec4a76c07b8d88d813c790ce4badb54699415835a1d +size 38127 diff --git a/screenshots/de/features.startchat.impl.root_StartChatView_Day_0_de.png b/screenshots/de/features.startchat.impl.root_StartChatView_Day_0_de.png index ac7f3fc01b0..bdc1faebb7f 100644 --- a/screenshots/de/features.startchat.impl.root_StartChatView_Day_0_de.png +++ b/screenshots/de/features.startchat.impl.root_StartChatView_Day_0_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:23f792a9a1f33ec9bf3548485579983c98675e2ffb0400489cafa6aeabbd68bf -size 28076 +oid sha256:771be782de8b73b0bdb9ac6208102f2f773cee37e98fa614334c6ad9eb7f1299 +size 28118 diff --git a/screenshots/de/features.startchat.impl.root_StartChatView_Day_1_de.png b/screenshots/de/features.startchat.impl.root_StartChatView_Day_1_de.png index 0582665018e..eddbdd659b3 100644 --- a/screenshots/de/features.startchat.impl.root_StartChatView_Day_1_de.png +++ b/screenshots/de/features.startchat.impl.root_StartChatView_Day_1_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:074f07583d066deac988cb26df841a4d6a8ec1713af4eb42c89bee693fe6baa6 -size 21018 +oid sha256:a535b503bd51307d37e81071e90e993b9197beddb771e6164525f5d4ea30421a +size 21112 diff --git a/screenshots/de/features.startchat.impl.root_StartChatView_Day_2_de.png b/screenshots/de/features.startchat.impl.root_StartChatView_Day_2_de.png index f9be1b4c799..a970242e06e 100644 --- a/screenshots/de/features.startchat.impl.root_StartChatView_Day_2_de.png +++ b/screenshots/de/features.startchat.impl.root_StartChatView_Day_2_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:eeba1031812aa4029a3d0fe55b637b2b1187c9b809240280e3ac93348f03b7eb -size 31837 +oid sha256:e7698997e24292a0faf338ccdd386c95c475f7d0bc835e94a82703fcf257bab4 +size 31951 diff --git a/screenshots/de/features.startchat.impl.root_StartChatView_Day_3_de.png b/screenshots/de/features.startchat.impl.root_StartChatView_Day_3_de.png index e0af3ed6dce..3e70e4d2202 100644 --- a/screenshots/de/features.startchat.impl.root_StartChatView_Day_3_de.png +++ b/screenshots/de/features.startchat.impl.root_StartChatView_Day_3_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e2fe8f9ac0af9e866f4e79af1c99680495f4a7ef96ebc440b48eb22419562565 -size 55164 +oid sha256:16da63819524ef1e4535776caaa9e073731fe79f85ba798b57d8552999cc8789 +size 52219 diff --git a/screenshots/de/features.startchat.impl.root_StartChatView_Day_4_de.png b/screenshots/de/features.startchat.impl.root_StartChatView_Day_4_de.png index 8c027060751..a0e0627c219 100644 --- a/screenshots/de/features.startchat.impl.root_StartChatView_Day_4_de.png +++ b/screenshots/de/features.startchat.impl.root_StartChatView_Day_4_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3cd6e4e494cd61e5cccc61786e33565e66108e3d9315dade7e091ee8233047e7 -size 45620 +oid sha256:f3a846c1d350efed3709f806d07983cf26fe9b6de78e4f3bce3df55002c2eb89 +size 45654 diff --git a/screenshots/de/features.startchat.impl.root_StartChatView_Day_5_de.png b/screenshots/de/features.startchat.impl.root_StartChatView_Day_5_de.png index e7b3e18d434..24e0ba7b395 100644 --- a/screenshots/de/features.startchat.impl.root_StartChatView_Day_5_de.png +++ b/screenshots/de/features.startchat.impl.root_StartChatView_Day_5_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:f7000b367f113aaf18a08089e01cfc3d46228032a0a9fdab19a9e5c89fa28c24 -size 32084 +oid sha256:d81b395d52397f11e919d4f1de9cf64b4adb12a5c060c91ddbc91cce4bc71e2c +size 32104 diff --git a/screenshots/de/features.userprofile.shared_UserProfileHeaderSection_Day_0_de.png b/screenshots/de/features.userprofile.shared_UserProfileHeaderSection_Day_0_de.png index 16138ae8f35..72323a22e6f 100644 --- a/screenshots/de/features.userprofile.shared_UserProfileHeaderSection_Day_0_de.png +++ b/screenshots/de/features.userprofile.shared_UserProfileHeaderSection_Day_0_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d9abcfd14cc3ac9fd3029dabcf0d4251941a02251a1f869ed8fe8fca8e790bae -size 16045 +oid sha256:cdb2f46dd809a4d8e28be46dcd84fed0cae4c734a5d4b65b0dd8a00849fd9df0 +size 16091 diff --git a/screenshots/de/features.userprofile.shared_UserProfileView_Day_2_de.png b/screenshots/de/features.userprofile.shared_UserProfileView_Day_2_de.png index 2a5acc9faa9..0dd7f82cde3 100644 --- a/screenshots/de/features.userprofile.shared_UserProfileView_Day_2_de.png +++ b/screenshots/de/features.userprofile.shared_UserProfileView_Day_2_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a4c6f61958bf82c3057e2fb0e83ce42b2425249c40f0af99983b6354be8d4952 -size 25188 +oid sha256:71063d31e200aa93ae2c966c7291d61bc325f1dc658afcf96150ccc7ad8e3322 +size 25238 diff --git a/screenshots/de/features.verifysession.impl.incoming_IncomingVerificationView_Day_0_de.png b/screenshots/de/features.verifysession.impl.incoming_IncomingVerificationView_Day_0_de.png index f1620e0b314..ea2c4d0f360 100644 --- a/screenshots/de/features.verifysession.impl.incoming_IncomingVerificationView_Day_0_de.png +++ b/screenshots/de/features.verifysession.impl.incoming_IncomingVerificationView_Day_0_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e9a4da42e5fb1bdd969d83c99f39bdd160fd277124499420b5f39f5e081a9f0c -size 48298 +oid sha256:51ca53f9c4bae68ce223da3fb7b7a08dd0beb6e9c0ebdc98c7f3e9396794325f +size 48597 diff --git a/screenshots/de/features.verifysession.impl.incoming_IncomingVerificationView_Day_10_de.png b/screenshots/de/features.verifysession.impl.incoming_IncomingVerificationView_Day_10_de.png index 1d5e3746082..cb13bd106ea 100644 --- a/screenshots/de/features.verifysession.impl.incoming_IncomingVerificationView_Day_10_de.png +++ b/screenshots/de/features.verifysession.impl.incoming_IncomingVerificationView_Day_10_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2afa86e0dc92132e23429bebe2d170977944012e3690d341e96c876e5ee8715a -size 28087 +oid sha256:981fa0c97956df43d3e278da0b043d9baecc427a0c7b1ef6b7a326e9f9d3736f +size 28458 diff --git a/screenshots/de/features.verifysession.impl.incoming_IncomingVerificationView_Day_11_de.png b/screenshots/de/features.verifysession.impl.incoming_IncomingVerificationView_Day_11_de.png index 73b5c74c390..e97935ee333 100644 --- a/screenshots/de/features.verifysession.impl.incoming_IncomingVerificationView_Day_11_de.png +++ b/screenshots/de/features.verifysession.impl.incoming_IncomingVerificationView_Day_11_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:dad66c15257d320ba504995128924c88e7f121e0924ba66a5b789cd98819118b -size 29432 +oid sha256:89d4b4173e001c79a6019105b5ca2c1b3dbc993be67232c73e3205f2891f769a +size 29799 diff --git a/screenshots/de/features.verifysession.impl.incoming_IncomingVerificationView_Day_1_de.png b/screenshots/de/features.verifysession.impl.incoming_IncomingVerificationView_Day_1_de.png index f1620e0b314..ea2c4d0f360 100644 --- a/screenshots/de/features.verifysession.impl.incoming_IncomingVerificationView_Day_1_de.png +++ b/screenshots/de/features.verifysession.impl.incoming_IncomingVerificationView_Day_1_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e9a4da42e5fb1bdd969d83c99f39bdd160fd277124499420b5f39f5e081a9f0c -size 48298 +oid sha256:51ca53f9c4bae68ce223da3fb7b7a08dd0beb6e9c0ebdc98c7f3e9396794325f +size 48597 diff --git a/screenshots/de/features.verifysession.impl.incoming_IncomingVerificationView_Day_2_de.png b/screenshots/de/features.verifysession.impl.incoming_IncomingVerificationView_Day_2_de.png index 2c2febe8f06..b534c6c5c2d 100644 --- a/screenshots/de/features.verifysession.impl.incoming_IncomingVerificationView_Day_2_de.png +++ b/screenshots/de/features.verifysession.impl.incoming_IncomingVerificationView_Day_2_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9c2f88235b71a5e8dc496b54ae3d6abf60ffd99372ad952b2588e4eb3d3a4104 -size 43338 +oid sha256:da24f298107887de836c9320588c47e23720b6cc36735472f2bf5b8b545eb072 +size 43720 diff --git a/screenshots/de/features.verifysession.impl.incoming_IncomingVerificationView_Day_3_de.png b/screenshots/de/features.verifysession.impl.incoming_IncomingVerificationView_Day_3_de.png index 54c412001b6..ff421ffaa79 100644 --- a/screenshots/de/features.verifysession.impl.incoming_IncomingVerificationView_Day_3_de.png +++ b/screenshots/de/features.verifysession.impl.incoming_IncomingVerificationView_Day_3_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:28438f4a4ea6e9e101ecb016a96a6d8acffabe78531d140245aae599f170742d -size 42487 +oid sha256:14c673c38607f967e65af7680d92bf2a2181fa007bb35fe92adc861368bb7542 +size 48256 diff --git a/screenshots/de/features.verifysession.impl.incoming_IncomingVerificationView_Day_4_de.png b/screenshots/de/features.verifysession.impl.incoming_IncomingVerificationView_Day_4_de.png index ec4adfbf255..3a7369516a5 100644 --- a/screenshots/de/features.verifysession.impl.incoming_IncomingVerificationView_Day_4_de.png +++ b/screenshots/de/features.verifysession.impl.incoming_IncomingVerificationView_Day_4_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:89d0fb5b05db746127691b5cb40c88276d140a572122bb943e2dc80a1f8b8eb3 -size 36261 +oid sha256:f135edc8a5c101dc30e7511deda402f12bd20b5e738c4a56a83997311f032746 +size 43311 diff --git a/screenshots/de/features.verifysession.impl.incoming_IncomingVerificationView_Day_7_de.png b/screenshots/de/features.verifysession.impl.incoming_IncomingVerificationView_Day_7_de.png index eb202b9f3e2..d1e82e7ea05 100644 --- a/screenshots/de/features.verifysession.impl.incoming_IncomingVerificationView_Day_7_de.png +++ b/screenshots/de/features.verifysession.impl.incoming_IncomingVerificationView_Day_7_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:95807c82c054157fefd82e05648f5c099613c33d816280280f40a92479009cb2 -size 43937 +oid sha256:ee0b85b15784537aad5f07512654c169161a7523ac5cf00002f07913c3e82b4c +size 52268 diff --git a/screenshots/de/features.verifysession.impl.incoming_IncomingVerificationView_Day_8_de.png b/screenshots/de/features.verifysession.impl.incoming_IncomingVerificationView_Day_8_de.png index 88547115553..327668f4eda 100644 --- a/screenshots/de/features.verifysession.impl.incoming_IncomingVerificationView_Day_8_de.png +++ b/screenshots/de/features.verifysession.impl.incoming_IncomingVerificationView_Day_8_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:fe27d8667d4a0220b4f1cee7b131793dad65469b79f7dc3929c782c9025b9750 -size 45031 +oid sha256:10da3f148a9dd3b2f541c2600c08f90953c572b62f554dce81032f42fd1248c7 +size 53399 diff --git a/screenshots/de/features.verifysession.impl.outgoing_OutgoingVerificationView_Day_10_de.png b/screenshots/de/features.verifysession.impl.outgoing_OutgoingVerificationView_Day_10_de.png index dad036e9857..bda11656cc3 100644 --- a/screenshots/de/features.verifysession.impl.outgoing_OutgoingVerificationView_Day_10_de.png +++ b/screenshots/de/features.verifysession.impl.outgoing_OutgoingVerificationView_Day_10_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a44eb83631c42b8f0ed4a72aa7ccf0720484363b8b9dd45bc8b35908c94f0e0c -size 27220 +oid sha256:d144997c48307010cbe9ab727fe94bbac12958d8d54f2b9adf45f9b03ff3babd +size 27588 diff --git a/screenshots/de/features.verifysession.impl.outgoing_OutgoingVerificationView_Day_11_de.png b/screenshots/de/features.verifysession.impl.outgoing_OutgoingVerificationView_Day_11_de.png index 7efb530cfda..c2615a560e7 100644 --- a/screenshots/de/features.verifysession.impl.outgoing_OutgoingVerificationView_Day_11_de.png +++ b/screenshots/de/features.verifysession.impl.outgoing_OutgoingVerificationView_Day_11_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c035769839244b97f2bab01baa770c8b3cfa9f30eccf7e66187301ed984df318 -size 29546 +oid sha256:a669aae91bcaa235e8f9134cd9f38b2ece4852c19b0e98782745a9585e24ff7e +size 29913 diff --git a/screenshots/de/features.verifysession.impl.outgoing_OutgoingVerificationView_Day_1_de.png b/screenshots/de/features.verifysession.impl.outgoing_OutgoingVerificationView_Day_1_de.png index d991096b7ab..14ccff2fcba 100644 --- a/screenshots/de/features.verifysession.impl.outgoing_OutgoingVerificationView_Day_1_de.png +++ b/screenshots/de/features.verifysession.impl.outgoing_OutgoingVerificationView_Day_1_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6cabad8bafdf955fd6d82d8b14aaa1bea56ea6a73f51a2bd6a82538c527b2fb5 -size 41205 +oid sha256:701803c3724f739d97efc2157f60bf31de162aeefacb452df8e74db8270c1400 +size 42153 diff --git a/screenshots/de/features.verifysession.impl.outgoing_OutgoingVerificationView_Day_2_de.png b/screenshots/de/features.verifysession.impl.outgoing_OutgoingVerificationView_Day_2_de.png index 8a415cf1db6..c0b06633f02 100644 --- a/screenshots/de/features.verifysession.impl.outgoing_OutgoingVerificationView_Day_2_de.png +++ b/screenshots/de/features.verifysession.impl.outgoing_OutgoingVerificationView_Day_2_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:42a7336ec373fa8b8b92d75f13f9328dd3594017dda18096a4af1fddecb44484 -size 25461 +oid sha256:176d023f205cd78de09bccbfc43badfd8fb1efcb575c8d1aa99de7b8378e4649 +size 29726 diff --git a/screenshots/de/features.verifysession.impl.outgoing_OutgoingVerificationView_Day_3_de.png b/screenshots/de/features.verifysession.impl.outgoing_OutgoingVerificationView_Day_3_de.png index 6ce10e25a89..53bdfa5554f 100644 --- a/screenshots/de/features.verifysession.impl.outgoing_OutgoingVerificationView_Day_3_de.png +++ b/screenshots/de/features.verifysession.impl.outgoing_OutgoingVerificationView_Day_3_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2518686a8c0d1b3fc0a345b3d778c2b15cdabf645835a565e2c31ab027571bef -size 21505 +oid sha256:9cabb111b200cc2275e150063c6e4ebc92d32b4877b047fc0658caa178320f1c +size 26786 diff --git a/screenshots/de/features.verifysession.impl.outgoing_OutgoingVerificationView_Day_6_de.png b/screenshots/de/features.verifysession.impl.outgoing_OutgoingVerificationView_Day_6_de.png index eb202b9f3e2..d1e82e7ea05 100644 --- a/screenshots/de/features.verifysession.impl.outgoing_OutgoingVerificationView_Day_6_de.png +++ b/screenshots/de/features.verifysession.impl.outgoing_OutgoingVerificationView_Day_6_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:95807c82c054157fefd82e05648f5c099613c33d816280280f40a92479009cb2 -size 43937 +oid sha256:ee0b85b15784537aad5f07512654c169161a7523ac5cf00002f07913c3e82b4c +size 52268 diff --git a/screenshots/de/libraries.accountselect.impl_AccountSelectView_Day_1_de.png b/screenshots/de/libraries.accountselect.impl_AccountSelectView_Day_1_de.png index efd1b648c21..79b48913889 100644 --- a/screenshots/de/libraries.accountselect.impl_AccountSelectView_Day_1_de.png +++ b/screenshots/de/libraries.accountselect.impl_AccountSelectView_Day_1_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d9fa23fe8db80a547f5955eee300a43d64d277f17fae32dd002fca1141100318 -size 49053 +oid sha256:1a107feebf22d62e559f7ea31951c6924f8991244efa2a032961a2808c432bf6 +size 43697 diff --git a/screenshots/de/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Day_3_de.png b/screenshots/de/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Day_3_de.png index 18f43f132d1..0414a20c94f 100644 --- a/screenshots/de/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Day_3_de.png +++ b/screenshots/de/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Day_3_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4e8dab068d8f07d8f9bedebc51b69c91d4d996c160791ccad75bd981b9d75fc7 -size 17112 +oid sha256:1b869af5e094555bae21115ddd8e637c463bf8d0e1f1ac9a88d0e84a7c0c191b +size 17324 diff --git a/screenshots/de/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Day_4_de.png b/screenshots/de/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Day_4_de.png index e69faa0ce96..10531b43bfa 100644 --- a/screenshots/de/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Day_4_de.png +++ b/screenshots/de/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Day_4_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:64faaae8773e35e52143f14d7bdf546b0ea1a3cb5a4962bda66bafb76b794468 -size 19122 +oid sha256:cc23094f3d51c108e64490f9ecc1f10c3ba8cb86f3273cd990181d21133dce4e +size 19320 diff --git a/screenshots/de/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Day_5_de.png b/screenshots/de/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Day_5_de.png index c44f6f4b4fc..64a879ff746 100644 --- a/screenshots/de/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Day_5_de.png +++ b/screenshots/de/libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Day_5_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:81d1ef176e9b62379ffdeac6e98c96d3fe118f920260e6deacd32437ee6e5348 -size 19088 +oid sha256:8a4dc61d7b3fc791fe6362cabcbb67a15d85669d778c1b186c73fcd470df3b21 +size 19295 diff --git a/screenshots/de/libraries.designsystem.components.dialogs_SaveChangesDialog_Day_0_de.png b/screenshots/de/libraries.designsystem.components.dialogs_SaveChangesDialog_Day_0_de.png index b8b796ace4c..cd461a5e126 100644 --- a/screenshots/de/libraries.designsystem.components.dialogs_SaveChangesDialog_Day_0_de.png +++ b/screenshots/de/libraries.designsystem.components.dialogs_SaveChangesDialog_Day_0_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a3632264ac1f406dfdd2d376c033741d2661ebf76b3b8f0ee150e108d6e85740 -size 27940 +oid sha256:0406efd19b2e8c9d83708af8669b8d2e8aaafef335eaef972e363e85589ccc56 +size 22171 diff --git a/screenshots/de/libraries.designsystem.theme.components.previews_TimePickerHorizontal_DateTime_pickers_de.png b/screenshots/de/libraries.designsystem.theme.components.previews_TimePickerHorizontal_DateTime_pickers_de.png index d72b78ab4cd..056bccc5b5a 100644 --- a/screenshots/de/libraries.designsystem.theme.components.previews_TimePickerHorizontal_DateTime_pickers_de.png +++ b/screenshots/de/libraries.designsystem.theme.components.previews_TimePickerHorizontal_DateTime_pickers_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:853edd40133d53f4109934cadfd7efd2cb6c6aeab6ad345b29a9008e902a9904 -size 36437 +oid sha256:ad5c8bbac1c770be5924fa44d79fb728b54593a6091b5a3fc78a69936668eab7 +size 34121 diff --git a/screenshots/de/libraries.designsystem.theme.components_SearchBarActiveWithNoResults_Search_views_de.png b/screenshots/de/libraries.designsystem.theme.components_SearchBarActiveWithNoResults_Search_views_de.png index 5be94f8d2dc..026aa22f793 100644 --- a/screenshots/de/libraries.designsystem.theme.components_SearchBarActiveWithNoResults_Search_views_de.png +++ b/screenshots/de/libraries.designsystem.theme.components_SearchBarActiveWithNoResults_Search_views_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5117f9e69029fc92ac52e9db641a4faf1b285dfd21bd607b5001a83a10387848 -size 10359 +oid sha256:f846e8780ecc4d76c5623f67b1fb2637daa0dd3844464aa59ed122f553d6508f +size 17214 diff --git a/screenshots/de/libraries.matrix.ui.components_CheckableUnresolvedUserRow_de.png b/screenshots/de/libraries.matrix.ui.components_CheckableUnresolvedUserRow_de.png index 9795c4e10c8..262a2e6c45d 100644 --- a/screenshots/de/libraries.matrix.ui.components_CheckableUnresolvedUserRow_de.png +++ b/screenshots/de/libraries.matrix.ui.components_CheckableUnresolvedUserRow_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c4fd702f03c1886684a2ded6deb907f4f64c2d8c749eb8a86c4f6f697625a616 -size 127507 +oid sha256:d4450043c6ecfee5bdbd017027e5b20372b35f01ac3821d5a73cfbf807799424 +size 116244 diff --git a/screenshots/de/libraries.matrix.ui.components_UnresolvedUserRow_de.png b/screenshots/de/libraries.matrix.ui.components_UnresolvedUserRow_de.png index 195fc6212a9..f6d4ee15165 100644 --- a/screenshots/de/libraries.matrix.ui.components_UnresolvedUserRow_de.png +++ b/screenshots/de/libraries.matrix.ui.components_UnresolvedUserRow_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e49d17ed624b012da26430405c715f6be034e2c73c1202ab979002443764f1c7 -size 73774 +oid sha256:a2bde77f49fd90f85c376daaf41346838c2d3e91a8cf52d734c61c2f419aa369 +size 74298 diff --git a/screenshots/de/libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_7_de.png b/screenshots/de/libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_7_de.png index d286963ecec..8f600a63b13 100644 --- a/screenshots/de/libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_7_de.png +++ b/screenshots/de/libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_7_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:06e84aad3494762d9f82db6f89a1c82b29e9fac055ded4f92fe109a553eb7016 -size 33335 +oid sha256:ab26718ed14a6ed33a82d5b8aaf7311455d8fa6e9a061ba5af833299f6f54f5a +size 34125 diff --git a/screenshots/de/libraries.roomselect.impl_RoomSelectView_Day_0_de.png b/screenshots/de/libraries.roomselect.impl_RoomSelectView_Day_0_de.png index a685eda44fc..6bfc8717fd2 100644 --- a/screenshots/de/libraries.roomselect.impl_RoomSelectView_Day_0_de.png +++ b/screenshots/de/libraries.roomselect.impl_RoomSelectView_Day_0_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3d93ffd5275a31b3f10d3118fd969b444dca509b99a2d38ec505b3f40d5dca41 -size 14069 +oid sha256:a0326603669450bb553227feb450a83c2c4f8a83a0b45fc00a0439b5ae6cee40 +size 14087 diff --git a/screenshots/de/libraries.roomselect.impl_RoomSelectView_Day_1_de.png b/screenshots/de/libraries.roomselect.impl_RoomSelectView_Day_1_de.png index b5f861d09ba..b62ad00757f 100644 --- a/screenshots/de/libraries.roomselect.impl_RoomSelectView_Day_1_de.png +++ b/screenshots/de/libraries.roomselect.impl_RoomSelectView_Day_1_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:3bd016c197832e8c4bafc03f6e01298ac9a4f28a7ffd56c072d08dcd1abc7a06 -size 12049 +oid sha256:4e1d912da6419dc1baec30171634ac9764862390d714191b71c2b94d896fa10a +size 12058 diff --git a/screenshots/de/libraries.roomselect.impl_RoomSelectView_Day_2_de.png b/screenshots/de/libraries.roomselect.impl_RoomSelectView_Day_2_de.png index 837a336f660..5ce059f9312 100644 --- a/screenshots/de/libraries.roomselect.impl_RoomSelectView_Day_2_de.png +++ b/screenshots/de/libraries.roomselect.impl_RoomSelectView_Day_2_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:eb3260ccf57e31534397b97c896200fa222c3ce1c41dbeb34ecee18deac26ae3 -size 32571 +oid sha256:53f60adb0f0abafad2a18536e13a64b6a9115f78c51de81fcf87521e14de79e9 +size 32590 diff --git a/screenshots/de/libraries.roomselect.impl_RoomSelectView_Day_3_de.png b/screenshots/de/libraries.roomselect.impl_RoomSelectView_Day_3_de.png index 98ed051cafb..4354ba502ca 100644 --- a/screenshots/de/libraries.roomselect.impl_RoomSelectView_Day_3_de.png +++ b/screenshots/de/libraries.roomselect.impl_RoomSelectView_Day_3_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:a8ae61dde38c637924bd95c73408856d4cdfb61728128983354ea566b5a02b6f -size 30936 +oid sha256:5171547f2ff09da353a6a2fb52a734930c165cdcf22b1dca58b89e47d2c3626d +size 30933 diff --git a/screenshots/de/libraries.roomselect.impl_RoomSelectView_Day_4_de.png b/screenshots/de/libraries.roomselect.impl_RoomSelectView_Day_4_de.png index e6ffefefbb8..5704892c4ab 100644 --- a/screenshots/de/libraries.roomselect.impl_RoomSelectView_Day_4_de.png +++ b/screenshots/de/libraries.roomselect.impl_RoomSelectView_Day_4_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:417ae6a02eb2ac758bd42f287c9ac9b35565afda2fa62783f03aeaca56990da5 +oid sha256:ad039c7af5726a980a586bd483c4aa938dde1cc2f116882ad7b6ca343495805d size 35196 diff --git a/screenshots/de/libraries.roomselect.impl_RoomSelectView_Day_5_de.png b/screenshots/de/libraries.roomselect.impl_RoomSelectView_Day_5_de.png index 40ac87be2eb..30c7d47edea 100644 --- a/screenshots/de/libraries.roomselect.impl_RoomSelectView_Day_5_de.png +++ b/screenshots/de/libraries.roomselect.impl_RoomSelectView_Day_5_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:2a40da17d87b7b39c4064cd148327723efda66bea208bd683efeb477616642c4 -size 30235 +oid sha256:180014cd36d6721b426d212581f9cceb3ba55f6e3e11fd5357d9311f786526c0 +size 30257 diff --git a/screenshots/de/libraries.textcomposer_TextComposerEditNotEncrypted_Day_0_de.png b/screenshots/de/libraries.textcomposer_TextComposerEditNotEncrypted_Day_0_de.png index dc24bcec5c2..6e1dd6cf39c 100644 --- a/screenshots/de/libraries.textcomposer_TextComposerEditNotEncrypted_Day_0_de.png +++ b/screenshots/de/libraries.textcomposer_TextComposerEditNotEncrypted_Day_0_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:596cc2da5cb85d9d740fbb9a2769adcc6a2eedb2940b1347d00fceda487402a5 -size 67976 +oid sha256:525aee31e2c09c8f7134e174de4e46f1913a5b4f3a88a4f2fba50dc615fdffc5 +size 68035 diff --git a/screenshots/de/libraries.textcomposer_TextComposerFormattingNotEncrypted_Day_0_de.png b/screenshots/de/libraries.textcomposer_TextComposerFormattingNotEncrypted_Day_0_de.png index ea48d1f02e7..6dba6f27920 100644 --- a/screenshots/de/libraries.textcomposer_TextComposerFormattingNotEncrypted_Day_0_de.png +++ b/screenshots/de/libraries.textcomposer_TextComposerFormattingNotEncrypted_Day_0_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c7425263614937932efe6435296f48ee4a8fb81c4829289b1993913d4bea292f -size 66098 +oid sha256:53c40e5833c999c8e3d49bde6f565d32a4381a40e7c0bcba845d16363c6f3faf +size 66163 diff --git a/screenshots/de/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_0_de.png b/screenshots/de/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_0_de.png index c30828e474b..e56631e2496 100644 --- a/screenshots/de/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_0_de.png +++ b/screenshots/de/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_0_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0bcca4a976c1fef41252b91bbe17301b1169c397833f7fda954f30c46d9ebefe -size 75296 +oid sha256:68741be741a63204b1ea6099ae2833b0aeb5defc1367d97bada887d5b96a6531 +size 75368 diff --git a/screenshots/de/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_10_de.png b/screenshots/de/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_10_de.png index 5a1dae13dda..e92eaf25977 100644 --- a/screenshots/de/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_10_de.png +++ b/screenshots/de/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_10_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:83bde3a14b7a8345678282f86a4a16f4af925daaab4b7f9393122a81c8fd4509 -size 61884 +oid sha256:8fabc114c60514f7eb512c8992a82bf1c946592c6e8187c87215cdfe7fe21a5f +size 61956 diff --git a/screenshots/de/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_11_de.png b/screenshots/de/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_11_de.png index a3f6ea71ebb..c07587a3be0 100644 --- a/screenshots/de/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_11_de.png +++ b/screenshots/de/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_11_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:78335f34f4ec3eed79ecb4fc5f74a6ea52ca758913db1f1253c6d67df13cc1bd -size 74715 +oid sha256:f3703422fb0a8c6fba5116f715743cfba7b4eb94139563e25d796ff51c6cbee7 +size 74797 diff --git a/screenshots/de/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_1_de.png b/screenshots/de/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_1_de.png index 2556acee456..aebf3f050c0 100644 --- a/screenshots/de/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_1_de.png +++ b/screenshots/de/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_1_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:fe025380c38ab0974930b7e91e3a0a4f02e49c8422b3f1119ad3a64d6aa1e770 -size 83357 +oid sha256:41d290c3153b85b1d97f2dbb087041c1dc57db31bd8904d3919dcf0b178b2286 +size 83439 diff --git a/screenshots/de/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_2_de.png b/screenshots/de/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_2_de.png index 8ee7cb1c67c..1b83986771d 100644 --- a/screenshots/de/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_2_de.png +++ b/screenshots/de/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_2_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e21a3a654f0b20a7fd6a53b8dafb83a060216fe5bb0e60a6a929cec91091500c -size 64430 +oid sha256:4cccf5887c1fe53f317e6d1823e747e651d4b615e73f81a68e730ba1d3aacc6f +size 64490 diff --git a/screenshots/de/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_3_de.png b/screenshots/de/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_3_de.png index f5352e8ba3a..f7a5b24bcc7 100644 --- a/screenshots/de/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_3_de.png +++ b/screenshots/de/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_3_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5e6c5e2f1cde6c5aaac386b1ed7f478192bb15811f082eb7b564375db1c45ca5 -size 63315 +oid sha256:0296e105d089b5afb565bbac72f7a3cdd92c0381ab867494114f2ec8067af18c +size 63387 diff --git a/screenshots/de/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_4_de.png b/screenshots/de/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_4_de.png index b68fe1da243..1864d911325 100644 --- a/screenshots/de/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_4_de.png +++ b/screenshots/de/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_4_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:aedd7a97c5f98857d674da7c42f7439e0af71ccb59f0a900161ae5b7497e9723 -size 68971 +oid sha256:e97005de9f3b1ae2873888c3258aa39c92fefa98194c55ed9d6abc7c8850af27 +size 69035 diff --git a/screenshots/de/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_5_de.png b/screenshots/de/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_5_de.png index 3ba3abf9f0e..b5a3abc87e1 100644 --- a/screenshots/de/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_5_de.png +++ b/screenshots/de/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_5_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:29aeb44961435a5a2d1000ff1e44ed698c4c2358e0d8eaf88f762e8004693942 -size 92241 +oid sha256:826550edf105257ff91ff2c9b798a128da4172125fc2d157bba6eaeb3f3cc60a +size 92294 diff --git a/screenshots/de/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_6_de.png b/screenshots/de/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_6_de.png index 1b640b003b4..12a40cd0d95 100644 --- a/screenshots/de/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_6_de.png +++ b/screenshots/de/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_6_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:163945bdc729e6355e0ac6ebf58fea897195053d79197fba64ec0601045be868 -size 62760 +oid sha256:5d859305f9f78b7c969d2ea8a3f49fe00015d1dc3c49b24dcd3d228705688d4a +size 62833 diff --git a/screenshots/de/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_7_de.png b/screenshots/de/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_7_de.png index 9a9414770c4..95325575c3a 100644 --- a/screenshots/de/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_7_de.png +++ b/screenshots/de/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_7_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:c0b44b23c3f31cae6bf229f992baa529eee15e087989230a355d0f29a429800b -size 63900 +oid sha256:4cab4d95d4466412843b716e27d43507c06a777aba77c9b3df3350a268c7597d +size 63975 diff --git a/screenshots/de/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_8_de.png b/screenshots/de/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_8_de.png index 7adecea8ed5..b42dec9211e 100644 --- a/screenshots/de/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_8_de.png +++ b/screenshots/de/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_8_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:7bcbb464943d25cda77e49f4c76bb01e587a4823080757f9718dafdb86137d2d -size 71591 +oid sha256:b33f245c717a8c8865aafe9aa3a09ff1201a8532db5327c536410b11e4479f3c +size 71660 diff --git a/screenshots/de/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_9_de.png b/screenshots/de/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_9_de.png index 2d9f044871c..da763a7902a 100644 --- a/screenshots/de/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_9_de.png +++ b/screenshots/de/libraries.textcomposer_TextComposerReplyNotEncrypted_Day_9_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:243cbf861b217e6314cedc853eefdeadd231ea674d22d683e6bc43e50de1a213 -size 62292 +oid sha256:6a9589f57428065452bcfa119b12d3d5ac37376771ab4c47378b458b470785a2 +size 62360 diff --git a/screenshots/de/libraries.textcomposer_TextComposerSimpleNotEncrypted_Day_0_de.png b/screenshots/de/libraries.textcomposer_TextComposerSimpleNotEncrypted_Day_0_de.png index d50c99a4f4d..da1d3f39eb1 100644 --- a/screenshots/de/libraries.textcomposer_TextComposerSimpleNotEncrypted_Day_0_de.png +++ b/screenshots/de/libraries.textcomposer_TextComposerSimpleNotEncrypted_Day_0_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:9b79bfd727b627baed2d6b99ae6d5711d4b4dcb0f74f937eb41727ede4317c79 -size 58334 +oid sha256:fc72269a07c163bc490a590d73dabb1d698e28298a69df89a058d20c8051d89f +size 58391 diff --git a/screenshots/de/libraries.textcomposer_TextComposerVoiceNotEncrypted_Day_0_de.png b/screenshots/de/libraries.textcomposer_TextComposerVoiceNotEncrypted_Day_0_de.png index 642d11e4030..1244f3032df 100644 --- a/screenshots/de/libraries.textcomposer_TextComposerVoiceNotEncrypted_Day_0_de.png +++ b/screenshots/de/libraries.textcomposer_TextComposerVoiceNotEncrypted_Day_0_de.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d6b02463827dea85c10b2766293fe11d25612a677386eb9cf056333313c89057 -size 38097 +oid sha256:39293a575ddbd303a4b184ce6db806423cf94073e52e5d879acf86a6e4ea99d0 +size 38154 diff --git a/screenshots/html/data.js b/screenshots/html/data.js index 758b77c7ee3..3d2e829373b 100644 --- a/screenshots/html/data.js +++ b/screenshots/html/data.js @@ -1,80 +1,80 @@ // Generated file, do not edit export const screenshots = [ ["en","en-dark","de",], -["features.preferences.impl.about_AboutView_Day_0_en","features.preferences.impl.about_AboutView_Night_0_en",20420,], +["features.preferences.impl.about_AboutView_Day_0_en","features.preferences.impl.about_AboutView_Night_0_en",20466,], ["features.invite.impl.acceptdecline_AcceptDeclineInviteView_Day_0_en","features.invite.impl.acceptdecline_AcceptDeclineInviteView_Night_0_en",0,], -["features.invite.impl.acceptdecline_AcceptDeclineInviteView_Day_1_en","features.invite.impl.acceptdecline_AcceptDeclineInviteView_Night_1_en",20420,], -["features.invite.impl.acceptdecline_AcceptDeclineInviteView_Day_2_en","features.invite.impl.acceptdecline_AcceptDeclineInviteView_Night_2_en",20420,], -["features.invite.impl.acceptdecline_AcceptDeclineInviteView_Day_3_en","features.invite.impl.acceptdecline_AcceptDeclineInviteView_Night_3_en",20420,], -["features.invite.impl.acceptdecline_AcceptDeclineInviteView_Day_4_en","features.invite.impl.acceptdecline_AcceptDeclineInviteView_Night_4_en",20420,], -["features.invite.impl.acceptdecline_AcceptDeclineInviteView_Day_5_en","features.invite.impl.acceptdecline_AcceptDeclineInviteView_Night_5_en",20420,], -["features.logout.impl_AccountDeactivationView_Day_0_en","features.logout.impl_AccountDeactivationView_Night_0_en",20420,], -["features.logout.impl_AccountDeactivationView_Day_1_en","features.logout.impl_AccountDeactivationView_Night_1_en",20420,], -["features.logout.impl_AccountDeactivationView_Day_2_en","features.logout.impl_AccountDeactivationView_Night_2_en",20420,], -["features.logout.impl_AccountDeactivationView_Day_3_en","features.logout.impl_AccountDeactivationView_Night_3_en",20420,], -["features.logout.impl_AccountDeactivationView_Day_4_en","features.logout.impl_AccountDeactivationView_Night_4_en",20420,], -["features.login.impl.accountprovider_AccountProviderOtherView_Day_0_en","features.login.impl.accountprovider_AccountProviderOtherView_Night_0_en",20420,], +["features.invite.impl.acceptdecline_AcceptDeclineInviteView_Day_1_en","features.invite.impl.acceptdecline_AcceptDeclineInviteView_Night_1_en",20466,], +["features.invite.impl.acceptdecline_AcceptDeclineInviteView_Day_2_en","features.invite.impl.acceptdecline_AcceptDeclineInviteView_Night_2_en",20466,], +["features.invite.impl.acceptdecline_AcceptDeclineInviteView_Day_3_en","features.invite.impl.acceptdecline_AcceptDeclineInviteView_Night_3_en",20466,], +["features.invite.impl.acceptdecline_AcceptDeclineInviteView_Day_4_en","features.invite.impl.acceptdecline_AcceptDeclineInviteView_Night_4_en",20466,], +["features.invite.impl.acceptdecline_AcceptDeclineInviteView_Day_5_en","features.invite.impl.acceptdecline_AcceptDeclineInviteView_Night_5_en",20466,], +["features.logout.impl_AccountDeactivationView_Day_0_en","features.logout.impl_AccountDeactivationView_Night_0_en",20466,], +["features.logout.impl_AccountDeactivationView_Day_1_en","features.logout.impl_AccountDeactivationView_Night_1_en",20466,], +["features.logout.impl_AccountDeactivationView_Day_2_en","features.logout.impl_AccountDeactivationView_Night_2_en",20466,], +["features.logout.impl_AccountDeactivationView_Day_3_en","features.logout.impl_AccountDeactivationView_Night_3_en",20466,], +["features.logout.impl_AccountDeactivationView_Day_4_en","features.logout.impl_AccountDeactivationView_Night_4_en",20466,], +["features.login.impl.accountprovider_AccountProviderOtherView_Day_0_en","features.login.impl.accountprovider_AccountProviderOtherView_Night_0_en",20466,], ["features.login.impl.accountprovider_AccountProviderView_Day_0_en","features.login.impl.accountprovider_AccountProviderView_Night_0_en",0,], ["features.login.impl.accountprovider_AccountProviderView_Day_1_en","features.login.impl.accountprovider_AccountProviderView_Night_1_en",0,], ["features.login.impl.accountprovider_AccountProviderView_Day_2_en","features.login.impl.accountprovider_AccountProviderView_Night_2_en",0,], ["features.login.impl.accountprovider_AccountProviderView_Day_3_en","features.login.impl.accountprovider_AccountProviderView_Night_3_en",0,], -["libraries.accountselect.impl_AccountSelectView_Day_0_en","libraries.accountselect.impl_AccountSelectView_Night_0_en",20420,], -["libraries.accountselect.impl_AccountSelectView_Day_1_en","libraries.accountselect.impl_AccountSelectView_Night_1_en",20420,], +["libraries.accountselect.impl_AccountSelectView_Day_0_en","libraries.accountselect.impl_AccountSelectView_Night_0_en",20466,], +["libraries.accountselect.impl_AccountSelectView_Day_1_en","libraries.accountselect.impl_AccountSelectView_Night_1_en",20466,], ["features.messages.impl.actionlist_ActionListViewContent_Day_0_en","features.messages.impl.actionlist_ActionListViewContent_Night_0_en",0,], -["features.messages.impl.actionlist_ActionListViewContent_Day_10_en","features.messages.impl.actionlist_ActionListViewContent_Night_10_en",20420,], -["features.messages.impl.actionlist_ActionListViewContent_Day_11_en","features.messages.impl.actionlist_ActionListViewContent_Night_11_en",20420,], -["features.messages.impl.actionlist_ActionListViewContent_Day_12_en","features.messages.impl.actionlist_ActionListViewContent_Night_12_en",20420,], +["features.messages.impl.actionlist_ActionListViewContent_Day_10_en","features.messages.impl.actionlist_ActionListViewContent_Night_10_en",20466,], +["features.messages.impl.actionlist_ActionListViewContent_Day_11_en","features.messages.impl.actionlist_ActionListViewContent_Night_11_en",20466,], +["features.messages.impl.actionlist_ActionListViewContent_Day_12_en","features.messages.impl.actionlist_ActionListViewContent_Night_12_en",20466,], ["features.messages.impl.actionlist_ActionListViewContent_Day_1_en","features.messages.impl.actionlist_ActionListViewContent_Night_1_en",0,], -["features.messages.impl.actionlist_ActionListViewContent_Day_2_en","features.messages.impl.actionlist_ActionListViewContent_Night_2_en",20420,], -["features.messages.impl.actionlist_ActionListViewContent_Day_3_en","features.messages.impl.actionlist_ActionListViewContent_Night_3_en",20420,], -["features.messages.impl.actionlist_ActionListViewContent_Day_4_en","features.messages.impl.actionlist_ActionListViewContent_Night_4_en",20420,], -["features.messages.impl.actionlist_ActionListViewContent_Day_5_en","features.messages.impl.actionlist_ActionListViewContent_Night_5_en",20420,], -["features.messages.impl.actionlist_ActionListViewContent_Day_6_en","features.messages.impl.actionlist_ActionListViewContent_Night_6_en",20420,], -["features.messages.impl.actionlist_ActionListViewContent_Day_7_en","features.messages.impl.actionlist_ActionListViewContent_Night_7_en",20420,], -["features.messages.impl.actionlist_ActionListViewContent_Day_8_en","features.messages.impl.actionlist_ActionListViewContent_Night_8_en",20420,], -["features.messages.impl.actionlist_ActionListViewContent_Day_9_en","features.messages.impl.actionlist_ActionListViewContent_Night_9_en",20420,], -["features.createroom.impl.addpeople_AddPeopleView_Day_0_en","features.createroom.impl.addpeople_AddPeopleView_Night_0_en",20420,], -["features.createroom.impl.addpeople_AddPeopleView_Day_1_en","features.createroom.impl.addpeople_AddPeopleView_Night_1_en",20420,], -["features.createroom.impl.addpeople_AddPeopleView_Day_2_en","features.createroom.impl.addpeople_AddPeopleView_Night_2_en",20420,], -["features.createroom.impl.addpeople_AddPeopleView_Day_3_en","features.createroom.impl.addpeople_AddPeopleView_Night_3_en",20420,], -["features.preferences.impl.advanced_AdvancedSettingsViewDark_0_en","",20420,], -["features.preferences.impl.advanced_AdvancedSettingsViewDark_1_en","",20420,], -["features.preferences.impl.advanced_AdvancedSettingsViewDark_2_en","",20420,], -["features.preferences.impl.advanced_AdvancedSettingsViewDark_3_en","",20420,], -["features.preferences.impl.advanced_AdvancedSettingsViewDark_4_en","",20420,], -["features.preferences.impl.advanced_AdvancedSettingsViewDark_5_en","",20420,], -["features.preferences.impl.advanced_AdvancedSettingsViewDark_6_en","",20420,], -["features.preferences.impl.advanced_AdvancedSettingsViewDark_7_en","",20420,], -["features.preferences.impl.advanced_AdvancedSettingsViewDark_8_en","",20420,], -["features.preferences.impl.advanced_AdvancedSettingsViewLight_0_en","",20420,], -["features.preferences.impl.advanced_AdvancedSettingsViewLight_1_en","",20420,], -["features.preferences.impl.advanced_AdvancedSettingsViewLight_2_en","",20420,], -["features.preferences.impl.advanced_AdvancedSettingsViewLight_3_en","",20420,], -["features.preferences.impl.advanced_AdvancedSettingsViewLight_4_en","",20420,], -["features.preferences.impl.advanced_AdvancedSettingsViewLight_5_en","",20420,], -["features.preferences.impl.advanced_AdvancedSettingsViewLight_6_en","",20420,], -["features.preferences.impl.advanced_AdvancedSettingsViewLight_7_en","",20420,], -["features.preferences.impl.advanced_AdvancedSettingsViewLight_8_en","",20420,], -["libraries.designsystem.components.dialogs_AlertDialogContent_Dialogs_en","",20420,], -["libraries.designsystem.components.dialogs_AlertDialog_Day_0_en","libraries.designsystem.components.dialogs_AlertDialog_Night_0_en",20420,], +["features.messages.impl.actionlist_ActionListViewContent_Day_2_en","features.messages.impl.actionlist_ActionListViewContent_Night_2_en",20466,], +["features.messages.impl.actionlist_ActionListViewContent_Day_3_en","features.messages.impl.actionlist_ActionListViewContent_Night_3_en",20466,], +["features.messages.impl.actionlist_ActionListViewContent_Day_4_en","features.messages.impl.actionlist_ActionListViewContent_Night_4_en",20466,], +["features.messages.impl.actionlist_ActionListViewContent_Day_5_en","features.messages.impl.actionlist_ActionListViewContent_Night_5_en",20466,], +["features.messages.impl.actionlist_ActionListViewContent_Day_6_en","features.messages.impl.actionlist_ActionListViewContent_Night_6_en",20466,], +["features.messages.impl.actionlist_ActionListViewContent_Day_7_en","features.messages.impl.actionlist_ActionListViewContent_Night_7_en",20466,], +["features.messages.impl.actionlist_ActionListViewContent_Day_8_en","features.messages.impl.actionlist_ActionListViewContent_Night_8_en",20466,], +["features.messages.impl.actionlist_ActionListViewContent_Day_9_en","features.messages.impl.actionlist_ActionListViewContent_Night_9_en",20466,], +["features.createroom.impl.addpeople_AddPeopleView_Day_0_en","features.createroom.impl.addpeople_AddPeopleView_Night_0_en",20466,], +["features.createroom.impl.addpeople_AddPeopleView_Day_1_en","features.createroom.impl.addpeople_AddPeopleView_Night_1_en",20466,], +["features.createroom.impl.addpeople_AddPeopleView_Day_2_en","features.createroom.impl.addpeople_AddPeopleView_Night_2_en",20466,], +["features.createroom.impl.addpeople_AddPeopleView_Day_3_en","features.createroom.impl.addpeople_AddPeopleView_Night_3_en",20466,], +["features.preferences.impl.advanced_AdvancedSettingsViewDark_0_en","",20466,], +["features.preferences.impl.advanced_AdvancedSettingsViewDark_1_en","",20466,], +["features.preferences.impl.advanced_AdvancedSettingsViewDark_2_en","",20466,], +["features.preferences.impl.advanced_AdvancedSettingsViewDark_3_en","",20466,], +["features.preferences.impl.advanced_AdvancedSettingsViewDark_4_en","",20466,], +["features.preferences.impl.advanced_AdvancedSettingsViewDark_5_en","",20466,], +["features.preferences.impl.advanced_AdvancedSettingsViewDark_6_en","",20466,], +["features.preferences.impl.advanced_AdvancedSettingsViewDark_7_en","",20466,], +["features.preferences.impl.advanced_AdvancedSettingsViewDark_8_en","",20466,], +["features.preferences.impl.advanced_AdvancedSettingsViewLight_0_en","",20466,], +["features.preferences.impl.advanced_AdvancedSettingsViewLight_1_en","",20466,], +["features.preferences.impl.advanced_AdvancedSettingsViewLight_2_en","",20466,], +["features.preferences.impl.advanced_AdvancedSettingsViewLight_3_en","",20466,], +["features.preferences.impl.advanced_AdvancedSettingsViewLight_4_en","",20466,], +["features.preferences.impl.advanced_AdvancedSettingsViewLight_5_en","",20466,], +["features.preferences.impl.advanced_AdvancedSettingsViewLight_6_en","",20466,], +["features.preferences.impl.advanced_AdvancedSettingsViewLight_7_en","",20466,], +["features.preferences.impl.advanced_AdvancedSettingsViewLight_8_en","",20466,], +["libraries.designsystem.components.dialogs_AlertDialogContent_Dialogs_en","",20466,], +["libraries.designsystem.components.dialogs_AlertDialog_Day_0_en","libraries.designsystem.components.dialogs_AlertDialog_Night_0_en",20466,], ["libraries.designsystem.theme.components_AllIcons_Icons_en","",0,], -["features.analytics.impl_AnalyticsOptInView_Day_0_en","features.analytics.impl_AnalyticsOptInView_Night_0_en",20420,], -["features.analytics.impl_AnalyticsOptInView_Day_1_en","features.analytics.impl_AnalyticsOptInView_Night_1_en",20420,], -["features.analytics.api.preferences_AnalyticsPreferencesView_Day_0_en","features.analytics.api.preferences_AnalyticsPreferencesView_Night_0_en",20420,], -["features.analytics.api.preferences_AnalyticsPreferencesView_Day_1_en","features.analytics.api.preferences_AnalyticsPreferencesView_Night_1_en",20420,], -["features.preferences.impl.analytics_AnalyticsSettingsView_Day_0_en","features.preferences.impl.analytics_AnalyticsSettingsView_Night_0_en",20420,], +["features.analytics.impl_AnalyticsOptInView_Day_0_en","features.analytics.impl_AnalyticsOptInView_Night_0_en",20466,], +["features.analytics.impl_AnalyticsOptInView_Day_1_en","features.analytics.impl_AnalyticsOptInView_Night_1_en",20466,], +["features.analytics.api.preferences_AnalyticsPreferencesView_Day_0_en","features.analytics.api.preferences_AnalyticsPreferencesView_Night_0_en",20466,], +["features.analytics.api.preferences_AnalyticsPreferencesView_Day_1_en","features.analytics.api.preferences_AnalyticsPreferencesView_Night_1_en",20466,], +["features.preferences.impl.analytics_AnalyticsSettingsView_Day_0_en","features.preferences.impl.analytics_AnalyticsSettingsView_Night_0_en",20466,], ["libraries.designsystem.components_Announcement_Day_0_en","libraries.designsystem.components_Announcement_Night_0_en",0,], -["services.apperror.impl_AppErrorView_Day_0_en","services.apperror.impl_AppErrorView_Night_0_en",20420,], +["services.apperror.impl_AppErrorView_Day_0_en","services.apperror.impl_AppErrorView_Night_0_en",20466,], ["libraries.designsystem.components.async_AsyncActionView_Day_0_en","libraries.designsystem.components.async_AsyncActionView_Night_0_en",0,], -["libraries.designsystem.components.async_AsyncActionView_Day_1_en","libraries.designsystem.components.async_AsyncActionView_Night_1_en",20420,], +["libraries.designsystem.components.async_AsyncActionView_Day_1_en","libraries.designsystem.components.async_AsyncActionView_Night_1_en",20466,], ["libraries.designsystem.components.async_AsyncActionView_Day_2_en","libraries.designsystem.components.async_AsyncActionView_Night_2_en",0,], -["libraries.designsystem.components.async_AsyncActionView_Day_3_en","libraries.designsystem.components.async_AsyncActionView_Night_3_en",20420,], +["libraries.designsystem.components.async_AsyncActionView_Day_3_en","libraries.designsystem.components.async_AsyncActionView_Night_3_en",20466,], ["libraries.designsystem.components.async_AsyncActionView_Day_4_en","libraries.designsystem.components.async_AsyncActionView_Night_4_en",0,], -["libraries.designsystem.components.async_AsyncFailure_Day_0_en","libraries.designsystem.components.async_AsyncFailure_Night_0_en",20420,], +["libraries.designsystem.components.async_AsyncFailure_Day_0_en","libraries.designsystem.components.async_AsyncFailure_Night_0_en",20466,], ["libraries.designsystem.components.async_AsyncIndicatorFailure_Day_0_en","libraries.designsystem.components.async_AsyncIndicatorFailure_Night_0_en",0,], ["libraries.designsystem.components.async_AsyncIndicatorLoading_Day_0_en","libraries.designsystem.components.async_AsyncIndicatorLoading_Night_0_en",0,], ["libraries.designsystem.components.async_AsyncLoading_Day_0_en","libraries.designsystem.components.async_AsyncLoading_Night_0_en",0,], -["features.messages.impl.messagecomposer_AttachmentSourcePickerMenu_Day_0_en","features.messages.impl.messagecomposer_AttachmentSourcePickerMenu_Night_0_en",20420,], +["features.messages.impl.messagecomposer_AttachmentSourcePickerMenu_Day_0_en","features.messages.impl.messagecomposer_AttachmentSourcePickerMenu_Night_0_en",20466,], ["libraries.matrix.ui.components_AttachmentThumbnail_Day_0_en","libraries.matrix.ui.components_AttachmentThumbnail_Night_0_en",0,], ["libraries.matrix.ui.components_AttachmentThumbnail_Day_1_en","libraries.matrix.ui.components_AttachmentThumbnail_Night_1_en",0,], ["libraries.matrix.ui.components_AttachmentThumbnail_Day_2_en","libraries.matrix.ui.components_AttachmentThumbnail_Night_2_en",0,], @@ -84,20 +84,23 @@ export const screenshots = [ ["libraries.matrix.ui.components_AttachmentThumbnail_Day_6_en","libraries.matrix.ui.components_AttachmentThumbnail_Night_6_en",0,], ["libraries.matrix.ui.components_AttachmentThumbnail_Day_7_en","libraries.matrix.ui.components_AttachmentThumbnail_Night_7_en",0,], ["libraries.matrix.ui.components_AttachmentThumbnail_Day_8_en","libraries.matrix.ui.components_AttachmentThumbnail_Night_8_en",0,], -["features.messages.impl.attachments.preview_AttachmentsView_0_en","",20420,], -["features.messages.impl.attachments.preview_AttachmentsView_1_en","",20420,], -["features.messages.impl.attachments.preview_AttachmentsView_2_en","",20420,], -["features.messages.impl.attachments.preview_AttachmentsView_3_en","",20420,], -["features.messages.impl.attachments.preview_AttachmentsView_4_en","",20420,], -["features.messages.impl.attachments.preview_AttachmentsView_5_en","",20420,], -["features.messages.impl.attachments.preview_AttachmentsView_6_en","",20420,], -["features.messages.impl.attachments.preview_AttachmentsView_7_en","",20420,], -["features.messages.impl.attachments.preview_AttachmentsView_8_en","",20420,], +["features.messages.impl.attachments.preview_AttachmentsPreviewView_0_en","",20466,], +["features.messages.impl.attachments.preview_AttachmentsPreviewView_1_en","",20466,], +["features.messages.impl.attachments.preview_AttachmentsPreviewView_2_en","",20466,], +["features.messages.impl.attachments.preview_AttachmentsPreviewView_3_en","",20466,], +["features.messages.impl.attachments.preview_AttachmentsPreviewView_4_en","",20466,], +["features.messages.impl.attachments.preview_AttachmentsPreviewView_5_en","",20466,], +["features.messages.impl.attachments.preview_AttachmentsPreviewView_6_en","",20466,], +["features.messages.impl.attachments.preview_AttachmentsPreviewView_7_en","",20466,], +["features.messages.impl.attachments.preview_AttachmentsPreviewView_8_en","",20466,], ["libraries.mediaviewer.impl.gallery.ui_AudioItemView_Day_0_en","libraries.mediaviewer.impl.gallery.ui_AudioItemView_Night_0_en",0,], ["libraries.mediaviewer.impl.gallery.ui_AudioItemView_Day_1_en","libraries.mediaviewer.impl.gallery.ui_AudioItemView_Night_1_en",0,], ["libraries.mediaviewer.impl.gallery.ui_AudioItemView_Day_2_en","libraries.mediaviewer.impl.gallery.ui_AudioItemView_Night_2_en",0,], -["libraries.matrix.ui.components_AvatarActionBottomSheet_Day_0_en","libraries.matrix.ui.components_AvatarActionBottomSheet_Night_0_en",20420,], +["libraries.matrix.ui.components_AvatarActionBottomSheet_Day_0_en","libraries.matrix.ui.components_AvatarActionBottomSheet_Night_0_en",20466,], ["libraries.designsystem.components.avatar.internal_AvatarCluster_Avatars_en","",0,], +["libraries.matrix.ui.components_AvatarPickerSizes_Day_0_en","libraries.matrix.ui.components_AvatarPickerSizes_Night_0_en",0,], +["libraries.matrix.ui.components_AvatarPickerViewRtl_Day_0_en","libraries.matrix.ui.components_AvatarPickerViewRtl_Night_0_en",0,], +["libraries.matrix.ui.components_AvatarPickerView_Day_0_en","libraries.matrix.ui.components_AvatarPickerView_Night_0_en",0,], ["libraries.designsystem.components.avatar_AvatarRowLastOnTopRtl_Day_0_en","libraries.designsystem.components.avatar_AvatarRowLastOnTopRtl_Night_0_en",0,], ["libraries.designsystem.components.avatar_AvatarRowLastOnTopRtl_Day_1_en","libraries.designsystem.components.avatar_AvatarRowLastOnTopRtl_Night_1_en",0,], ["libraries.designsystem.components.avatar_AvatarRowLastOnTopRtl_Day_2_en","libraries.designsystem.components.avatar_AvatarRowLastOnTopRtl_Night_2_en",0,], @@ -123,22 +126,22 @@ export const screenshots = [ ["libraries.designsystem.modifiers_BackgroundVerticalGradientDisabled_Day_0_en","libraries.designsystem.modifiers_BackgroundVerticalGradientDisabled_Night_0_en",0,], ["libraries.designsystem.modifiers_BackgroundVerticalGradient_Day_0_en","libraries.designsystem.modifiers_BackgroundVerticalGradient_Night_0_en",0,], ["libraries.designsystem.components_Badge_Day_0_en","libraries.designsystem.components_Badge_Night_0_en",0,], -["features.home.impl.components_BatteryOptimizationBanner_Day_0_en","features.home.impl.components_BatteryOptimizationBanner_Night_0_en",20420,], +["features.home.impl.components_BatteryOptimizationBanner_Day_0_en","features.home.impl.components_BatteryOptimizationBanner_Night_0_en",20466,], ["libraries.designsystem.atomic.atoms_BetaLabel_Day_0_en","libraries.designsystem.atomic.atoms_BetaLabel_Night_0_en",0,], ["libraries.designsystem.components_BigIcon_Day_0_en","libraries.designsystem.components_BigIcon_Night_0_en",0,], -["features.preferences.impl.blockedusers_BlockedUsersView_Day_0_en","features.preferences.impl.blockedusers_BlockedUsersView_Night_0_en",20420,], -["features.preferences.impl.blockedusers_BlockedUsersView_Day_1_en","features.preferences.impl.blockedusers_BlockedUsersView_Night_1_en",20420,], -["features.preferences.impl.blockedusers_BlockedUsersView_Day_2_en","features.preferences.impl.blockedusers_BlockedUsersView_Night_2_en",20420,], -["features.preferences.impl.blockedusers_BlockedUsersView_Day_3_en","features.preferences.impl.blockedusers_BlockedUsersView_Night_3_en",20420,], -["features.preferences.impl.blockedusers_BlockedUsersView_Day_4_en","features.preferences.impl.blockedusers_BlockedUsersView_Night_4_en",20420,], -["features.preferences.impl.blockedusers_BlockedUsersView_Day_5_en","features.preferences.impl.blockedusers_BlockedUsersView_Night_5_en",20420,], -["features.preferences.impl.blockedusers_BlockedUsersView_Day_6_en","features.preferences.impl.blockedusers_BlockedUsersView_Night_6_en",20420,], +["features.preferences.impl.blockedusers_BlockedUsersView_Day_0_en","features.preferences.impl.blockedusers_BlockedUsersView_Night_0_en",20466,], +["features.preferences.impl.blockedusers_BlockedUsersView_Day_1_en","features.preferences.impl.blockedusers_BlockedUsersView_Night_1_en",20466,], +["features.preferences.impl.blockedusers_BlockedUsersView_Day_2_en","features.preferences.impl.blockedusers_BlockedUsersView_Night_2_en",20466,], +["features.preferences.impl.blockedusers_BlockedUsersView_Day_3_en","features.preferences.impl.blockedusers_BlockedUsersView_Night_3_en",20466,], +["features.preferences.impl.blockedusers_BlockedUsersView_Day_4_en","features.preferences.impl.blockedusers_BlockedUsersView_Night_4_en",20466,], +["features.preferences.impl.blockedusers_BlockedUsersView_Day_5_en","features.preferences.impl.blockedusers_BlockedUsersView_Night_5_en",20466,], +["features.preferences.impl.blockedusers_BlockedUsersView_Day_6_en","features.preferences.impl.blockedusers_BlockedUsersView_Night_6_en",20466,], ["libraries.designsystem.theme.components_BottomSheetDragHandle_Day_0_en","libraries.designsystem.theme.components_BottomSheetDragHandle_Night_0_en",0,], -["features.rageshake.impl.bugreport_BugReportViewDay_0_en","",20420,], -["features.rageshake.impl.bugreport_BugReportViewDay_1_en","",20420,], -["features.rageshake.impl.bugreport_BugReportViewDay_2_en","",20420,], -["features.rageshake.impl.bugreport_BugReportViewDay_3_en","",20420,], -["features.rageshake.impl.bugreport_BugReportViewDay_4_en","",20420,], +["features.rageshake.impl.bugreport_BugReportViewDay_0_en","",20466,], +["features.rageshake.impl.bugreport_BugReportViewDay_1_en","",20466,], +["features.rageshake.impl.bugreport_BugReportViewDay_2_en","",20466,], +["features.rageshake.impl.bugreport_BugReportViewDay_3_en","",20466,], +["features.rageshake.impl.bugreport_BugReportViewDay_4_en","",20466,], ["features.rageshake.impl.bugreport_BugReportViewNight_0_en","",0,], ["features.rageshake.impl.bugreport_BugReportViewNight_1_en","",0,], ["features.rageshake.impl.bugreport_BugReportViewNight_2_en","",0,], @@ -148,133 +151,139 @@ export const screenshots = [ ["libraries.designsystem.atomic.molecules_ButtonRowMolecule_Day_0_en","libraries.designsystem.atomic.molecules_ButtonRowMolecule_Night_0_en",0,], ["features.messages.impl.timeline.components_CallMenuItem_Day_0_en","features.messages.impl.timeline.components_CallMenuItem_Night_0_en",0,], ["features.messages.impl.timeline.components_CallMenuItem_Day_1_en","features.messages.impl.timeline.components_CallMenuItem_Night_1_en",0,], -["features.messages.impl.timeline.components_CallMenuItem_Day_2_en","features.messages.impl.timeline.components_CallMenuItem_Night_2_en",20420,], -["features.messages.impl.timeline.components_CallMenuItem_Day_3_en","features.messages.impl.timeline.components_CallMenuItem_Night_3_en",20420,], +["features.messages.impl.timeline.components_CallMenuItem_Day_2_en","features.messages.impl.timeline.components_CallMenuItem_Night_2_en",20466,], +["features.messages.impl.timeline.components_CallMenuItem_Day_3_en","features.messages.impl.timeline.components_CallMenuItem_Night_3_en",20466,], ["features.messages.impl.timeline.components_CallMenuItem_Day_4_en","features.messages.impl.timeline.components_CallMenuItem_Night_4_en",0,], ["features.messages.impl.timeline.components_CallMenuItem_Day_5_en","features.messages.impl.timeline.components_CallMenuItem_Night_5_en",0,], ["features.call.impl.ui_CallScreenView_Day_0_en","features.call.impl.ui_CallScreenView_Night_0_en",0,], -["features.call.impl.ui_CallScreenView_Day_1_en","features.call.impl.ui_CallScreenView_Night_1_en",20420,], -["features.call.impl.ui_CallScreenView_Day_2_en","features.call.impl.ui_CallScreenView_Night_2_en",20420,], -["features.call.impl.ui_CallScreenView_Day_3_en","features.call.impl.ui_CallScreenView_Night_3_en",20420,], -["libraries.textcomposer_CaptionWarningBottomSheet_Day_0_en","libraries.textcomposer_CaptionWarningBottomSheet_Night_0_en",20420,], -["features.login.impl.screens.changeaccountprovider_ChangeAccountProviderView_Day_0_en","features.login.impl.screens.changeaccountprovider_ChangeAccountProviderView_Night_0_en",20420,], -["features.login.impl.screens.changeaccountprovider_ChangeAccountProviderView_Day_1_en","features.login.impl.screens.changeaccountprovider_ChangeAccountProviderView_Night_1_en",20420,], -["features.rolesandpermissions.impl.roles_ChangeRolesView_Day_0_en","features.rolesandpermissions.impl.roles_ChangeRolesView_Night_0_en",20420,], -["features.rolesandpermissions.impl.roles_ChangeRolesView_Day_10_en","features.rolesandpermissions.impl.roles_ChangeRolesView_Night_10_en",20420,], -["features.rolesandpermissions.impl.roles_ChangeRolesView_Day_11_en","features.rolesandpermissions.impl.roles_ChangeRolesView_Night_11_en",20420,], -["features.rolesandpermissions.impl.roles_ChangeRolesView_Day_12_en","features.rolesandpermissions.impl.roles_ChangeRolesView_Night_12_en",20420,], -["features.rolesandpermissions.impl.roles_ChangeRolesView_Day_13_en","features.rolesandpermissions.impl.roles_ChangeRolesView_Night_13_en",20420,], -["features.rolesandpermissions.impl.roles_ChangeRolesView_Day_1_en","features.rolesandpermissions.impl.roles_ChangeRolesView_Night_1_en",20420,], -["features.rolesandpermissions.impl.roles_ChangeRolesView_Day_2_en","features.rolesandpermissions.impl.roles_ChangeRolesView_Night_2_en",20420,], -["features.rolesandpermissions.impl.roles_ChangeRolesView_Day_3_en","features.rolesandpermissions.impl.roles_ChangeRolesView_Night_3_en",20420,], -["features.rolesandpermissions.impl.roles_ChangeRolesView_Day_4_en","features.rolesandpermissions.impl.roles_ChangeRolesView_Night_4_en",20420,], +["features.call.impl.ui_CallScreenView_Day_1_en","features.call.impl.ui_CallScreenView_Night_1_en",20466,], +["features.call.impl.ui_CallScreenView_Day_2_en","features.call.impl.ui_CallScreenView_Night_2_en",20466,], +["features.call.impl.ui_CallScreenView_Day_3_en","features.call.impl.ui_CallScreenView_Night_3_en",20466,], +["libraries.textcomposer_CaptionWarningBottomSheet_Day_0_en","libraries.textcomposer_CaptionWarningBottomSheet_Night_0_en",20466,], +["features.login.impl.screens.changeaccountprovider_ChangeAccountProviderView_Day_0_en","features.login.impl.screens.changeaccountprovider_ChangeAccountProviderView_Night_0_en",20466,], +["features.login.impl.screens.changeaccountprovider_ChangeAccountProviderView_Day_1_en","features.login.impl.screens.changeaccountprovider_ChangeAccountProviderView_Night_1_en",20466,], +["features.rolesandpermissions.impl.roles_ChangeRolesView_Day_0_en","features.rolesandpermissions.impl.roles_ChangeRolesView_Night_0_en",20466,], +["features.rolesandpermissions.impl.roles_ChangeRolesView_Day_10_en","features.rolesandpermissions.impl.roles_ChangeRolesView_Night_10_en",20466,], +["features.rolesandpermissions.impl.roles_ChangeRolesView_Day_11_en","features.rolesandpermissions.impl.roles_ChangeRolesView_Night_11_en",20466,], +["features.rolesandpermissions.impl.roles_ChangeRolesView_Day_12_en","features.rolesandpermissions.impl.roles_ChangeRolesView_Night_12_en",20466,], +["features.rolesandpermissions.impl.roles_ChangeRolesView_Day_13_en","features.rolesandpermissions.impl.roles_ChangeRolesView_Night_13_en",20466,], +["features.rolesandpermissions.impl.roles_ChangeRolesView_Day_1_en","features.rolesandpermissions.impl.roles_ChangeRolesView_Night_1_en",20466,], +["features.rolesandpermissions.impl.roles_ChangeRolesView_Day_2_en","features.rolesandpermissions.impl.roles_ChangeRolesView_Night_2_en",20466,], +["features.rolesandpermissions.impl.roles_ChangeRolesView_Day_3_en","features.rolesandpermissions.impl.roles_ChangeRolesView_Night_3_en",20466,], +["features.rolesandpermissions.impl.roles_ChangeRolesView_Day_4_en","features.rolesandpermissions.impl.roles_ChangeRolesView_Night_4_en",20466,], ["features.rolesandpermissions.impl.roles_ChangeRolesView_Day_5_en","features.rolesandpermissions.impl.roles_ChangeRolesView_Night_5_en",0,], -["features.rolesandpermissions.impl.roles_ChangeRolesView_Day_6_en","features.rolesandpermissions.impl.roles_ChangeRolesView_Night_6_en",20420,], -["features.rolesandpermissions.impl.roles_ChangeRolesView_Day_7_en","features.rolesandpermissions.impl.roles_ChangeRolesView_Night_7_en",20420,], -["features.rolesandpermissions.impl.roles_ChangeRolesView_Day_8_en","features.rolesandpermissions.impl.roles_ChangeRolesView_Night_8_en",20420,], -["features.rolesandpermissions.impl.roles_ChangeRolesView_Day_9_en","features.rolesandpermissions.impl.roles_ChangeRolesView_Night_9_en",20420,], -["features.rolesandpermissions.impl.permissions_ChangeRoomPermissionsView_Day_0_en","features.rolesandpermissions.impl.permissions_ChangeRoomPermissionsView_Night_0_en",20420,], -["features.rolesandpermissions.impl.permissions_ChangeRoomPermissionsView_Day_1_en","features.rolesandpermissions.impl.permissions_ChangeRoomPermissionsView_Night_1_en",20420,], -["features.rolesandpermissions.impl.permissions_ChangeRoomPermissionsView_Day_2_en","features.rolesandpermissions.impl.permissions_ChangeRoomPermissionsView_Night_2_en",20420,], -["features.rolesandpermissions.impl.permissions_ChangeRoomPermissionsView_Day_3_en","features.rolesandpermissions.impl.permissions_ChangeRoomPermissionsView_Night_3_en",20420,], -["features.rolesandpermissions.impl.permissions_ChangeRoomPermissionsView_Day_4_en","features.rolesandpermissions.impl.permissions_ChangeRoomPermissionsView_Night_4_en",20420,], +["features.rolesandpermissions.impl.roles_ChangeRolesView_Day_6_en","features.rolesandpermissions.impl.roles_ChangeRolesView_Night_6_en",20466,], +["features.rolesandpermissions.impl.roles_ChangeRolesView_Day_7_en","features.rolesandpermissions.impl.roles_ChangeRolesView_Night_7_en",20466,], +["features.rolesandpermissions.impl.roles_ChangeRolesView_Day_8_en","features.rolesandpermissions.impl.roles_ChangeRolesView_Night_8_en",20466,], +["features.rolesandpermissions.impl.roles_ChangeRolesView_Day_9_en","features.rolesandpermissions.impl.roles_ChangeRolesView_Night_9_en",20466,], +["features.rolesandpermissions.impl.permissions_ChangeRoomPermissionsView_Day_0_en","features.rolesandpermissions.impl.permissions_ChangeRoomPermissionsView_Night_0_en",20466,], +["features.rolesandpermissions.impl.permissions_ChangeRoomPermissionsView_Day_1_en","features.rolesandpermissions.impl.permissions_ChangeRoomPermissionsView_Night_1_en",20466,], +["features.rolesandpermissions.impl.permissions_ChangeRoomPermissionsView_Day_2_en","features.rolesandpermissions.impl.permissions_ChangeRoomPermissionsView_Night_2_en",20466,], +["features.rolesandpermissions.impl.permissions_ChangeRoomPermissionsView_Day_3_en","features.rolesandpermissions.impl.permissions_ChangeRoomPermissionsView_Night_3_en",20466,], +["features.rolesandpermissions.impl.permissions_ChangeRoomPermissionsView_Day_4_en","features.rolesandpermissions.impl.permissions_ChangeRoomPermissionsView_Night_4_en",20466,], +["features.rolesandpermissions.impl.permissions_ChangeRoomPermissionsView_Day_5_en","features.rolesandpermissions.impl.permissions_ChangeRoomPermissionsView_Night_5_en",20466,], +["features.rolesandpermissions.impl.permissions_ChangeRoomPermissionsView_Day_6_en","features.rolesandpermissions.impl.permissions_ChangeRoomPermissionsView_Night_6_en",20466,], ["features.login.impl.changeserver_ChangeServerView_Day_0_en","features.login.impl.changeserver_ChangeServerView_Night_0_en",0,], -["features.login.impl.changeserver_ChangeServerView_Day_1_en","features.login.impl.changeserver_ChangeServerView_Night_1_en",20420,], -["features.login.impl.changeserver_ChangeServerView_Day_2_en","features.login.impl.changeserver_ChangeServerView_Night_2_en",20420,], -["features.login.impl.changeserver_ChangeServerView_Day_3_en","features.login.impl.changeserver_ChangeServerView_Night_3_en",20420,], -["features.login.impl.changeserver_ChangeServerView_Day_4_en","features.login.impl.changeserver_ChangeServerView_Night_4_en",20420,], -["features.login.impl.changeserver_ChangeServerView_Day_5_en","features.login.impl.changeserver_ChangeServerView_Night_5_en",20420,], +["features.login.impl.changeserver_ChangeServerView_Day_1_en","features.login.impl.changeserver_ChangeServerView_Night_1_en",20466,], +["features.login.impl.changeserver_ChangeServerView_Day_2_en","features.login.impl.changeserver_ChangeServerView_Night_2_en",20466,], +["features.login.impl.changeserver_ChangeServerView_Day_3_en","features.login.impl.changeserver_ChangeServerView_Night_3_en",20466,], +["features.login.impl.changeserver_ChangeServerView_Day_4_en","features.login.impl.changeserver_ChangeServerView_Night_4_en",20466,], +["features.login.impl.changeserver_ChangeServerView_Day_5_en","features.login.impl.changeserver_ChangeServerView_Night_5_en",20466,], ["libraries.matrix.ui.components_CheckableResolvedUserRow_en","",0,], -["libraries.matrix.ui.components_CheckableUnresolvedUserRow_en","",20420,], +["libraries.matrix.ui.components_CheckableUnresolvedUserRow_en","",20466,], ["libraries.designsystem.theme.components_Checkboxes_Toggles_en","",0,], -["features.login.impl.screens.chooseaccountprovider_ChooseAccountProviderView_Day_0_en","features.login.impl.screens.chooseaccountprovider_ChooseAccountProviderView_Night_0_en",20420,], -["features.login.impl.screens.chooseaccountprovider_ChooseAccountProviderView_Day_1_en","features.login.impl.screens.chooseaccountprovider_ChooseAccountProviderView_Night_1_en",20420,], -["features.login.impl.screens.chooseaccountprovider_ChooseAccountProviderView_Day_2_en","features.login.impl.screens.chooseaccountprovider_ChooseAccountProviderView_Night_2_en",20420,], -["features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Day_0_en","features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Night_0_en",20420,], -["features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Day_1_en","features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Night_1_en",20420,], -["features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Day_2_en","features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Night_2_en",20420,], -["features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Day_3_en","features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Night_3_en",20420,], -["features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Day_4_en","features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Night_4_en",20420,], +["features.login.impl.screens.chooseaccountprovider_ChooseAccountProviderView_Day_0_en","features.login.impl.screens.chooseaccountprovider_ChooseAccountProviderView_Night_0_en",20466,], +["features.login.impl.screens.chooseaccountprovider_ChooseAccountProviderView_Day_1_en","features.login.impl.screens.chooseaccountprovider_ChooseAccountProviderView_Night_1_en",20466,], +["features.login.impl.screens.chooseaccountprovider_ChooseAccountProviderView_Day_2_en","features.login.impl.screens.chooseaccountprovider_ChooseAccountProviderView_Night_2_en",20466,], +["features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Day_0_en","features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Night_0_en",20466,], +["features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Day_1_en","features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Night_1_en",20466,], +["features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Day_2_en","features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Night_2_en",20466,], +["features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Day_3_en","features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Night_3_en",20466,], +["features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Day_4_en","features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Night_4_en",20466,], ["libraries.designsystem.theme.components_CircularProgressIndicator_Progress_Indicators_en","",0,], ["libraries.designsystem.components_ClickableLinkText_Text_en","",0,], ["libraries.designsystem.theme_ColorAliases_Day_0_en","libraries.designsystem.theme_ColorAliases_Night_0_en",0,], -["libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Day_0_en","libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Night_0_en",20420,], -["libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Day_1_en","libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Night_1_en",20420,], -["libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Day_2_en","libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Night_2_en",20420,], -["libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Day_3_en","libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Night_3_en",20420,], -["libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Day_4_en","libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Night_4_en",20420,], -["libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Day_5_en","libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Night_5_en",20420,], -["libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Day_6_en","libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Night_6_en",20420,], -["libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Day_7_en","libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Night_7_en",20420,], -["libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Day_8_en","libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Night_8_en",20420,], -["libraries.textcomposer_ComposerModeView_Day_0_en","libraries.textcomposer_ComposerModeView_Night_0_en",20420,], +["libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Day_0_en","libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Night_0_en",20466,], +["libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Day_1_en","libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Night_1_en",20466,], +["libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Day_2_en","libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Night_2_en",20466,], +["libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Day_3_en","libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Night_3_en",20466,], +["libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Day_4_en","libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Night_4_en",20466,], +["libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Day_5_en","libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Night_5_en",20466,], +["libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Day_6_en","libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Night_6_en",20466,], +["libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Day_7_en","libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Night_7_en",20466,], +["libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Day_8_en","libraries.designsystem.atomic.molecules_ComposerAlertMolecule_Night_8_en",20466,], +["libraries.textcomposer_ComposerModeView_Day_0_en","libraries.textcomposer_ComposerModeView_Night_0_en",20466,], ["libraries.textcomposer_ComposerModeView_Day_1_en","libraries.textcomposer_ComposerModeView_Night_1_en",0,], ["libraries.textcomposer_ComposerModeView_Day_2_en","libraries.textcomposer_ComposerModeView_Night_2_en",0,], ["libraries.textcomposer_ComposerModeView_Day_3_en","libraries.textcomposer_ComposerModeView_Night_3_en",0,], -["features.createroom.impl.configureroom_ConfigureRoomViewDark_0_en","",20420,], -["features.createroom.impl.configureroom_ConfigureRoomViewDark_1_en","",20420,], -["features.createroom.impl.configureroom_ConfigureRoomViewDark_2_en","",20420,], -["features.createroom.impl.configureroom_ConfigureRoomViewDark_3_en","",20420,], -["features.createroom.impl.configureroom_ConfigureRoomViewDark_4_en","",20420,], -["features.createroom.impl.configureroom_ConfigureRoomViewDark_5_en","",20420,], -["features.createroom.impl.configureroom_ConfigureRoomViewLight_0_en","",20420,], -["features.createroom.impl.configureroom_ConfigureRoomViewLight_1_en","",20420,], -["features.createroom.impl.configureroom_ConfigureRoomViewLight_2_en","",20420,], -["features.createroom.impl.configureroom_ConfigureRoomViewLight_3_en","",20420,], -["features.createroom.impl.configureroom_ConfigureRoomViewLight_4_en","",20420,], -["features.createroom.impl.configureroom_ConfigureRoomViewLight_5_en","",20420,], -["features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Day_0_en","features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Night_0_en",20420,], -["features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Day_1_en","features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Night_1_en",20420,], -["features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Day_2_en","features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Night_2_en",20420,], -["features.home.impl.components_ConfirmRecoveryKeyBanner_Day_0_en","features.home.impl.components_ConfirmRecoveryKeyBanner_Night_0_en",20420,], +["features.createroom.impl.configureroom_ConfigureRoomViewDark_0_en","",20466,], +["features.createroom.impl.configureroom_ConfigureRoomViewDark_1_en","",20466,], +["features.createroom.impl.configureroom_ConfigureRoomViewDark_2_en","",20466,], +["features.createroom.impl.configureroom_ConfigureRoomViewDark_3_en","",20466,], +["features.createroom.impl.configureroom_ConfigureRoomViewDark_4_en","",20466,], +["features.createroom.impl.configureroom_ConfigureRoomViewDark_5_en","",20466,], +["features.createroom.impl.configureroom_ConfigureRoomViewDark_6_en","",20467,], +["features.createroom.impl.configureroom_ConfigureRoomViewLight_0_en","",20466,], +["features.createroom.impl.configureroom_ConfigureRoomViewLight_1_en","",20466,], +["features.createroom.impl.configureroom_ConfigureRoomViewLight_2_en","",20466,], +["features.createroom.impl.configureroom_ConfigureRoomViewLight_3_en","",20466,], +["features.createroom.impl.configureroom_ConfigureRoomViewLight_4_en","",20466,], +["features.createroom.impl.configureroom_ConfigureRoomViewLight_5_en","",20466,], +["features.createroom.impl.configureroom_ConfigureRoomViewLight_6_en","",20467,], +["features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Day_0_en","features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Night_0_en",20466,], +["features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Day_1_en","features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Night_1_en",20466,], +["features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Day_2_en","features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Night_2_en",20466,], +["features.home.impl.components_ConfirmRecoveryKeyBanner_Day_0_en","features.home.impl.components_ConfirmRecoveryKeyBanner_Night_0_en",20466,], ["libraries.designsystem.components.dialogs_ConfirmationDialogContent_Dialogs_en","",0,], ["libraries.designsystem.components.dialogs_ConfirmationDialog_Day_0_en","libraries.designsystem.components.dialogs_ConfirmationDialog_Night_0_en",0,], ["features.networkmonitor.api.ui_ConnectivityIndicator_Day_0_en","features.networkmonitor.api.ui_ConnectivityIndicator_Night_0_en",0,], ["libraries.designsystem.atomic.atoms_CounterAtom_Day_0_en","libraries.designsystem.atomic.atoms_CounterAtom_Night_0_en",0,], -["features.rageshake.api.crash_CrashDetectionView_Day_0_en","features.rageshake.api.crash_CrashDetectionView_Night_0_en",20420,], -["features.login.impl.screens.createaccount_CreateAccountView_Day_0_en","features.login.impl.screens.createaccount_CreateAccountView_Night_0_en",20420,], -["features.login.impl.screens.createaccount_CreateAccountView_Day_1_en","features.login.impl.screens.createaccount_CreateAccountView_Night_1_en",20420,], -["features.login.impl.screens.createaccount_CreateAccountView_Day_2_en","features.login.impl.screens.createaccount_CreateAccountView_Night_2_en",20420,], -["features.login.impl.screens.createaccount_CreateAccountView_Day_3_en","features.login.impl.screens.createaccount_CreateAccountView_Night_3_en",20420,], -["libraries.matrix.ui.components_CreateDmConfirmationBottomSheet_Day_0_en","libraries.matrix.ui.components_CreateDmConfirmationBottomSheet_Night_0_en",20420,], -["libraries.matrix.ui.components_CreateDmConfirmationBottomSheet_Day_1_en","libraries.matrix.ui.components_CreateDmConfirmationBottomSheet_Night_1_en",20420,], -["features.poll.impl.create_CreatePollView_Day_0_en","features.poll.impl.create_CreatePollView_Night_0_en",20420,], -["features.poll.impl.create_CreatePollView_Day_1_en","features.poll.impl.create_CreatePollView_Night_1_en",20420,], -["features.poll.impl.create_CreatePollView_Day_2_en","features.poll.impl.create_CreatePollView_Night_2_en",20420,], -["features.poll.impl.create_CreatePollView_Day_3_en","features.poll.impl.create_CreatePollView_Night_3_en",20420,], -["features.poll.impl.create_CreatePollView_Day_4_en","features.poll.impl.create_CreatePollView_Night_4_en",20420,], -["features.poll.impl.create_CreatePollView_Day_5_en","features.poll.impl.create_CreatePollView_Night_5_en",20420,], -["features.poll.impl.create_CreatePollView_Day_6_en","features.poll.impl.create_CreatePollView_Night_6_en",20420,], -["features.poll.impl.create_CreatePollView_Day_7_en","features.poll.impl.create_CreatePollView_Night_7_en",20420,], -["libraries.dateformatter.impl.previews_DateFormatterModeView_0_en","",20420,], -["libraries.dateformatter.impl.previews_DateFormatterModeView_1_en","",20420,], -["libraries.dateformatter.impl.previews_DateFormatterModeView_2_en","",20420,], -["libraries.dateformatter.impl.previews_DateFormatterModeView_3_en","",20420,], -["libraries.dateformatter.impl.previews_DateFormatterModeView_4_en","",20420,], +["features.rageshake.api.crash_CrashDetectionView_Day_0_en","features.rageshake.api.crash_CrashDetectionView_Night_0_en",20466,], +["features.login.impl.screens.createaccount_CreateAccountView_Day_0_en","features.login.impl.screens.createaccount_CreateAccountView_Night_0_en",20466,], +["features.login.impl.screens.createaccount_CreateAccountView_Day_1_en","features.login.impl.screens.createaccount_CreateAccountView_Night_1_en",20466,], +["features.login.impl.screens.createaccount_CreateAccountView_Day_2_en","features.login.impl.screens.createaccount_CreateAccountView_Night_2_en",20466,], +["features.login.impl.screens.createaccount_CreateAccountView_Day_3_en","features.login.impl.screens.createaccount_CreateAccountView_Night_3_en",20466,], +["libraries.matrix.ui.components_CreateDmConfirmationBottomSheet_Day_0_en","libraries.matrix.ui.components_CreateDmConfirmationBottomSheet_Night_0_en",20466,], +["libraries.matrix.ui.components_CreateDmConfirmationBottomSheet_Day_1_en","libraries.matrix.ui.components_CreateDmConfirmationBottomSheet_Night_1_en",20466,], +["features.poll.impl.create_CreatePollView_Day_0_en","features.poll.impl.create_CreatePollView_Night_0_en",20466,], +["features.poll.impl.create_CreatePollView_Day_1_en","features.poll.impl.create_CreatePollView_Night_1_en",20466,], +["features.poll.impl.create_CreatePollView_Day_2_en","features.poll.impl.create_CreatePollView_Night_2_en",20466,], +["features.poll.impl.create_CreatePollView_Day_3_en","features.poll.impl.create_CreatePollView_Night_3_en",20466,], +["features.poll.impl.create_CreatePollView_Day_4_en","features.poll.impl.create_CreatePollView_Night_4_en",20466,], +["features.poll.impl.create_CreatePollView_Day_5_en","features.poll.impl.create_CreatePollView_Night_5_en",20466,], +["features.poll.impl.create_CreatePollView_Day_6_en","features.poll.impl.create_CreatePollView_Night_6_en",20466,], +["features.poll.impl.create_CreatePollView_Day_7_en","features.poll.impl.create_CreatePollView_Night_7_en",20466,], +["libraries.dateformatter.impl.previews_DateFormatterModeView_0_en","",20466,], +["libraries.dateformatter.impl.previews_DateFormatterModeView_1_en","",20466,], +["libraries.dateformatter.impl.previews_DateFormatterModeView_2_en","",20466,], +["libraries.dateformatter.impl.previews_DateFormatterModeView_3_en","",20466,], +["libraries.dateformatter.impl.previews_DateFormatterModeView_4_en","",20466,], ["libraries.mediaviewer.impl.gallery.ui_DateItemView_Day_0_en","libraries.mediaviewer.impl.gallery.ui_DateItemView_Night_0_en",0,], ["libraries.mediaviewer.impl.gallery.ui_DateItemView_Day_1_en","libraries.mediaviewer.impl.gallery.ui_DateItemView_Night_1_en",0,], -["libraries.designsystem.theme.components.previews_DatePickerDark_DateTime_pickers_en","",20420,], -["libraries.designsystem.theme.components.previews_DatePickerLight_DateTime_pickers_en","",20420,], -["features.invite.impl.declineandblock_DeclineAndBlockView_Day_0_en","features.invite.impl.declineandblock_DeclineAndBlockView_Night_0_en",20420,], -["features.invite.impl.declineandblock_DeclineAndBlockView_Day_1_en","features.invite.impl.declineandblock_DeclineAndBlockView_Night_1_en",20420,], -["features.invite.impl.declineandblock_DeclineAndBlockView_Day_2_en","features.invite.impl.declineandblock_DeclineAndBlockView_Night_2_en",20420,], -["features.invite.impl.declineandblock_DeclineAndBlockView_Day_3_en","features.invite.impl.declineandblock_DeclineAndBlockView_Night_3_en",20420,], -["features.invite.impl.declineandblock_DeclineAndBlockView_Day_4_en","features.invite.impl.declineandblock_DeclineAndBlockView_Night_4_en",20420,], +["libraries.designsystem.theme.components.previews_DatePickerDark_DateTime_pickers_en","",20466,], +["libraries.designsystem.theme.components.previews_DatePickerLight_DateTime_pickers_en","",20466,], +["features.invite.impl.declineandblock_DeclineAndBlockView_Day_0_en","features.invite.impl.declineandblock_DeclineAndBlockView_Night_0_en",20466,], +["features.invite.impl.declineandblock_DeclineAndBlockView_Day_1_en","features.invite.impl.declineandblock_DeclineAndBlockView_Night_1_en",20466,], +["features.invite.impl.declineandblock_DeclineAndBlockView_Day_2_en","features.invite.impl.declineandblock_DeclineAndBlockView_Night_2_en",20466,], +["features.invite.impl.declineandblock_DeclineAndBlockView_Day_3_en","features.invite.impl.declineandblock_DeclineAndBlockView_Night_3_en",20466,], +["features.invite.impl.declineandblock_DeclineAndBlockView_Day_4_en","features.invite.impl.declineandblock_DeclineAndBlockView_Night_4_en",20466,], ["features.logout.impl.direct_DefaultDirectLogoutView_Day_0_en","features.logout.impl.direct_DefaultDirectLogoutView_Night_0_en",0,], -["features.logout.impl.direct_DefaultDirectLogoutView_Day_1_en","features.logout.impl.direct_DefaultDirectLogoutView_Night_1_en",20420,], -["features.logout.impl.direct_DefaultDirectLogoutView_Day_2_en","features.logout.impl.direct_DefaultDirectLogoutView_Night_2_en",20420,], -["features.logout.impl.direct_DefaultDirectLogoutView_Day_3_en","features.logout.impl.direct_DefaultDirectLogoutView_Night_3_en",20420,], +["features.logout.impl.direct_DefaultDirectLogoutView_Day_1_en","features.logout.impl.direct_DefaultDirectLogoutView_Night_1_en",20466,], +["features.logout.impl.direct_DefaultDirectLogoutView_Day_2_en","features.logout.impl.direct_DefaultDirectLogoutView_Night_2_en",20466,], +["features.logout.impl.direct_DefaultDirectLogoutView_Day_3_en","features.logout.impl.direct_DefaultDirectLogoutView_Night_3_en",20466,], ["features.logout.impl.direct_DefaultDirectLogoutView_Day_4_en","features.logout.impl.direct_DefaultDirectLogoutView_Night_4_en",0,], -["features.preferences.impl.notifications.edit_DefaultNotificationSettingOption_Day_0_en","features.preferences.impl.notifications.edit_DefaultNotificationSettingOption_Night_0_en",20420,], +["features.preferences.impl.notifications.edit_DefaultNotificationSettingOption_Day_0_en","features.preferences.impl.notifications.edit_DefaultNotificationSettingOption_Night_0_en",20466,], ["features.licenses.impl.details_DependenciesDetailsView_Day_0_en","features.licenses.impl.details_DependenciesDetailsView_Night_0_en",0,], -["features.licenses.impl.list_DependencyLicensesListView_Day_0_en","features.licenses.impl.list_DependencyLicensesListView_Night_0_en",20420,], -["features.licenses.impl.list_DependencyLicensesListView_Day_1_en","features.licenses.impl.list_DependencyLicensesListView_Night_1_en",20420,], -["features.licenses.impl.list_DependencyLicensesListView_Day_2_en","features.licenses.impl.list_DependencyLicensesListView_Night_2_en",20420,], -["features.licenses.impl.list_DependencyLicensesListView_Day_3_en","features.licenses.impl.list_DependencyLicensesListView_Night_3_en",20420,], -["features.preferences.impl.developer_DeveloperSettingsView_Day_0_en","features.preferences.impl.developer_DeveloperSettingsView_Night_0_en",20420,], -["features.preferences.impl.developer_DeveloperSettingsView_Day_1_en","features.preferences.impl.developer_DeveloperSettingsView_Night_1_en",20420,], -["features.preferences.impl.developer_DeveloperSettingsView_Day_2_en","features.preferences.impl.developer_DeveloperSettingsView_Night_2_en",20420,], -["features.preferences.impl.developer_DeveloperSettingsView_Day_3_en","features.preferences.impl.developer_DeveloperSettingsView_Night_3_en",20420,], +["features.licenses.impl.list_DependencyLicensesListView_Day_0_en","features.licenses.impl.list_DependencyLicensesListView_Night_0_en",20466,], +["features.licenses.impl.list_DependencyLicensesListView_Day_1_en","features.licenses.impl.list_DependencyLicensesListView_Night_1_en",20466,], +["features.licenses.impl.list_DependencyLicensesListView_Day_2_en","features.licenses.impl.list_DependencyLicensesListView_Night_2_en",20466,], +["features.licenses.impl.list_DependencyLicensesListView_Day_3_en","features.licenses.impl.list_DependencyLicensesListView_Night_3_en",20466,], +["features.linknewdevice.impl.screens.desktop_DesktopNoticeView_Day_0_en","features.linknewdevice.impl.screens.desktop_DesktopNoticeView_Night_0_en",20466,], +["features.linknewdevice.impl.screens.desktop_DesktopNoticeView_Day_1_en","features.linknewdevice.impl.screens.desktop_DesktopNoticeView_Night_1_en",20466,], +["features.preferences.impl.developer_DeveloperSettingsView_Day_0_en","features.preferences.impl.developer_DeveloperSettingsView_Night_0_en",20466,], +["features.preferences.impl.developer_DeveloperSettingsView_Day_1_en","features.preferences.impl.developer_DeveloperSettingsView_Night_1_en",20466,], +["features.preferences.impl.developer_DeveloperSettingsView_Day_2_en","features.preferences.impl.developer_DeveloperSettingsView_Night_2_en",20466,], +["features.preferences.impl.developer_DeveloperSettingsView_Day_3_en","features.preferences.impl.developer_DeveloperSettingsView_Night_3_en",20466,], ["libraries.designsystem.theme.components_DialogWithDestructiveButton_Dialog_with_destructive_button_Dialogs_en","",0,], ["libraries.designsystem.theme.components_DialogWithOnlyMessageAndOkButton_Dialog_with_only_message_and_ok_button_Dialogs_en","",0,], ["libraries.designsystem.theme.components_DialogWithThirdButton_Dialog_with_third_button_Dialogs_en","",0,], @@ -289,22 +298,19 @@ export const screenshots = [ ["libraries.designsystem.text_DpScale_1_0f__en","",0,], ["libraries.designsystem.text_DpScale_1_5f__en","",0,], ["libraries.designsystem.theme.components_DropdownMenuItem_Menus_en","",0,], -["features.preferences.impl.notifications.edit_EditDefaultNotificationSettingView_Day_0_en","features.preferences.impl.notifications.edit_EditDefaultNotificationSettingView_Night_0_en",20420,], -["features.preferences.impl.notifications.edit_EditDefaultNotificationSettingView_Day_1_en","features.preferences.impl.notifications.edit_EditDefaultNotificationSettingView_Night_1_en",20420,], -["features.preferences.impl.notifications.edit_EditDefaultNotificationSettingView_Day_2_en","features.preferences.impl.notifications.edit_EditDefaultNotificationSettingView_Night_2_en",20420,], -["features.preferences.impl.notifications.edit_EditDefaultNotificationSettingView_Day_3_en","features.preferences.impl.notifications.edit_EditDefaultNotificationSettingView_Night_3_en",20420,], -["features.preferences.impl.notifications.edit_EditDefaultNotificationSettingView_Day_4_en","features.preferences.impl.notifications.edit_EditDefaultNotificationSettingView_Night_4_en",20420,], -["features.securityandprivacy.impl.editroomaddress_EditRoomAddressView_Day_0_en","features.securityandprivacy.impl.editroomaddress_EditRoomAddressView_Night_0_en",0,], -["features.securityandprivacy.impl.editroomaddress_EditRoomAddressView_Day_1_en","features.securityandprivacy.impl.editroomaddress_EditRoomAddressView_Night_1_en",0,], -["features.securityandprivacy.impl.editroomaddress_EditRoomAddressView_Day_2_en","features.securityandprivacy.impl.editroomaddress_EditRoomAddressView_Night_2_en",0,], -["features.securityandprivacy.impl.editroomaddress_EditRoomAddressView_Day_3_en","features.securityandprivacy.impl.editroomaddress_EditRoomAddressView_Night_3_en",0,], -["features.securityandprivacy.impl.editroomaddress_EditRoomAddressView_Day_4_en","features.securityandprivacy.impl.editroomaddress_EditRoomAddressView_Night_4_en",0,], -["features.preferences.impl.user.editprofile_EditUserProfileView_Day_0_en","features.preferences.impl.user.editprofile_EditUserProfileView_Night_0_en",20420,], -["features.preferences.impl.user.editprofile_EditUserProfileView_Day_1_en","features.preferences.impl.user.editprofile_EditUserProfileView_Night_1_en",20420,], -["features.preferences.impl.user.editprofile_EditUserProfileView_Day_2_en","features.preferences.impl.user.editprofile_EditUserProfileView_Night_2_en",0,], -["libraries.matrix.ui.components_EditableAvatarView_Day_0_en","libraries.matrix.ui.components_EditableAvatarView_Night_0_en",0,], -["libraries.matrix.ui.components_EditableAvatarView_Day_1_en","libraries.matrix.ui.components_EditableAvatarView_Night_1_en",0,], -["libraries.matrix.ui.components_EditableAvatarView_Day_2_en","libraries.matrix.ui.components_EditableAvatarView_Night_2_en",0,], +["features.preferences.impl.notifications.edit_EditDefaultNotificationSettingView_Day_0_en","features.preferences.impl.notifications.edit_EditDefaultNotificationSettingView_Night_0_en",20466,], +["features.preferences.impl.notifications.edit_EditDefaultNotificationSettingView_Day_1_en","features.preferences.impl.notifications.edit_EditDefaultNotificationSettingView_Night_1_en",20466,], +["features.preferences.impl.notifications.edit_EditDefaultNotificationSettingView_Day_2_en","features.preferences.impl.notifications.edit_EditDefaultNotificationSettingView_Night_2_en",20466,], +["features.preferences.impl.notifications.edit_EditDefaultNotificationSettingView_Day_3_en","features.preferences.impl.notifications.edit_EditDefaultNotificationSettingView_Night_3_en",20466,], +["features.preferences.impl.notifications.edit_EditDefaultNotificationSettingView_Day_4_en","features.preferences.impl.notifications.edit_EditDefaultNotificationSettingView_Night_4_en",20466,], +["features.securityandprivacy.impl.editroomaddress_EditRoomAddressView_Day_0_en","features.securityandprivacy.impl.editroomaddress_EditRoomAddressView_Night_0_en",20466,], +["features.securityandprivacy.impl.editroomaddress_EditRoomAddressView_Day_1_en","features.securityandprivacy.impl.editroomaddress_EditRoomAddressView_Night_1_en",20466,], +["features.securityandprivacy.impl.editroomaddress_EditRoomAddressView_Day_2_en","features.securityandprivacy.impl.editroomaddress_EditRoomAddressView_Night_2_en",20466,], +["features.securityandprivacy.impl.editroomaddress_EditRoomAddressView_Day_3_en","features.securityandprivacy.impl.editroomaddress_EditRoomAddressView_Night_3_en",20466,], +["features.securityandprivacy.impl.editroomaddress_EditRoomAddressView_Day_4_en","features.securityandprivacy.impl.editroomaddress_EditRoomAddressView_Night_4_en",20466,], +["features.preferences.impl.user.editprofile_EditUserProfileView_Day_0_en","features.preferences.impl.user.editprofile_EditUserProfileView_Night_0_en",20466,], +["features.preferences.impl.user.editprofile_EditUserProfileView_Day_1_en","features.preferences.impl.user.editprofile_EditUserProfileView_Night_1_en",20466,], +["features.preferences.impl.user.editprofile_EditUserProfileView_Day_2_en","features.preferences.impl.user.editprofile_EditUserProfileView_Night_2_en",20466,], ["libraries.matrix.ui.components_EditableOrgAvatarRtl_Day_0_en","libraries.matrix.ui.components_EditableOrgAvatarRtl_Night_0_en",0,], ["libraries.matrix.ui.components_EditableOrgAvatar_Day_0_en","libraries.matrix.ui.components_EditableOrgAvatar_Night_0_en",0,], ["libraries.designsystem.atomic.atoms_ElementLogoAtomLargeNoBlurShadow_Day_0_en","libraries.designsystem.atomic.atoms_ElementLogoAtomLargeNoBlurShadow_Night_0_en",0,], @@ -312,14 +318,28 @@ export const screenshots = [ ["libraries.designsystem.atomic.atoms_ElementLogoAtomMediumNoBlurShadow_Day_0_en","libraries.designsystem.atomic.atoms_ElementLogoAtomMediumNoBlurShadow_Night_0_en",0,], ["libraries.designsystem.atomic.atoms_ElementLogoAtomMedium_Day_0_en","libraries.designsystem.atomic.atoms_ElementLogoAtomMedium_Night_0_en",0,], ["features.messages.impl.timeline.components.customreaction_EmojiItem_Day_0_en","features.messages.impl.timeline.components.customreaction_EmojiItem_Night_0_en",0,], -["features.messages.impl.timeline.components.customreaction.picker_EmojiPicker_Day_0_en","features.messages.impl.timeline.components.customreaction.picker_EmojiPicker_Night_0_en",20420,], -["features.messages.impl.timeline.components.customreaction.picker_EmojiPicker_Day_1_en","features.messages.impl.timeline.components.customreaction.picker_EmojiPicker_Night_1_en",20420,], +["features.messages.impl.timeline.components.customreaction.picker_EmojiPicker_Day_0_en","features.messages.impl.timeline.components.customreaction.picker_EmojiPicker_Night_0_en",20466,], +["features.messages.impl.timeline.components.customreaction.picker_EmojiPicker_Day_1_en","features.messages.impl.timeline.components.customreaction.picker_EmojiPicker_Night_1_en",20466,], ["features.messages.impl.timeline.components.customreaction.picker_EmojiPicker_Day_2_en","features.messages.impl.timeline.components.customreaction.picker_EmojiPicker_Night_2_en",0,], ["features.messages.impl.timeline.components.customreaction.picker_EmojiPicker_Day_3_en","features.messages.impl.timeline.components.customreaction.picker_EmojiPicker_Night_3_en",0,], ["libraries.ui.common.nodes_EmptyView_Day_0_en","libraries.ui.common.nodes_EmptyView_Night_0_en",0,], -["libraries.designsystem.components.dialogs_ErrorDialogContent_Dialogs_en","",20420,], -["libraries.designsystem.components.dialogs_ErrorDialogWithDoNotShowAgain_Day_0_en","libraries.designsystem.components.dialogs_ErrorDialogWithDoNotShowAgain_Night_0_en",20420,], -["libraries.designsystem.components.dialogs_ErrorDialog_Day_0_en","libraries.designsystem.components.dialogs_ErrorDialog_Night_0_en",20420,], +["features.linknewdevice.impl.screens.number_EnterNumberView_Day_0_en","features.linknewdevice.impl.screens.number_EnterNumberView_Night_0_en",20466,], +["features.linknewdevice.impl.screens.number_EnterNumberView_Day_1_en","features.linknewdevice.impl.screens.number_EnterNumberView_Night_1_en",20466,], +["features.linknewdevice.impl.screens.number_EnterNumberView_Day_2_en","features.linknewdevice.impl.screens.number_EnterNumberView_Night_2_en",20466,], +["features.linknewdevice.impl.screens.number_EnterNumberView_Day_3_en","features.linknewdevice.impl.screens.number_EnterNumberView_Night_3_en",20466,], +["features.linknewdevice.impl.screens.number_EnterNumberView_Day_4_en","features.linknewdevice.impl.screens.number_EnterNumberView_Night_4_en",20466,], +["features.linknewdevice.impl.screens.number_EnterNumberView_Day_5_en","features.linknewdevice.impl.screens.number_EnterNumberView_Night_5_en",20466,], +["libraries.designsystem.components.dialogs_ErrorDialogContent_Dialogs_en","",20466,], +["libraries.designsystem.components.dialogs_ErrorDialogWithDoNotShowAgain_Day_0_en","libraries.designsystem.components.dialogs_ErrorDialogWithDoNotShowAgain_Night_0_en",20466,], +["libraries.designsystem.components.dialogs_ErrorDialog_Day_0_en","libraries.designsystem.components.dialogs_ErrorDialog_Night_0_en",20466,], +["features.linknewdevice.impl.screens.error_ErrorView_Day_0_en","features.linknewdevice.impl.screens.error_ErrorView_Night_0_en",20466,], +["features.linknewdevice.impl.screens.error_ErrorView_Day_1_en","features.linknewdevice.impl.screens.error_ErrorView_Night_1_en",20466,], +["features.linknewdevice.impl.screens.error_ErrorView_Day_2_en","features.linknewdevice.impl.screens.error_ErrorView_Night_2_en",20466,], +["features.linknewdevice.impl.screens.error_ErrorView_Day_3_en","features.linknewdevice.impl.screens.error_ErrorView_Night_3_en",20466,], +["features.linknewdevice.impl.screens.error_ErrorView_Day_4_en","features.linknewdevice.impl.screens.error_ErrorView_Night_4_en",20466,], +["features.linknewdevice.impl.screens.error_ErrorView_Day_5_en","features.linknewdevice.impl.screens.error_ErrorView_Night_5_en",20466,], +["features.linknewdevice.impl.screens.error_ErrorView_Day_6_en","features.linknewdevice.impl.screens.error_ErrorView_Night_6_en",20466,], +["features.linknewdevice.impl.screens.error_ErrorView_Day_7_en","features.linknewdevice.impl.screens.error_ErrorView_Night_7_en",20466,], ["features.messages.impl.timeline.debug_EventDebugInfoView_Day_0_en","features.messages.impl.timeline.debug_EventDebugInfoView_Night_0_en",0,], ["libraries.designsystem.components_ExpandableBottomSheetLayout_en","",0,], ["libraries.featureflag.ui_FeatureListView_Day_0_en","libraries.featureflag.ui_FeatureListView_Night_0_en",0,], @@ -338,43 +358,46 @@ export const screenshots = [ ["libraries.designsystem.theme.components_FloatingActionButton_Floating_Action_Buttons_en","",0,], ["libraries.designsystem.atomic.pages_FlowStepPage_Day_0_en","libraries.designsystem.atomic.pages_FlowStepPage_Night_0_en",0,], ["features.messages.impl.timeline.focus_FocusRequestStateView_Day_0_en","features.messages.impl.timeline.focus_FocusRequestStateView_Night_0_en",0,], -["features.messages.impl.timeline.focus_FocusRequestStateView_Day_1_en","features.messages.impl.timeline.focus_FocusRequestStateView_Night_1_en",20420,], -["features.messages.impl.timeline.focus_FocusRequestStateView_Day_2_en","features.messages.impl.timeline.focus_FocusRequestStateView_Night_2_en",20420,], -["features.messages.impl.timeline.focus_FocusRequestStateView_Day_3_en","features.messages.impl.timeline.focus_FocusRequestStateView_Night_3_en",20420,], +["features.messages.impl.timeline.focus_FocusRequestStateView_Day_1_en","features.messages.impl.timeline.focus_FocusRequestStateView_Night_1_en",20466,], +["features.messages.impl.timeline.focus_FocusRequestStateView_Day_2_en","features.messages.impl.timeline.focus_FocusRequestStateView_Night_2_en",20466,], +["features.messages.impl.timeline.focus_FocusRequestStateView_Day_3_en","features.messages.impl.timeline.focus_FocusRequestStateView_Night_3_en",20466,], ["features.messages.impl.timeline.components_FocusedEvent_Day_0_en","features.messages.impl.timeline.components_FocusedEvent_Night_0_en",0,], ["libraries.textcomposer.components_FormattingOption_Day_0_en","libraries.textcomposer.components_FormattingOption_Night_0_en",0,], ["features.forward.impl_ForwardMessagesView_Day_0_en","features.forward.impl_ForwardMessagesView_Night_0_en",0,], ["features.forward.impl_ForwardMessagesView_Day_1_en","features.forward.impl_ForwardMessagesView_Night_1_en",0,], ["features.forward.impl_ForwardMessagesView_Day_2_en","features.forward.impl_ForwardMessagesView_Night_2_en",0,], -["features.forward.impl_ForwardMessagesView_Day_3_en","features.forward.impl_ForwardMessagesView_Night_3_en",20420,], -["features.home.impl.components_FullScreenIntentPermissionBanner_Day_0_en","features.home.impl.components_FullScreenIntentPermissionBanner_Night_0_en",20420,], +["features.forward.impl_ForwardMessagesView_Day_3_en","features.forward.impl_ForwardMessagesView_Night_3_en",20466,], +["features.home.impl.components_FullScreenIntentPermissionBanner_Day_0_en","features.home.impl.components_FullScreenIntentPermissionBanner_Night_0_en",20466,], ["libraries.designsystem.components.button_GradientFloatingActionButtonCircleShape_Day_0_en","libraries.designsystem.components.button_GradientFloatingActionButtonCircleShape_Night_0_en",0,], ["libraries.designsystem.components.button_GradientFloatingActionButton_Day_0_en","libraries.designsystem.components.button_GradientFloatingActionButton_Night_0_en",0,], ["features.messages.impl.timeline.components.group_GroupHeaderView_Day_0_en","features.messages.impl.timeline.components.group_GroupHeaderView_Night_0_en",0,], ["libraries.designsystem.atomic.pages_HeaderFooterPageScrollable_Day_0_en","libraries.designsystem.atomic.pages_HeaderFooterPageScrollable_Night_0_en",0,], ["libraries.designsystem.atomic.pages_HeaderFooterPage_Day_0_en","libraries.designsystem.atomic.pages_HeaderFooterPage_Night_0_en",0,], -["features.home.impl.spaces_HomeSpacesView_Day_0_en","features.home.impl.spaces_HomeSpacesView_Night_0_en",20420,], -["features.home.impl.spaces_HomeSpacesView_Day_1_en","features.home.impl.spaces_HomeSpacesView_Night_1_en",20420,], -["features.home.impl.components_HomeTopBarMultiAccount_Day_0_en","features.home.impl.components_HomeTopBarMultiAccount_Night_0_en",20420,], -["features.home.impl.components_HomeTopBarWithIndicator_Day_0_en","features.home.impl.components_HomeTopBarWithIndicator_Night_0_en",20420,], -["features.home.impl.components_HomeTopBar_Day_0_en","features.home.impl.components_HomeTopBar_Night_0_en",20420,], +["features.messages.impl.crypto.historyvisible_HistoryVisibleStateView_Day_0_en","features.messages.impl.crypto.historyvisible_HistoryVisibleStateView_Night_0_en",20466,], +["features.home.impl.spaces_HomeSpacesView_Day_0_en","features.home.impl.spaces_HomeSpacesView_Night_0_en",20466,], +["features.home.impl.spaces_HomeSpacesView_Day_1_en","features.home.impl.spaces_HomeSpacesView_Night_1_en",20466,], +["features.home.impl.spaces_HomeSpacesView_Day_2_en","features.home.impl.spaces_HomeSpacesView_Night_2_en",20467,], +["features.home.impl.components_HomeTopBarMultiAccount_Day_0_en","features.home.impl.components_HomeTopBarMultiAccount_Night_0_en",20466,], +["features.home.impl.components_HomeTopBarSpaces_Day_0_en","features.home.impl.components_HomeTopBarSpaces_Night_0_en",0,], +["features.home.impl.components_HomeTopBarWithIndicator_Day_0_en","features.home.impl.components_HomeTopBarWithIndicator_Night_0_en",20466,], +["features.home.impl.components_HomeTopBar_Day_0_en","features.home.impl.components_HomeTopBar_Night_0_en",20466,], ["features.home.impl_HomeViewA11y_en","",0,], -["features.home.impl_HomeView_Day_0_en","features.home.impl_HomeView_Night_0_en",20420,], -["features.home.impl_HomeView_Day_10_en","features.home.impl_HomeView_Night_10_en",20420,], +["features.home.impl_HomeView_Day_0_en","features.home.impl_HomeView_Night_0_en",20466,], +["features.home.impl_HomeView_Day_10_en","features.home.impl_HomeView_Night_10_en",20466,], ["features.home.impl_HomeView_Day_11_en","features.home.impl_HomeView_Night_11_en",0,], ["features.home.impl_HomeView_Day_12_en","features.home.impl_HomeView_Night_12_en",0,], -["features.home.impl_HomeView_Day_13_en","features.home.impl_HomeView_Night_13_en",20420,], -["features.home.impl_HomeView_Day_14_en","features.home.impl_HomeView_Night_14_en",20420,], -["features.home.impl_HomeView_Day_15_en","features.home.impl_HomeView_Night_15_en",20420,], -["features.home.impl_HomeView_Day_1_en","features.home.impl_HomeView_Night_1_en",20420,], -["features.home.impl_HomeView_Day_2_en","features.home.impl_HomeView_Night_2_en",20420,], -["features.home.impl_HomeView_Day_3_en","features.home.impl_HomeView_Night_3_en",20420,], -["features.home.impl_HomeView_Day_4_en","features.home.impl_HomeView_Night_4_en",20420,], -["features.home.impl_HomeView_Day_5_en","features.home.impl_HomeView_Night_5_en",20420,], -["features.home.impl_HomeView_Day_6_en","features.home.impl_HomeView_Night_6_en",20420,], -["features.home.impl_HomeView_Day_7_en","features.home.impl_HomeView_Night_7_en",20420,], -["features.home.impl_HomeView_Day_8_en","features.home.impl_HomeView_Night_8_en",20420,], -["features.home.impl_HomeView_Day_9_en","features.home.impl_HomeView_Night_9_en",20420,], +["features.home.impl_HomeView_Day_13_en","features.home.impl_HomeView_Night_13_en",20466,], +["features.home.impl_HomeView_Day_14_en","features.home.impl_HomeView_Night_14_en",20466,], +["features.home.impl_HomeView_Day_15_en","features.home.impl_HomeView_Night_15_en",20466,], +["features.home.impl_HomeView_Day_1_en","features.home.impl_HomeView_Night_1_en",20466,], +["features.home.impl_HomeView_Day_2_en","features.home.impl_HomeView_Night_2_en",20466,], +["features.home.impl_HomeView_Day_3_en","features.home.impl_HomeView_Night_3_en",20466,], +["features.home.impl_HomeView_Day_4_en","features.home.impl_HomeView_Night_4_en",20466,], +["features.home.impl_HomeView_Day_5_en","features.home.impl_HomeView_Night_5_en",20466,], +["features.home.impl_HomeView_Day_6_en","features.home.impl_HomeView_Night_6_en",20466,], +["features.home.impl_HomeView_Day_7_en","features.home.impl_HomeView_Night_7_en",20466,], +["features.home.impl_HomeView_Day_8_en","features.home.impl_HomeView_Night_8_en",20466,], +["features.home.impl_HomeView_Day_9_en","features.home.impl_HomeView_Night_9_en",20466,], ["libraries.designsystem.theme.components_HorizontalDivider_Dividers_en","",0,], ["libraries.designsystem.ruler_HorizontalRuler_Day_0_en","libraries.designsystem.ruler_HorizontalRuler_Night_0_en",0,], ["libraries.designsystem.theme.components_IconButton_Buttons_en","",0,], @@ -387,8 +410,8 @@ export const screenshots = [ ["appicon.enterprise_Icon_en","",0,], ["libraries.designsystem.icons_IconsOther_Day_0_en","libraries.designsystem.icons_IconsOther_Night_0_en",0,], ["features.messages.impl.crypto.identity_IdentityChangeStateView_Day_0_en","features.messages.impl.crypto.identity_IdentityChangeStateView_Night_0_en",0,], -["features.messages.impl.crypto.identity_IdentityChangeStateView_Day_1_en","features.messages.impl.crypto.identity_IdentityChangeStateView_Night_1_en",20420,], -["features.messages.impl.crypto.identity_IdentityChangeStateView_Day_2_en","features.messages.impl.crypto.identity_IdentityChangeStateView_Night_2_en",20420,], +["features.messages.impl.crypto.identity_IdentityChangeStateView_Day_1_en","features.messages.impl.crypto.identity_IdentityChangeStateView_Night_1_en",20466,], +["features.messages.impl.crypto.identity_IdentityChangeStateView_Day_2_en","features.messages.impl.crypto.identity_IdentityChangeStateView_Night_2_en",20466,], ["libraries.mediaviewer.impl.gallery.ui_ImageItemView_Day_0_en","libraries.mediaviewer.impl.gallery.ui_ImageItemView_Night_0_en",0,], ["libraries.matrix.ui.messages.reply_InReplyToView_Day_0_en","libraries.matrix.ui.messages.reply_InReplyToView_Night_0_en",0,], ["libraries.matrix.ui.messages.reply_InReplyToView_Day_10_en","libraries.matrix.ui.messages.reply_InReplyToView_Night_10_en",0,], @@ -396,109 +419,115 @@ export const screenshots = [ ["libraries.matrix.ui.messages.reply_InReplyToView_Day_1_en","libraries.matrix.ui.messages.reply_InReplyToView_Night_1_en",0,], ["libraries.matrix.ui.messages.reply_InReplyToView_Day_2_en","libraries.matrix.ui.messages.reply_InReplyToView_Night_2_en",0,], ["libraries.matrix.ui.messages.reply_InReplyToView_Day_3_en","libraries.matrix.ui.messages.reply_InReplyToView_Night_3_en",0,], -["libraries.matrix.ui.messages.reply_InReplyToView_Day_4_en","libraries.matrix.ui.messages.reply_InReplyToView_Night_4_en",20420,], +["libraries.matrix.ui.messages.reply_InReplyToView_Day_4_en","libraries.matrix.ui.messages.reply_InReplyToView_Night_4_en",20466,], ["libraries.matrix.ui.messages.reply_InReplyToView_Day_5_en","libraries.matrix.ui.messages.reply_InReplyToView_Night_5_en",0,], ["libraries.matrix.ui.messages.reply_InReplyToView_Day_6_en","libraries.matrix.ui.messages.reply_InReplyToView_Night_6_en",0,], ["libraries.matrix.ui.messages.reply_InReplyToView_Day_7_en","libraries.matrix.ui.messages.reply_InReplyToView_Night_7_en",0,], -["libraries.matrix.ui.messages.reply_InReplyToView_Day_8_en","libraries.matrix.ui.messages.reply_InReplyToView_Night_8_en",20420,], +["libraries.matrix.ui.messages.reply_InReplyToView_Day_8_en","libraries.matrix.ui.messages.reply_InReplyToView_Night_8_en",20466,], ["libraries.matrix.ui.messages.reply_InReplyToView_Day_9_en","libraries.matrix.ui.messages.reply_InReplyToView_Night_9_en",0,], -["features.call.impl.ui_IncomingCallScreen_Day_0_en","features.call.impl.ui_IncomingCallScreen_Night_0_en",20420,], +["features.call.impl.ui_IncomingCallScreen_Day_0_en","features.call.impl.ui_IncomingCallScreen_Night_0_en",20466,], ["features.verifysession.impl.incoming_IncomingVerificationViewA11y_en","",0,], -["features.verifysession.impl.incoming_IncomingVerificationView_Day_0_en","features.verifysession.impl.incoming_IncomingVerificationView_Night_0_en",20420,], -["features.verifysession.impl.incoming_IncomingVerificationView_Day_10_en","features.verifysession.impl.incoming_IncomingVerificationView_Night_10_en",20420,], -["features.verifysession.impl.incoming_IncomingVerificationView_Day_11_en","features.verifysession.impl.incoming_IncomingVerificationView_Night_11_en",20420,], -["features.verifysession.impl.incoming_IncomingVerificationView_Day_12_en","features.verifysession.impl.incoming_IncomingVerificationView_Night_12_en",20420,], -["features.verifysession.impl.incoming_IncomingVerificationView_Day_13_en","features.verifysession.impl.incoming_IncomingVerificationView_Night_13_en",20420,], -["features.verifysession.impl.incoming_IncomingVerificationView_Day_1_en","features.verifysession.impl.incoming_IncomingVerificationView_Night_1_en",20420,], -["features.verifysession.impl.incoming_IncomingVerificationView_Day_2_en","features.verifysession.impl.incoming_IncomingVerificationView_Night_2_en",20420,], -["features.verifysession.impl.incoming_IncomingVerificationView_Day_3_en","features.verifysession.impl.incoming_IncomingVerificationView_Night_3_en",20420,], -["features.verifysession.impl.incoming_IncomingVerificationView_Day_4_en","features.verifysession.impl.incoming_IncomingVerificationView_Night_4_en",20420,], -["features.verifysession.impl.incoming_IncomingVerificationView_Day_5_en","features.verifysession.impl.incoming_IncomingVerificationView_Night_5_en",20420,], -["features.verifysession.impl.incoming_IncomingVerificationView_Day_6_en","features.verifysession.impl.incoming_IncomingVerificationView_Night_6_en",20420,], -["features.verifysession.impl.incoming_IncomingVerificationView_Day_7_en","features.verifysession.impl.incoming_IncomingVerificationView_Night_7_en",20420,], -["features.verifysession.impl.incoming_IncomingVerificationView_Day_8_en","features.verifysession.impl.incoming_IncomingVerificationView_Night_8_en",20420,], -["features.verifysession.impl.incoming_IncomingVerificationView_Day_9_en","features.verifysession.impl.incoming_IncomingVerificationView_Night_9_en",20420,], +["features.verifysession.impl.incoming_IncomingVerificationView_Day_0_en","features.verifysession.impl.incoming_IncomingVerificationView_Night_0_en",20466,], +["features.verifysession.impl.incoming_IncomingVerificationView_Day_10_en","features.verifysession.impl.incoming_IncomingVerificationView_Night_10_en",20466,], +["features.verifysession.impl.incoming_IncomingVerificationView_Day_11_en","features.verifysession.impl.incoming_IncomingVerificationView_Night_11_en",20466,], +["features.verifysession.impl.incoming_IncomingVerificationView_Day_12_en","features.verifysession.impl.incoming_IncomingVerificationView_Night_12_en",20466,], +["features.verifysession.impl.incoming_IncomingVerificationView_Day_13_en","features.verifysession.impl.incoming_IncomingVerificationView_Night_13_en",20466,], +["features.verifysession.impl.incoming_IncomingVerificationView_Day_1_en","features.verifysession.impl.incoming_IncomingVerificationView_Night_1_en",20466,], +["features.verifysession.impl.incoming_IncomingVerificationView_Day_2_en","features.verifysession.impl.incoming_IncomingVerificationView_Night_2_en",20466,], +["features.verifysession.impl.incoming_IncomingVerificationView_Day_3_en","features.verifysession.impl.incoming_IncomingVerificationView_Night_3_en",20466,], +["features.verifysession.impl.incoming_IncomingVerificationView_Day_4_en","features.verifysession.impl.incoming_IncomingVerificationView_Night_4_en",20466,], +["features.verifysession.impl.incoming_IncomingVerificationView_Day_5_en","features.verifysession.impl.incoming_IncomingVerificationView_Night_5_en",20466,], +["features.verifysession.impl.incoming_IncomingVerificationView_Day_6_en","features.verifysession.impl.incoming_IncomingVerificationView_Night_6_en",20466,], +["features.verifysession.impl.incoming_IncomingVerificationView_Day_7_en","features.verifysession.impl.incoming_IncomingVerificationView_Night_7_en",20466,], +["features.verifysession.impl.incoming_IncomingVerificationView_Day_8_en","features.verifysession.impl.incoming_IncomingVerificationView_Night_8_en",20466,], +["features.verifysession.impl.incoming_IncomingVerificationView_Day_9_en","features.verifysession.impl.incoming_IncomingVerificationView_Night_9_en",20466,], ["libraries.designsystem.atomic.molecules_InfoListItemMolecule_Day_0_en","libraries.designsystem.atomic.molecules_InfoListItemMolecule_Night_0_en",0,], ["libraries.designsystem.atomic.organisms_InfoListOrganism_Day_0_en","libraries.designsystem.atomic.organisms_InfoListOrganism_Night_0_en",0,], ["libraries.matrix.ui.media_InitialsAvatarBitmapGenerator_Day_0_en","libraries.matrix.ui.media_InitialsAvatarBitmapGenerator_Night_0_en",0,], -["features.call.impl.ui_InvalidAudioDeviceDialog_Day_0_en","features.call.impl.ui_InvalidAudioDeviceDialog_Night_0_en",20420,], -["features.invitepeople.impl_InvitePeopleView_Day_0_en","features.invitepeople.impl_InvitePeopleView_Night_0_en",20420,], -["features.invitepeople.impl_InvitePeopleView_Day_1_en","features.invitepeople.impl_InvitePeopleView_Night_1_en",20420,], +["features.call.impl.ui_InvalidAudioDeviceDialog_Day_0_en","features.call.impl.ui_InvalidAudioDeviceDialog_Night_0_en",20466,], +["features.invitepeople.impl_InvitePeopleView_Day_0_en","features.invitepeople.impl_InvitePeopleView_Night_0_en",20466,], +["features.invitepeople.impl_InvitePeopleView_Day_1_en","features.invitepeople.impl_InvitePeopleView_Night_1_en",20466,], ["features.invitepeople.impl_InvitePeopleView_Day_2_en","features.invitepeople.impl_InvitePeopleView_Night_2_en",0,], ["features.invitepeople.impl_InvitePeopleView_Day_3_en","features.invitepeople.impl_InvitePeopleView_Night_3_en",0,], -["features.invitepeople.impl_InvitePeopleView_Day_4_en","features.invitepeople.impl_InvitePeopleView_Night_4_en",20420,], -["features.invitepeople.impl_InvitePeopleView_Day_5_en","features.invitepeople.impl_InvitePeopleView_Night_5_en",20420,], -["features.invitepeople.impl_InvitePeopleView_Day_6_en","features.invitepeople.impl_InvitePeopleView_Night_6_en",20420,], -["features.invitepeople.impl_InvitePeopleView_Day_7_en","features.invitepeople.impl_InvitePeopleView_Night_7_en",20420,], +["features.invitepeople.impl_InvitePeopleView_Day_4_en","features.invitepeople.impl_InvitePeopleView_Night_4_en",20466,], +["features.invitepeople.impl_InvitePeopleView_Day_5_en","features.invitepeople.impl_InvitePeopleView_Night_5_en",20466,], +["features.invitepeople.impl_InvitePeopleView_Day_6_en","features.invitepeople.impl_InvitePeopleView_Night_6_en",20466,], +["features.invitepeople.impl_InvitePeopleView_Day_7_en","features.invitepeople.impl_InvitePeopleView_Night_7_en",20466,], ["features.invitepeople.impl_InvitePeopleView_Day_8_en","features.invitepeople.impl_InvitePeopleView_Night_8_en",0,], -["features.invitepeople.impl_InvitePeopleView_Day_9_en","features.invitepeople.impl_InvitePeopleView_Night_9_en",20420,], -["libraries.matrix.ui.components_InviteSenderView_Day_0_en","libraries.matrix.ui.components_InviteSenderView_Night_0_en",20420,], -["features.startchat.impl.joinbyaddress_JoinRoomByAddressView_Day_0_en","features.startchat.impl.joinbyaddress_JoinRoomByAddressView_Night_0_en",20420,], -["features.startchat.impl.joinbyaddress_JoinRoomByAddressView_Day_1_en","features.startchat.impl.joinbyaddress_JoinRoomByAddressView_Night_1_en",20420,], -["features.startchat.impl.joinbyaddress_JoinRoomByAddressView_Day_2_en","features.startchat.impl.joinbyaddress_JoinRoomByAddressView_Night_2_en",20420,], -["features.startchat.impl.joinbyaddress_JoinRoomByAddressView_Day_3_en","features.startchat.impl.joinbyaddress_JoinRoomByAddressView_Night_3_en",20420,], -["features.startchat.impl.joinbyaddress_JoinRoomByAddressView_Day_4_en","features.startchat.impl.joinbyaddress_JoinRoomByAddressView_Night_4_en",20420,], -["features.startchat.impl.joinbyaddress_JoinRoomByAddressView_Day_5_en","features.startchat.impl.joinbyaddress_JoinRoomByAddressView_Night_5_en",20420,], +["features.invitepeople.impl_InvitePeopleView_Day_9_en","features.invitepeople.impl_InvitePeopleView_Night_9_en",20466,], +["libraries.matrix.ui.components_InviteSenderView_Day_0_en","libraries.matrix.ui.components_InviteSenderView_Night_0_en",20466,], +["features.startchat.impl.joinbyaddress_JoinRoomByAddressView_Day_0_en","features.startchat.impl.joinbyaddress_JoinRoomByAddressView_Night_0_en",20466,], +["features.startchat.impl.joinbyaddress_JoinRoomByAddressView_Day_1_en","features.startchat.impl.joinbyaddress_JoinRoomByAddressView_Night_1_en",20466,], +["features.startchat.impl.joinbyaddress_JoinRoomByAddressView_Day_2_en","features.startchat.impl.joinbyaddress_JoinRoomByAddressView_Night_2_en",20466,], +["features.startchat.impl.joinbyaddress_JoinRoomByAddressView_Day_3_en","features.startchat.impl.joinbyaddress_JoinRoomByAddressView_Night_3_en",20466,], +["features.startchat.impl.joinbyaddress_JoinRoomByAddressView_Day_4_en","features.startchat.impl.joinbyaddress_JoinRoomByAddressView_Night_4_en",20466,], +["features.startchat.impl.joinbyaddress_JoinRoomByAddressView_Day_5_en","features.startchat.impl.joinbyaddress_JoinRoomByAddressView_Night_5_en",20466,], ["features.joinroom.impl_JoinRoomView_Day_0_en","features.joinroom.impl_JoinRoomView_Night_0_en",0,], -["features.joinroom.impl_JoinRoomView_Day_10_en","features.joinroom.impl_JoinRoomView_Night_10_en",20420,], -["features.joinroom.impl_JoinRoomView_Day_11_en","features.joinroom.impl_JoinRoomView_Night_11_en",20420,], -["features.joinroom.impl_JoinRoomView_Day_12_en","features.joinroom.impl_JoinRoomView_Night_12_en",20420,], -["features.joinroom.impl_JoinRoomView_Day_13_en","features.joinroom.impl_JoinRoomView_Night_13_en",20420,], -["features.joinroom.impl_JoinRoomView_Day_14_en","features.joinroom.impl_JoinRoomView_Night_14_en",20420,], -["features.joinroom.impl_JoinRoomView_Day_15_en","features.joinroom.impl_JoinRoomView_Night_15_en",20420,], -["features.joinroom.impl_JoinRoomView_Day_16_en","features.joinroom.impl_JoinRoomView_Night_16_en",20420,], -["features.joinroom.impl_JoinRoomView_Day_1_en","features.joinroom.impl_JoinRoomView_Night_1_en",20420,], -["features.joinroom.impl_JoinRoomView_Day_2_en","features.joinroom.impl_JoinRoomView_Night_2_en",20420,], -["features.joinroom.impl_JoinRoomView_Day_3_en","features.joinroom.impl_JoinRoomView_Night_3_en",20420,], -["features.joinroom.impl_JoinRoomView_Day_4_en","features.joinroom.impl_JoinRoomView_Night_4_en",20420,], -["features.joinroom.impl_JoinRoomView_Day_5_en","features.joinroom.impl_JoinRoomView_Night_5_en",20420,], -["features.joinroom.impl_JoinRoomView_Day_6_en","features.joinroom.impl_JoinRoomView_Night_6_en",20420,], -["features.joinroom.impl_JoinRoomView_Day_7_en","features.joinroom.impl_JoinRoomView_Night_7_en",20420,], -["features.joinroom.impl_JoinRoomView_Day_8_en","features.joinroom.impl_JoinRoomView_Night_8_en",20420,], -["features.joinroom.impl_JoinRoomView_Day_9_en","features.joinroom.impl_JoinRoomView_Night_9_en",20420,], -["features.knockrequests.impl.banner_KnockRequestsBannerView_Day_0_en","features.knockrequests.impl.banner_KnockRequestsBannerView_Night_0_en",20420,], -["features.knockrequests.impl.banner_KnockRequestsBannerView_Day_1_en","features.knockrequests.impl.banner_KnockRequestsBannerView_Night_1_en",20420,], -["features.knockrequests.impl.banner_KnockRequestsBannerView_Day_2_en","features.knockrequests.impl.banner_KnockRequestsBannerView_Night_2_en",20420,], -["features.knockrequests.impl.banner_KnockRequestsBannerView_Day_3_en","features.knockrequests.impl.banner_KnockRequestsBannerView_Night_3_en",20420,], -["features.knockrequests.impl.banner_KnockRequestsBannerView_Day_4_en","features.knockrequests.impl.banner_KnockRequestsBannerView_Night_4_en",20420,], -["features.knockrequests.impl.banner_KnockRequestsBannerView_Day_5_en","features.knockrequests.impl.banner_KnockRequestsBannerView_Night_5_en",20420,], -["features.knockrequests.impl.banner_KnockRequestsBannerView_Day_6_en","features.knockrequests.impl.banner_KnockRequestsBannerView_Night_6_en",20420,], -["features.knockrequests.impl.list_KnockRequestsListView_Day_0_en","features.knockrequests.impl.list_KnockRequestsListView_Night_0_en",20420,], -["features.knockrequests.impl.list_KnockRequestsListView_Day_10_en","features.knockrequests.impl.list_KnockRequestsListView_Night_10_en",20420,], -["features.knockrequests.impl.list_KnockRequestsListView_Day_1_en","features.knockrequests.impl.list_KnockRequestsListView_Night_1_en",20420,], -["features.knockrequests.impl.list_KnockRequestsListView_Day_2_en","features.knockrequests.impl.list_KnockRequestsListView_Night_2_en",20420,], -["features.knockrequests.impl.list_KnockRequestsListView_Day_3_en","features.knockrequests.impl.list_KnockRequestsListView_Night_3_en",20420,], -["features.knockrequests.impl.list_KnockRequestsListView_Day_4_en","features.knockrequests.impl.list_KnockRequestsListView_Night_4_en",20420,], -["features.knockrequests.impl.list_KnockRequestsListView_Day_5_en","features.knockrequests.impl.list_KnockRequestsListView_Night_5_en",20420,], -["features.knockrequests.impl.list_KnockRequestsListView_Day_6_en","features.knockrequests.impl.list_KnockRequestsListView_Night_6_en",20420,], -["features.knockrequests.impl.list_KnockRequestsListView_Day_7_en","features.knockrequests.impl.list_KnockRequestsListView_Night_7_en",20420,], -["features.knockrequests.impl.list_KnockRequestsListView_Day_8_en","features.knockrequests.impl.list_KnockRequestsListView_Night_8_en",20420,], -["features.knockrequests.impl.list_KnockRequestsListView_Day_9_en","features.knockrequests.impl.list_KnockRequestsListView_Night_9_en",20420,], +["features.joinroom.impl_JoinRoomView_Day_10_en","features.joinroom.impl_JoinRoomView_Night_10_en",20466,], +["features.joinroom.impl_JoinRoomView_Day_11_en","features.joinroom.impl_JoinRoomView_Night_11_en",20466,], +["features.joinroom.impl_JoinRoomView_Day_12_en","features.joinroom.impl_JoinRoomView_Night_12_en",20466,], +["features.joinroom.impl_JoinRoomView_Day_13_en","features.joinroom.impl_JoinRoomView_Night_13_en",20466,], +["features.joinroom.impl_JoinRoomView_Day_14_en","features.joinroom.impl_JoinRoomView_Night_14_en",20466,], +["features.joinroom.impl_JoinRoomView_Day_15_en","features.joinroom.impl_JoinRoomView_Night_15_en",20466,], +["features.joinroom.impl_JoinRoomView_Day_16_en","features.joinroom.impl_JoinRoomView_Night_16_en",20466,], +["features.joinroom.impl_JoinRoomView_Day_1_en","features.joinroom.impl_JoinRoomView_Night_1_en",20466,], +["features.joinroom.impl_JoinRoomView_Day_2_en","features.joinroom.impl_JoinRoomView_Night_2_en",20466,], +["features.joinroom.impl_JoinRoomView_Day_3_en","features.joinroom.impl_JoinRoomView_Night_3_en",20466,], +["features.joinroom.impl_JoinRoomView_Day_4_en","features.joinroom.impl_JoinRoomView_Night_4_en",20466,], +["features.joinroom.impl_JoinRoomView_Day_5_en","features.joinroom.impl_JoinRoomView_Night_5_en",20466,], +["features.joinroom.impl_JoinRoomView_Day_6_en","features.joinroom.impl_JoinRoomView_Night_6_en",20466,], +["features.joinroom.impl_JoinRoomView_Day_7_en","features.joinroom.impl_JoinRoomView_Night_7_en",20466,], +["features.joinroom.impl_JoinRoomView_Day_8_en","features.joinroom.impl_JoinRoomView_Night_8_en",20466,], +["features.joinroom.impl_JoinRoomView_Day_9_en","features.joinroom.impl_JoinRoomView_Night_9_en",20466,], +["features.knockrequests.impl.banner_KnockRequestsBannerView_Day_0_en","features.knockrequests.impl.banner_KnockRequestsBannerView_Night_0_en",20466,], +["features.knockrequests.impl.banner_KnockRequestsBannerView_Day_1_en","features.knockrequests.impl.banner_KnockRequestsBannerView_Night_1_en",20466,], +["features.knockrequests.impl.banner_KnockRequestsBannerView_Day_2_en","features.knockrequests.impl.banner_KnockRequestsBannerView_Night_2_en",20466,], +["features.knockrequests.impl.banner_KnockRequestsBannerView_Day_3_en","features.knockrequests.impl.banner_KnockRequestsBannerView_Night_3_en",20466,], +["features.knockrequests.impl.banner_KnockRequestsBannerView_Day_4_en","features.knockrequests.impl.banner_KnockRequestsBannerView_Night_4_en",20466,], +["features.knockrequests.impl.banner_KnockRequestsBannerView_Day_5_en","features.knockrequests.impl.banner_KnockRequestsBannerView_Night_5_en",20466,], +["features.knockrequests.impl.banner_KnockRequestsBannerView_Day_6_en","features.knockrequests.impl.banner_KnockRequestsBannerView_Night_6_en",20466,], +["features.knockrequests.impl.list_KnockRequestsListView_Day_0_en","features.knockrequests.impl.list_KnockRequestsListView_Night_0_en",20466,], +["features.knockrequests.impl.list_KnockRequestsListView_Day_10_en","features.knockrequests.impl.list_KnockRequestsListView_Night_10_en",20466,], +["features.knockrequests.impl.list_KnockRequestsListView_Day_1_en","features.knockrequests.impl.list_KnockRequestsListView_Night_1_en",20466,], +["features.knockrequests.impl.list_KnockRequestsListView_Day_2_en","features.knockrequests.impl.list_KnockRequestsListView_Night_2_en",20466,], +["features.knockrequests.impl.list_KnockRequestsListView_Day_3_en","features.knockrequests.impl.list_KnockRequestsListView_Night_3_en",20466,], +["features.knockrequests.impl.list_KnockRequestsListView_Day_4_en","features.knockrequests.impl.list_KnockRequestsListView_Night_4_en",20466,], +["features.knockrequests.impl.list_KnockRequestsListView_Day_5_en","features.knockrequests.impl.list_KnockRequestsListView_Night_5_en",20466,], +["features.knockrequests.impl.list_KnockRequestsListView_Day_6_en","features.knockrequests.impl.list_KnockRequestsListView_Night_6_en",20466,], +["features.knockrequests.impl.list_KnockRequestsListView_Day_7_en","features.knockrequests.impl.list_KnockRequestsListView_Night_7_en",20466,], +["features.knockrequests.impl.list_KnockRequestsListView_Day_8_en","features.knockrequests.impl.list_KnockRequestsListView_Night_8_en",20466,], +["features.knockrequests.impl.list_KnockRequestsListView_Day_9_en","features.knockrequests.impl.list_KnockRequestsListView_Night_9_en",20466,], ["libraries.designsystem.components_LabelledCheckbox_Toggles_en","",0,], -["features.preferences.impl.labs_LabsView_Day_0_en","features.preferences.impl.labs_LabsView_Night_0_en",20420,], -["features.preferences.impl.labs_LabsView_Day_1_en","features.preferences.impl.labs_LabsView_Night_1_en",20420,], +["features.preferences.impl.labs_LabsView_Day_0_en","features.preferences.impl.labs_LabsView_Night_0_en",20466,], +["features.preferences.impl.labs_LabsView_Day_1_en","features.preferences.impl.labs_LabsView_Night_1_en",20466,], ["features.leaveroom.impl_LeaveRoomView_Day_0_en","features.leaveroom.impl_LeaveRoomView_Night_0_en",0,], -["features.leaveroom.impl_LeaveRoomView_Day_1_en","features.leaveroom.impl_LeaveRoomView_Night_1_en",20420,], -["features.leaveroom.impl_LeaveRoomView_Day_2_en","features.leaveroom.impl_LeaveRoomView_Night_2_en",20420,], -["features.leaveroom.impl_LeaveRoomView_Day_3_en","features.leaveroom.impl_LeaveRoomView_Night_3_en",20420,], -["features.leaveroom.impl_LeaveRoomView_Day_4_en","features.leaveroom.impl_LeaveRoomView_Night_4_en",20420,], -["features.leaveroom.impl_LeaveRoomView_Day_5_en","features.leaveroom.impl_LeaveRoomView_Night_5_en",20420,], -["features.leaveroom.impl_LeaveRoomView_Day_6_en","features.leaveroom.impl_LeaveRoomView_Night_6_en",20420,], -["features.leaveroom.impl_LeaveRoomView_Day_7_en","features.leaveroom.impl_LeaveRoomView_Night_7_en",20420,], -["features.space.impl.leave_LeaveSpaceView_Day_0_en","features.space.impl.leave_LeaveSpaceView_Night_0_en",20420,], -["features.space.impl.leave_LeaveSpaceView_Day_1_en","features.space.impl.leave_LeaveSpaceView_Night_1_en",20420,], -["features.space.impl.leave_LeaveSpaceView_Day_2_en","features.space.impl.leave_LeaveSpaceView_Night_2_en",20420,], -["features.space.impl.leave_LeaveSpaceView_Day_3_en","features.space.impl.leave_LeaveSpaceView_Night_3_en",20420,], -["features.space.impl.leave_LeaveSpaceView_Day_4_en","features.space.impl.leave_LeaveSpaceView_Night_4_en",20420,], -["features.space.impl.leave_LeaveSpaceView_Day_5_en","features.space.impl.leave_LeaveSpaceView_Night_5_en",20420,], -["features.space.impl.leave_LeaveSpaceView_Day_6_en","features.space.impl.leave_LeaveSpaceView_Night_6_en",20420,], -["features.space.impl.leave_LeaveSpaceView_Day_7_en","features.space.impl.leave_LeaveSpaceView_Night_7_en",20420,], -["features.space.impl.leave_LeaveSpaceView_Day_8_en","features.space.impl.leave_LeaveSpaceView_Night_8_en",20420,], -["features.space.impl.leave_LeaveSpaceView_Day_9_en","features.space.impl.leave_LeaveSpaceView_Night_9_en",20420,], +["features.leaveroom.impl_LeaveRoomView_Day_1_en","features.leaveroom.impl_LeaveRoomView_Night_1_en",20466,], +["features.leaveroom.impl_LeaveRoomView_Day_2_en","features.leaveroom.impl_LeaveRoomView_Night_2_en",20466,], +["features.leaveroom.impl_LeaveRoomView_Day_3_en","features.leaveroom.impl_LeaveRoomView_Night_3_en",20466,], +["features.leaveroom.impl_LeaveRoomView_Day_4_en","features.leaveroom.impl_LeaveRoomView_Night_4_en",20466,], +["features.leaveroom.impl_LeaveRoomView_Day_5_en","features.leaveroom.impl_LeaveRoomView_Night_5_en",20466,], +["features.leaveroom.impl_LeaveRoomView_Day_6_en","features.leaveroom.impl_LeaveRoomView_Night_6_en",20466,], +["features.leaveroom.impl_LeaveRoomView_Day_7_en","features.leaveroom.impl_LeaveRoomView_Night_7_en",20466,], +["features.space.impl.leave_LeaveSpaceView_Day_0_en","features.space.impl.leave_LeaveSpaceView_Night_0_en",20466,], +["features.space.impl.leave_LeaveSpaceView_Day_1_en","features.space.impl.leave_LeaveSpaceView_Night_1_en",20466,], +["features.space.impl.leave_LeaveSpaceView_Day_2_en","features.space.impl.leave_LeaveSpaceView_Night_2_en",20466,], +["features.space.impl.leave_LeaveSpaceView_Day_3_en","features.space.impl.leave_LeaveSpaceView_Night_3_en",20466,], +["features.space.impl.leave_LeaveSpaceView_Day_4_en","features.space.impl.leave_LeaveSpaceView_Night_4_en",20466,], +["features.space.impl.leave_LeaveSpaceView_Day_5_en","features.space.impl.leave_LeaveSpaceView_Night_5_en",20466,], +["features.space.impl.leave_LeaveSpaceView_Day_6_en","features.space.impl.leave_LeaveSpaceView_Night_6_en",20466,], +["features.space.impl.leave_LeaveSpaceView_Day_7_en","features.space.impl.leave_LeaveSpaceView_Night_7_en",20466,], +["features.space.impl.leave_LeaveSpaceView_Day_8_en","features.space.impl.leave_LeaveSpaceView_Night_8_en",20466,], +["features.space.impl.leave_LeaveSpaceView_Day_9_en","features.space.impl.leave_LeaveSpaceView_Night_9_en",20466,], ["libraries.designsystem.background_LightGradientBackground_Day_0_en","libraries.designsystem.background_LightGradientBackground_Night_0_en",0,], ["libraries.designsystem.theme.components_LinearProgressIndicator_Progress_Indicators_en","",0,], +["features.linknewdevice.impl.screens.root_LinkNewDeviceRootView_Day_0_en","features.linknewdevice.impl.screens.root_LinkNewDeviceRootView_Night_0_en",20466,], +["features.linknewdevice.impl.screens.root_LinkNewDeviceRootView_Day_1_en","features.linknewdevice.impl.screens.root_LinkNewDeviceRootView_Night_1_en",20467,], +["features.linknewdevice.impl.screens.root_LinkNewDeviceRootView_Day_2_en","features.linknewdevice.impl.screens.root_LinkNewDeviceRootView_Night_2_en",20466,], +["features.linknewdevice.impl.screens.root_LinkNewDeviceRootView_Day_3_en","features.linknewdevice.impl.screens.root_LinkNewDeviceRootView_Night_3_en",20466,], +["features.linknewdevice.impl.screens.root_LinkNewDeviceRootView_Day_4_en","features.linknewdevice.impl.screens.root_LinkNewDeviceRootView_Night_4_en",20467,], +["features.linknewdevice.impl.screens.root_LinkNewDeviceRootView_Day_5_en","features.linknewdevice.impl.screens.root_LinkNewDeviceRootView_Night_5_en",20466,], ["features.messages.impl.link_LinkView_Day_0_en","features.messages.impl.link_LinkView_Night_0_en",0,], -["features.messages.impl.link_LinkView_Day_1_en","features.messages.impl.link_LinkView_Night_1_en",20420,], +["features.messages.impl.link_LinkView_Day_1_en","features.messages.impl.link_LinkView_Night_1_en",20466,], ["libraries.designsystem.components.dialogs_ListDialogContent_Dialogs_en","",0,], ["libraries.designsystem.components.dialogs_ListDialog_Day_0_en","libraries.designsystem.components.dialogs_ListDialog_Night_0_en",0,], ["libraries.designsystem.theme.components_ListItemPrimaryActionWithIcon_List_item_-_Primary_action_&_Icon_List_items_en","",0,], @@ -553,38 +582,41 @@ export const screenshots = [ ["libraries.designsystem.theme.components_ListSupportingTextSmallPadding_List_supporting_text_-_small_padding_List_sections_en","",0,], ["libraries.textcomposer.components_LiveWaveformView_Day_0_en","libraries.textcomposer.components_LiveWaveformView_Night_0_en",0,], ["appnav.room.joined_LoadingRoomNodeView_Day_0_en","appnav.room.joined_LoadingRoomNodeView_Night_0_en",0,], -["appnav.room.joined_LoadingRoomNodeView_Day_1_en","appnav.room.joined_LoadingRoomNodeView_Night_1_en",20420,], -["features.lockscreen.impl.settings_LockScreenSettingsView_Day_0_en","features.lockscreen.impl.settings_LockScreenSettingsView_Night_0_en",20420,], -["features.lockscreen.impl.settings_LockScreenSettingsView_Day_1_en","features.lockscreen.impl.settings_LockScreenSettingsView_Night_1_en",20420,], -["features.lockscreen.impl.settings_LockScreenSettingsView_Day_2_en","features.lockscreen.impl.settings_LockScreenSettingsView_Night_2_en",20420,], +["appnav.room.joined_LoadingRoomNodeView_Day_1_en","appnav.room.joined_LoadingRoomNodeView_Night_1_en",20466,], +["features.lockscreen.impl.settings_LockScreenSettingsView_Day_0_en","features.lockscreen.impl.settings_LockScreenSettingsView_Night_0_en",20466,], +["features.lockscreen.impl.settings_LockScreenSettingsView_Day_1_en","features.lockscreen.impl.settings_LockScreenSettingsView_Night_1_en",20466,], +["features.lockscreen.impl.settings_LockScreenSettingsView_Day_2_en","features.lockscreen.impl.settings_LockScreenSettingsView_Night_2_en",20466,], ["appnav.loggedin_LoggedInView_Day_0_en","appnav.loggedin_LoggedInView_Night_0_en",0,], -["appnav.loggedin_LoggedInView_Day_1_en","appnav.loggedin_LoggedInView_Night_1_en",20420,], -["appnav.loggedin_LoggedInView_Day_2_en","appnav.loggedin_LoggedInView_Night_2_en",20420,], -["appnav.loggedin_LoggedInView_Day_3_en","appnav.loggedin_LoggedInView_Night_3_en",20420,], -["features.login.impl.login_LoginModeView_Day_0_en","features.login.impl.login_LoginModeView_Night_0_en",20420,], -["features.login.impl.login_LoginModeView_Day_1_en","features.login.impl.login_LoginModeView_Night_1_en",20420,], -["features.login.impl.login_LoginModeView_Day_2_en","features.login.impl.login_LoginModeView_Night_2_en",20420,], -["features.login.impl.login_LoginModeView_Day_3_en","features.login.impl.login_LoginModeView_Night_3_en",20420,], -["features.login.impl.login_LoginModeView_Day_4_en","features.login.impl.login_LoginModeView_Night_4_en",20420,], -["features.login.impl.login_LoginModeView_Day_5_en","features.login.impl.login_LoginModeView_Night_5_en",20420,], -["features.login.impl.login_LoginModeView_Day_6_en","features.login.impl.login_LoginModeView_Night_6_en",20420,], -["features.login.impl.screens.loginpassword_LoginPasswordView_Day_0_en","features.login.impl.screens.loginpassword_LoginPasswordView_Night_0_en",20420,], -["features.login.impl.screens.loginpassword_LoginPasswordView_Day_1_en","features.login.impl.screens.loginpassword_LoginPasswordView_Night_1_en",20420,], -["features.login.impl.screens.loginpassword_LoginPasswordView_Day_2_en","features.login.impl.screens.loginpassword_LoginPasswordView_Night_2_en",20420,], -["features.logout.impl_LogoutView_Day_0_en","features.logout.impl_LogoutView_Night_0_en",20420,], -["features.logout.impl_LogoutView_Day_10_en","features.logout.impl_LogoutView_Night_10_en",20420,], -["features.logout.impl_LogoutView_Day_11_en","features.logout.impl_LogoutView_Night_11_en",20420,], -["features.logout.impl_LogoutView_Day_1_en","features.logout.impl_LogoutView_Night_1_en",20420,], -["features.logout.impl_LogoutView_Day_2_en","features.logout.impl_LogoutView_Night_2_en",20420,], -["features.logout.impl_LogoutView_Day_3_en","features.logout.impl_LogoutView_Night_3_en",20420,], -["features.logout.impl_LogoutView_Day_4_en","features.logout.impl_LogoutView_Night_4_en",20420,], -["features.logout.impl_LogoutView_Day_5_en","features.logout.impl_LogoutView_Night_5_en",20420,], -["features.logout.impl_LogoutView_Day_6_en","features.logout.impl_LogoutView_Night_6_en",20420,], -["features.logout.impl_LogoutView_Day_7_en","features.logout.impl_LogoutView_Night_7_en",20420,], -["features.logout.impl_LogoutView_Day_8_en","features.logout.impl_LogoutView_Night_8_en",20420,], -["features.logout.impl_LogoutView_Day_9_en","features.logout.impl_LogoutView_Night_9_en",20420,], +["appnav.loggedin_LoggedInView_Day_1_en","appnav.loggedin_LoggedInView_Night_1_en",20466,], +["appnav.loggedin_LoggedInView_Day_2_en","appnav.loggedin_LoggedInView_Night_2_en",20466,], +["appnav.loggedin_LoggedInView_Day_3_en","appnav.loggedin_LoggedInView_Night_3_en",20466,], +["features.login.impl.login_LoginModeView_Day_0_en","features.login.impl.login_LoginModeView_Night_0_en",20466,], +["features.login.impl.login_LoginModeView_Day_1_en","features.login.impl.login_LoginModeView_Night_1_en",20466,], +["features.login.impl.login_LoginModeView_Day_2_en","features.login.impl.login_LoginModeView_Night_2_en",20466,], +["features.login.impl.login_LoginModeView_Day_3_en","features.login.impl.login_LoginModeView_Night_3_en",20466,], +["features.login.impl.login_LoginModeView_Day_4_en","features.login.impl.login_LoginModeView_Night_4_en",20466,], +["features.login.impl.login_LoginModeView_Day_5_en","features.login.impl.login_LoginModeView_Night_5_en",20466,], +["features.login.impl.login_LoginModeView_Day_6_en","features.login.impl.login_LoginModeView_Night_6_en",20466,], +["features.login.impl.screens.loginpassword_LoginPasswordView_Day_0_en","features.login.impl.screens.loginpassword_LoginPasswordView_Night_0_en",20466,], +["features.login.impl.screens.loginpassword_LoginPasswordView_Day_1_en","features.login.impl.screens.loginpassword_LoginPasswordView_Night_1_en",20466,], +["features.login.impl.screens.loginpassword_LoginPasswordView_Day_2_en","features.login.impl.screens.loginpassword_LoginPasswordView_Night_2_en",20466,], +["features.logout.impl_LogoutView_Day_0_en","features.logout.impl_LogoutView_Night_0_en",20466,], +["features.logout.impl_LogoutView_Day_10_en","features.logout.impl_LogoutView_Night_10_en",20466,], +["features.logout.impl_LogoutView_Day_11_en","features.logout.impl_LogoutView_Night_11_en",20466,], +["features.logout.impl_LogoutView_Day_1_en","features.logout.impl_LogoutView_Night_1_en",20466,], +["features.logout.impl_LogoutView_Day_2_en","features.logout.impl_LogoutView_Night_2_en",20466,], +["features.logout.impl_LogoutView_Day_3_en","features.logout.impl_LogoutView_Night_3_en",20466,], +["features.logout.impl_LogoutView_Day_4_en","features.logout.impl_LogoutView_Night_4_en",20466,], +["features.logout.impl_LogoutView_Day_5_en","features.logout.impl_LogoutView_Night_5_en",20466,], +["features.logout.impl_LogoutView_Day_6_en","features.logout.impl_LogoutView_Night_6_en",20466,], +["features.logout.impl_LogoutView_Day_7_en","features.logout.impl_LogoutView_Night_7_en",20466,], +["features.logout.impl_LogoutView_Day_8_en","features.logout.impl_LogoutView_Night_8_en",20466,], +["features.logout.impl_LogoutView_Day_9_en","features.logout.impl_LogoutView_Night_9_en",20466,], ["libraries.designsystem.components.button_MainActionButton_Buttons_en","",0,], -["libraries.textcomposer_MarkdownTextComposerEdit_Day_0_en","libraries.textcomposer_MarkdownTextComposerEdit_Night_0_en",20420,], +["features.securityandprivacy.impl.manageauthorizedspaces_ManageAuthorizedSpacesView_Day_0_en","features.securityandprivacy.impl.manageauthorizedspaces_ManageAuthorizedSpacesView_Night_0_en",20467,], +["features.securityandprivacy.impl.manageauthorizedspaces_ManageAuthorizedSpacesView_Day_1_en","features.securityandprivacy.impl.manageauthorizedspaces_ManageAuthorizedSpacesView_Night_1_en",20467,], +["features.securityandprivacy.impl.manageauthorizedspaces_ManageAuthorizedSpacesView_Day_2_en","features.securityandprivacy.impl.manageauthorizedspaces_ManageAuthorizedSpacesView_Night_2_en",20467,], +["libraries.textcomposer_MarkdownTextComposerEdit_Day_0_en","libraries.textcomposer_MarkdownTextComposerEdit_Night_0_en",20466,], ["libraries.textcomposer.components.markdown_MarkdownTextInput_Day_0_en","libraries.textcomposer.components.markdown_MarkdownTextInput_Night_0_en",0,], ["libraries.designsystem.atomic.atoms_MatrixBadgeAtomInfo_Day_0_en","libraries.designsystem.atomic.atoms_MatrixBadgeAtomInfo_Night_0_en",0,], ["libraries.designsystem.atomic.atoms_MatrixBadgeAtomNegative_Day_0_en","libraries.designsystem.atomic.atoms_MatrixBadgeAtomNegative_Night_0_en",0,], @@ -597,22 +629,22 @@ export const screenshots = [ ["libraries.matrix.ui.components_MatrixUserRow_Day_1_en","libraries.matrix.ui.components_MatrixUserRow_Night_1_en",0,], ["libraries.mediaviewer.impl.local.audio_MediaAudioView_Day_0_en","libraries.mediaviewer.impl.local.audio_MediaAudioView_Night_0_en",0,], ["libraries.mediaviewer.impl.local.audio_MediaAudioView_Day_1_en","libraries.mediaviewer.impl.local.audio_MediaAudioView_Night_1_en",0,], -["libraries.mediaviewer.impl.details_MediaDeleteConfirmationBottomSheet_Day_0_en","libraries.mediaviewer.impl.details_MediaDeleteConfirmationBottomSheet_Night_0_en",20420,], -["libraries.mediaviewer.impl.details_MediaDetailsBottomSheet_Day_0_en","libraries.mediaviewer.impl.details_MediaDetailsBottomSheet_Night_0_en",20420,], +["libraries.mediaviewer.impl.details_MediaDeleteConfirmationBottomSheet_Day_0_en","libraries.mediaviewer.impl.details_MediaDeleteConfirmationBottomSheet_Night_0_en",20466,], +["libraries.mediaviewer.impl.details_MediaDetailsBottomSheet_Day_0_en","libraries.mediaviewer.impl.details_MediaDetailsBottomSheet_Night_0_en",20466,], ["libraries.mediaviewer.impl.local.file_MediaFileView_Day_0_en","libraries.mediaviewer.impl.local.file_MediaFileView_Night_0_en",0,], -["libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_0_en","libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_0_en",20420,], -["libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_10_en","libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_10_en",20420,], -["libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_11_en","libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_11_en",20420,], -["libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_12_en","libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_12_en",20420,], -["libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_1_en","libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_1_en",20420,], -["libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_2_en","libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_2_en",20420,], -["libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_3_en","libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_3_en",20420,], -["libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_4_en","libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_4_en",20420,], -["libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_5_en","libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_5_en",20420,], -["libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_6_en","libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_6_en",20420,], -["libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_7_en","libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_7_en",20420,], -["libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_8_en","libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_8_en",20420,], -["libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_9_en","libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_9_en",20420,], +["libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_0_en","libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_0_en",20466,], +["libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_10_en","libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_10_en",20466,], +["libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_11_en","libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_11_en",20466,], +["libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_12_en","libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_12_en",20466,], +["libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_1_en","libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_1_en",20466,], +["libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_2_en","libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_2_en",20466,], +["libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_3_en","libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_3_en",20466,], +["libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_4_en","libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_4_en",20466,], +["libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_5_en","libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_5_en",20466,], +["libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_6_en","libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_6_en",20466,], +["libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_7_en","libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_7_en",20466,], +["libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_8_en","libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_8_en",20466,], +["libraries.mediaviewer.impl.gallery_MediaGalleryView_Day_9_en","libraries.mediaviewer.impl.gallery_MediaGalleryView_Night_9_en",20466,], ["libraries.mediaviewer.impl.local.image_MediaImageView_Day_0_en","libraries.mediaviewer.impl.local.image_MediaImageView_Night_0_en",0,], ["libraries.mediaviewer.impl.local.player_MediaPlayerControllerView_Day_0_en","libraries.mediaviewer.impl.local.player_MediaPlayerControllerView_Night_0_en",0,], ["libraries.mediaviewer.impl.local.player_MediaPlayerControllerView_Day_1_en","libraries.mediaviewer.impl.local.player_MediaPlayerControllerView_Night_1_en",0,], @@ -620,14 +652,14 @@ export const screenshots = [ ["libraries.mediaviewer.impl.local.video_MediaVideoView_Day_0_en","libraries.mediaviewer.impl.local.video_MediaVideoView_Night_0_en",0,], ["libraries.mediaviewer.impl.viewer_MediaViewerView_0_en","",0,], ["libraries.mediaviewer.impl.viewer_MediaViewerView_10_en","",0,], -["libraries.mediaviewer.impl.viewer_MediaViewerView_11_en","",20420,], -["libraries.mediaviewer.impl.viewer_MediaViewerView_12_en","",20420,], +["libraries.mediaviewer.impl.viewer_MediaViewerView_11_en","",20466,], +["libraries.mediaviewer.impl.viewer_MediaViewerView_12_en","",20466,], ["libraries.mediaviewer.impl.viewer_MediaViewerView_13_en","",0,], -["libraries.mediaviewer.impl.viewer_MediaViewerView_14_en","",20420,], +["libraries.mediaviewer.impl.viewer_MediaViewerView_14_en","",20466,], ["libraries.mediaviewer.impl.viewer_MediaViewerView_15_en","",0,], ["libraries.mediaviewer.impl.viewer_MediaViewerView_16_en","",0,], ["libraries.mediaviewer.impl.viewer_MediaViewerView_1_en","",0,], -["libraries.mediaviewer.impl.viewer_MediaViewerView_2_en","",20420,], +["libraries.mediaviewer.impl.viewer_MediaViewerView_2_en","",20466,], ["libraries.mediaviewer.impl.viewer_MediaViewerView_3_en","",0,], ["libraries.mediaviewer.impl.viewer_MediaViewerView_4_en","",0,], ["libraries.mediaviewer.impl.viewer_MediaViewerView_5_en","",0,], @@ -641,7 +673,7 @@ export const screenshots = [ ["libraries.textcomposer.mentions_MentionSpanTheme_Day_0_en","libraries.textcomposer.mentions_MentionSpanTheme_Night_0_en",0,], ["libraries.designsystem.theme.components.previews_Menu_Menus_en","",0,], ["features.messages.impl.messagecomposer_MessageComposerViewVoice_Day_0_en","features.messages.impl.messagecomposer_MessageComposerViewVoice_Night_0_en",0,], -["features.messages.impl.messagecomposer_MessageComposerView_Day_0_en","features.messages.impl.messagecomposer_MessageComposerView_Night_0_en",20420,], +["features.messages.impl.messagecomposer_MessageComposerView_Day_0_en","features.messages.impl.messagecomposer_MessageComposerView_Night_0_en",20466,], ["features.messages.impl.timeline.components_MessageEventBubble_Day_0_en","features.messages.impl.timeline.components_MessageEventBubble_Night_0_en",0,], ["features.messages.impl.timeline.components_MessageEventBubble_Day_1_en","features.messages.impl.timeline.components_MessageEventBubble_Night_1_en",0,], ["features.messages.impl.timeline.components_MessageEventBubble_Day_2_en","features.messages.impl.timeline.components_MessageEventBubble_Night_2_en",0,], @@ -650,7 +682,7 @@ export const screenshots = [ ["features.messages.impl.timeline.components_MessageEventBubble_Day_5_en","features.messages.impl.timeline.components_MessageEventBubble_Night_5_en",0,], ["features.messages.impl.timeline.components_MessageEventBubble_Day_6_en","features.messages.impl.timeline.components_MessageEventBubble_Night_6_en",0,], ["features.messages.impl.timeline.components_MessageEventBubble_Day_7_en","features.messages.impl.timeline.components_MessageEventBubble_Night_7_en",0,], -["features.messages.impl.timeline.components_MessageShieldView_Day_0_en","features.messages.impl.timeline.components_MessageShieldView_Night_0_en",20420,], +["features.messages.impl.timeline.components_MessageShieldView_Day_0_en","features.messages.impl.timeline.components_MessageShieldView_Night_0_en",20466,], ["features.messages.impl.timeline.components_MessageStateEventContainer_Day_0_en","features.messages.impl.timeline.components_MessageStateEventContainer_Night_0_en",0,], ["features.messages.impl.timeline.components_MessagesReactionButtonAdd_Day_0_en","features.messages.impl.timeline.components_MessagesReactionButtonAdd_Night_0_en",0,], ["features.messages.impl.timeline.components_MessagesReactionButtonExtra_Day_0_en","features.messages.impl.timeline.components_MessagesReactionButtonExtra_Night_0_en",0,], @@ -658,137 +690,144 @@ export const screenshots = [ ["features.messages.impl.timeline.components_MessagesReactionButton_Day_1_en","features.messages.impl.timeline.components_MessagesReactionButton_Night_1_en",0,], ["features.messages.impl.timeline.components_MessagesReactionButton_Day_2_en","features.messages.impl.timeline.components_MessagesReactionButton_Night_2_en",0,], ["features.messages.impl.timeline.components_MessagesReactionButton_Day_3_en","features.messages.impl.timeline.components_MessagesReactionButton_Night_3_en",0,], -["features.messages.impl.topbars_MessagesViewTopBar_Day_0_en","features.messages.impl.topbars_MessagesViewTopBar_Night_0_en",20420,], -["features.messages.impl.crypto.identity_MessagesViewWithIdentityChange_Day_0_en","features.messages.impl.crypto.identity_MessagesViewWithIdentityChange_Night_0_en",20420,], -["features.messages.impl.crypto.identity_MessagesViewWithIdentityChange_Day_1_en","features.messages.impl.crypto.identity_MessagesViewWithIdentityChange_Night_1_en",20420,], -["features.messages.impl.crypto.identity_MessagesViewWithIdentityChange_Day_2_en","features.messages.impl.crypto.identity_MessagesViewWithIdentityChange_Night_2_en",20420,], -["features.messages.impl_MessagesView_Day_0_en","features.messages.impl_MessagesView_Night_0_en",20420,], -["features.messages.impl_MessagesView_Day_1_en","features.messages.impl_MessagesView_Night_1_en",20420,], -["features.messages.impl_MessagesView_Day_2_en","features.messages.impl_MessagesView_Night_2_en",20420,], -["features.messages.impl_MessagesView_Day_3_en","features.messages.impl_MessagesView_Night_3_en",20420,], -["features.messages.impl_MessagesView_Day_4_en","features.messages.impl_MessagesView_Night_4_en",20420,], -["features.messages.impl_MessagesView_Day_5_en","features.messages.impl_MessagesView_Night_5_en",20420,], -["features.messages.impl_MessagesView_Day_6_en","features.messages.impl_MessagesView_Night_6_en",20420,], -["features.messages.impl_MessagesView_Day_7_en","features.messages.impl_MessagesView_Night_7_en",20420,], -["features.messages.impl_MessagesView_Day_8_en","features.messages.impl_MessagesView_Night_8_en",20420,], -["features.messages.impl_MessagesView_Day_9_en","features.messages.impl_MessagesView_Night_9_en",20420,], +["features.messages.impl_MessagesViewA11y_en","",0,], +["features.messages.impl.topbars_MessagesViewTopBar_Day_0_en","features.messages.impl.topbars_MessagesViewTopBar_Night_0_en",20466,], +["features.messages.impl.crypto.historyvisible_MessagesViewWithHistoryVisible_Day_0_en","features.messages.impl.crypto.historyvisible_MessagesViewWithHistoryVisible_Night_0_en",20466,], +["features.messages.impl.crypto.identity_MessagesViewWithIdentityChange_Day_0_en","features.messages.impl.crypto.identity_MessagesViewWithIdentityChange_Night_0_en",20466,], +["features.messages.impl.crypto.identity_MessagesViewWithIdentityChange_Day_1_en","features.messages.impl.crypto.identity_MessagesViewWithIdentityChange_Night_1_en",20466,], +["features.messages.impl.crypto.identity_MessagesViewWithIdentityChange_Day_2_en","features.messages.impl.crypto.identity_MessagesViewWithIdentityChange_Night_2_en",20466,], +["features.messages.impl_MessagesView_Day_0_en","features.messages.impl_MessagesView_Night_0_en",20466,], +["features.messages.impl_MessagesView_Day_10_en","features.messages.impl_MessagesView_Night_10_en",20466,], +["features.messages.impl_MessagesView_Day_11_en","features.messages.impl_MessagesView_Night_11_en",20466,], +["features.messages.impl_MessagesView_Day_12_en","features.messages.impl_MessagesView_Night_12_en",20466,], +["features.messages.impl_MessagesView_Day_1_en","features.messages.impl_MessagesView_Night_1_en",20466,], +["features.messages.impl_MessagesView_Day_2_en","features.messages.impl_MessagesView_Night_2_en",20466,], +["features.messages.impl_MessagesView_Day_3_en","features.messages.impl_MessagesView_Night_3_en",20466,], +["features.messages.impl_MessagesView_Day_4_en","features.messages.impl_MessagesView_Night_4_en",20466,], +["features.messages.impl_MessagesView_Day_5_en","features.messages.impl_MessagesView_Night_5_en",20466,], +["features.messages.impl_MessagesView_Day_6_en","features.messages.impl_MessagesView_Night_6_en",20466,], +["features.messages.impl_MessagesView_Day_7_en","features.messages.impl_MessagesView_Night_7_en",20466,], +["features.messages.impl_MessagesView_Day_8_en","features.messages.impl_MessagesView_Night_8_en",20466,], +["features.messages.impl_MessagesView_Day_9_en","features.messages.impl_MessagesView_Night_9_en",20466,], ["features.migration.impl_MigrationView_Day_0_en","features.migration.impl_MigrationView_Night_0_en",0,], -["features.migration.impl_MigrationView_Day_1_en","features.migration.impl_MigrationView_Night_1_en",20420,], +["features.migration.impl_MigrationView_Day_1_en","features.migration.impl_MigrationView_Night_1_en",20466,], ["libraries.designsystem.theme.components_ModalBottomSheetDark_Bottom_Sheets_en","",0,], ["libraries.designsystem.theme.components_ModalBottomSheetLight_Bottom_Sheets_en","",0,], ["appicon.element_MonochromeIcon_en","",0,], -["features.preferences.impl.root_MultiAccountSection_Day_0_en","features.preferences.impl.root_MultiAccountSection_Night_0_en",20420,], +["features.preferences.impl.root_MultiAccountSection_Day_0_en","features.preferences.impl.root_MultiAccountSection_Night_0_en",0,], ["libraries.designsystem.components.dialogs_MultipleSelectionDialogContent_Dialogs_en","",0,], ["libraries.designsystem.components.dialogs_MultipleSelectionDialog_Day_0_en","libraries.designsystem.components.dialogs_MultipleSelectionDialog_Night_0_en",0,], ["libraries.designsystem.components.list_MutipleSelectionListItemSelectedTrailingContent_Multiple_selection_List_item_-_selection_in_trailing_content_List_items_en","",0,], ["libraries.designsystem.components.list_MutipleSelectionListItemSelected_Multiple_selection_List_item_-_selection_in_supporting_text_List_items_en","",0,], ["libraries.designsystem.components.list_MutipleSelectionListItem_Multiple_selection_List_item_-_no_selection_List_items_en","",0,], ["libraries.designsystem.theme.components_NavigationBar_App_Bars_en","",0,], -["features.home.impl.components_NewNotificationSoundBanner_Day_0_en","features.home.impl.components_NewNotificationSoundBanner_Night_0_en",20420,], -["features.preferences.impl.notifications_NotificationSettingsView_Day_0_en","features.preferences.impl.notifications_NotificationSettingsView_Night_0_en",20420,], -["features.preferences.impl.notifications_NotificationSettingsView_Day_10_en","features.preferences.impl.notifications_NotificationSettingsView_Night_10_en",20420,], -["features.preferences.impl.notifications_NotificationSettingsView_Day_11_en","features.preferences.impl.notifications_NotificationSettingsView_Night_11_en",20420,], -["features.preferences.impl.notifications_NotificationSettingsView_Day_12_en","features.preferences.impl.notifications_NotificationSettingsView_Night_12_en",20420,], -["features.preferences.impl.notifications_NotificationSettingsView_Day_13_en","features.preferences.impl.notifications_NotificationSettingsView_Night_13_en",20420,], -["features.preferences.impl.notifications_NotificationSettingsView_Day_1_en","features.preferences.impl.notifications_NotificationSettingsView_Night_1_en",20420,], -["features.preferences.impl.notifications_NotificationSettingsView_Day_2_en","features.preferences.impl.notifications_NotificationSettingsView_Night_2_en",20420,], -["features.preferences.impl.notifications_NotificationSettingsView_Day_3_en","features.preferences.impl.notifications_NotificationSettingsView_Night_3_en",20420,], -["features.preferences.impl.notifications_NotificationSettingsView_Day_4_en","features.preferences.impl.notifications_NotificationSettingsView_Night_4_en",20420,], -["features.preferences.impl.notifications_NotificationSettingsView_Day_5_en","features.preferences.impl.notifications_NotificationSettingsView_Night_5_en",20420,], -["features.preferences.impl.notifications_NotificationSettingsView_Day_6_en","features.preferences.impl.notifications_NotificationSettingsView_Night_6_en",20420,], -["features.preferences.impl.notifications_NotificationSettingsView_Day_7_en","features.preferences.impl.notifications_NotificationSettingsView_Night_7_en",20420,], -["features.preferences.impl.notifications_NotificationSettingsView_Day_8_en","features.preferences.impl.notifications_NotificationSettingsView_Night_8_en",20420,], -["features.preferences.impl.notifications_NotificationSettingsView_Day_9_en","features.preferences.impl.notifications_NotificationSettingsView_Night_9_en",20420,], -["features.ftue.impl.notifications_NotificationsOptInView_Day_0_en","features.ftue.impl.notifications_NotificationsOptInView_Night_0_en",20420,], +["features.home.impl.components_NewNotificationSoundBanner_Day_0_en","features.home.impl.components_NewNotificationSoundBanner_Night_0_en",20466,], +["features.preferences.impl.notifications_NotificationSettingsView_Day_0_en","features.preferences.impl.notifications_NotificationSettingsView_Night_0_en",20466,], +["features.preferences.impl.notifications_NotificationSettingsView_Day_10_en","features.preferences.impl.notifications_NotificationSettingsView_Night_10_en",20466,], +["features.preferences.impl.notifications_NotificationSettingsView_Day_11_en","features.preferences.impl.notifications_NotificationSettingsView_Night_11_en",20466,], +["features.preferences.impl.notifications_NotificationSettingsView_Day_12_en","features.preferences.impl.notifications_NotificationSettingsView_Night_12_en",20466,], +["features.preferences.impl.notifications_NotificationSettingsView_Day_13_en","features.preferences.impl.notifications_NotificationSettingsView_Night_13_en",20466,], +["features.preferences.impl.notifications_NotificationSettingsView_Day_1_en","features.preferences.impl.notifications_NotificationSettingsView_Night_1_en",20466,], +["features.preferences.impl.notifications_NotificationSettingsView_Day_2_en","features.preferences.impl.notifications_NotificationSettingsView_Night_2_en",20466,], +["features.preferences.impl.notifications_NotificationSettingsView_Day_3_en","features.preferences.impl.notifications_NotificationSettingsView_Night_3_en",20466,], +["features.preferences.impl.notifications_NotificationSettingsView_Day_4_en","features.preferences.impl.notifications_NotificationSettingsView_Night_4_en",20466,], +["features.preferences.impl.notifications_NotificationSettingsView_Day_5_en","features.preferences.impl.notifications_NotificationSettingsView_Night_5_en",20466,], +["features.preferences.impl.notifications_NotificationSettingsView_Day_6_en","features.preferences.impl.notifications_NotificationSettingsView_Night_6_en",20466,], +["features.preferences.impl.notifications_NotificationSettingsView_Day_7_en","features.preferences.impl.notifications_NotificationSettingsView_Night_7_en",20466,], +["features.preferences.impl.notifications_NotificationSettingsView_Day_8_en","features.preferences.impl.notifications_NotificationSettingsView_Night_8_en",20466,], +["features.preferences.impl.notifications_NotificationSettingsView_Day_9_en","features.preferences.impl.notifications_NotificationSettingsView_Night_9_en",20466,], +["features.ftue.impl.notifications_NotificationsOptInView_Day_0_en","features.ftue.impl.notifications_NotificationsOptInView_Night_0_en",20466,], +["features.linknewdevice.impl.screens.number.component_NumberTextField_Day_0_en","features.linknewdevice.impl.screens.number.component_NumberTextField_Night_0_en",0,], ["libraries.designsystem.atomic.pages_OnBoardingPage_Day_0_en","libraries.designsystem.atomic.pages_OnBoardingPage_Night_0_en",0,], -["features.login.impl.screens.onboarding_OnBoardingView_Day_0_en","features.login.impl.screens.onboarding_OnBoardingView_Night_0_en",20420,], -["features.login.impl.screens.onboarding_OnBoardingView_Day_1_en","features.login.impl.screens.onboarding_OnBoardingView_Night_1_en",20420,], -["features.login.impl.screens.onboarding_OnBoardingView_Day_2_en","features.login.impl.screens.onboarding_OnBoardingView_Night_2_en",20420,], -["features.login.impl.screens.onboarding_OnBoardingView_Day_3_en","features.login.impl.screens.onboarding_OnBoardingView_Night_3_en",20420,], -["features.login.impl.screens.onboarding_OnBoardingView_Day_4_en","features.login.impl.screens.onboarding_OnBoardingView_Night_4_en",20420,], -["features.login.impl.screens.onboarding_OnBoardingView_Day_5_en","features.login.impl.screens.onboarding_OnBoardingView_Night_5_en",20420,], -["features.login.impl.screens.onboarding_OnBoardingView_Day_6_en","features.login.impl.screens.onboarding_OnBoardingView_Night_6_en",20420,], -["features.login.impl.screens.onboarding_OnBoardingView_Day_7_en","features.login.impl.screens.onboarding_OnBoardingView_Night_7_en",20420,], +["features.login.impl.screens.onboarding_OnBoardingView_Day_0_en","features.login.impl.screens.onboarding_OnBoardingView_Night_0_en",20466,], +["features.login.impl.screens.onboarding_OnBoardingView_Day_1_en","features.login.impl.screens.onboarding_OnBoardingView_Night_1_en",20466,], +["features.login.impl.screens.onboarding_OnBoardingView_Day_2_en","features.login.impl.screens.onboarding_OnBoardingView_Night_2_en",20466,], +["features.login.impl.screens.onboarding_OnBoardingView_Day_3_en","features.login.impl.screens.onboarding_OnBoardingView_Night_3_en",20466,], +["features.login.impl.screens.onboarding_OnBoardingView_Day_4_en","features.login.impl.screens.onboarding_OnBoardingView_Night_4_en",20466,], +["features.login.impl.screens.onboarding_OnBoardingView_Day_5_en","features.login.impl.screens.onboarding_OnBoardingView_Night_5_en",20466,], +["features.login.impl.screens.onboarding_OnBoardingView_Day_6_en","features.login.impl.screens.onboarding_OnBoardingView_Night_6_en",20466,], +["features.login.impl.screens.onboarding_OnBoardingView_Day_7_en","features.login.impl.screens.onboarding_OnBoardingView_Night_7_en",20466,], ["libraries.designsystem.background_OnboardingBackground_Day_0_en","libraries.designsystem.background_OnboardingBackground_Night_0_en",0,], -["libraries.matrix.ui.components_OrganizationHeader_Day_0_en","libraries.matrix.ui.components_OrganizationHeader_Night_0_en",20420,], -["features.verifysession.impl.outgoing_OutgoingVerificationView_Day_0_en","features.verifysession.impl.outgoing_OutgoingVerificationView_Night_0_en",20420,], -["features.verifysession.impl.outgoing_OutgoingVerificationView_Day_10_en","features.verifysession.impl.outgoing_OutgoingVerificationView_Night_10_en",20420,], -["features.verifysession.impl.outgoing_OutgoingVerificationView_Day_11_en","features.verifysession.impl.outgoing_OutgoingVerificationView_Night_11_en",20420,], +["libraries.matrix.ui.components_OrganizationHeader_Day_0_en","libraries.matrix.ui.components_OrganizationHeader_Night_0_en",20466,], +["features.verifysession.impl.outgoing_OutgoingVerificationView_Day_0_en","features.verifysession.impl.outgoing_OutgoingVerificationView_Night_0_en",20466,], +["features.verifysession.impl.outgoing_OutgoingVerificationView_Day_10_en","features.verifysession.impl.outgoing_OutgoingVerificationView_Night_10_en",20466,], +["features.verifysession.impl.outgoing_OutgoingVerificationView_Day_11_en","features.verifysession.impl.outgoing_OutgoingVerificationView_Night_11_en",20466,], ["features.verifysession.impl.outgoing_OutgoingVerificationView_Day_12_en","features.verifysession.impl.outgoing_OutgoingVerificationView_Night_12_en",0,], ["features.verifysession.impl.outgoing_OutgoingVerificationView_Day_13_en","features.verifysession.impl.outgoing_OutgoingVerificationView_Night_13_en",0,], -["features.verifysession.impl.outgoing_OutgoingVerificationView_Day_1_en","features.verifysession.impl.outgoing_OutgoingVerificationView_Night_1_en",20420,], -["features.verifysession.impl.outgoing_OutgoingVerificationView_Day_2_en","features.verifysession.impl.outgoing_OutgoingVerificationView_Night_2_en",20420,], -["features.verifysession.impl.outgoing_OutgoingVerificationView_Day_3_en","features.verifysession.impl.outgoing_OutgoingVerificationView_Night_3_en",20420,], -["features.verifysession.impl.outgoing_OutgoingVerificationView_Day_4_en","features.verifysession.impl.outgoing_OutgoingVerificationView_Night_4_en",20420,], -["features.verifysession.impl.outgoing_OutgoingVerificationView_Day_5_en","features.verifysession.impl.outgoing_OutgoingVerificationView_Night_5_en",20420,], -["features.verifysession.impl.outgoing_OutgoingVerificationView_Day_6_en","features.verifysession.impl.outgoing_OutgoingVerificationView_Night_6_en",20420,], -["features.verifysession.impl.outgoing_OutgoingVerificationView_Day_7_en","features.verifysession.impl.outgoing_OutgoingVerificationView_Night_7_en",20420,], -["features.verifysession.impl.outgoing_OutgoingVerificationView_Day_8_en","features.verifysession.impl.outgoing_OutgoingVerificationView_Night_8_en",20420,], -["features.verifysession.impl.outgoing_OutgoingVerificationView_Day_9_en","features.verifysession.impl.outgoing_OutgoingVerificationView_Night_9_en",20420,], +["features.verifysession.impl.outgoing_OutgoingVerificationView_Day_1_en","features.verifysession.impl.outgoing_OutgoingVerificationView_Night_1_en",20466,], +["features.verifysession.impl.outgoing_OutgoingVerificationView_Day_2_en","features.verifysession.impl.outgoing_OutgoingVerificationView_Night_2_en",20466,], +["features.verifysession.impl.outgoing_OutgoingVerificationView_Day_3_en","features.verifysession.impl.outgoing_OutgoingVerificationView_Night_3_en",20466,], +["features.verifysession.impl.outgoing_OutgoingVerificationView_Day_4_en","features.verifysession.impl.outgoing_OutgoingVerificationView_Night_4_en",20466,], +["features.verifysession.impl.outgoing_OutgoingVerificationView_Day_5_en","features.verifysession.impl.outgoing_OutgoingVerificationView_Night_5_en",20466,], +["features.verifysession.impl.outgoing_OutgoingVerificationView_Day_6_en","features.verifysession.impl.outgoing_OutgoingVerificationView_Night_6_en",20466,], +["features.verifysession.impl.outgoing_OutgoingVerificationView_Day_7_en","features.verifysession.impl.outgoing_OutgoingVerificationView_Night_7_en",20466,], +["features.verifysession.impl.outgoing_OutgoingVerificationView_Day_8_en","features.verifysession.impl.outgoing_OutgoingVerificationView_Night_8_en",20466,], +["features.verifysession.impl.outgoing_OutgoingVerificationView_Day_9_en","features.verifysession.impl.outgoing_OutgoingVerificationView_Night_9_en",20466,], ["libraries.designsystem.theme.components_OutlinedButtonLargeLowPadding_Buttons_en","",0,], ["libraries.designsystem.theme.components_OutlinedButtonLarge_Buttons_en","",0,], ["libraries.designsystem.theme.components_OutlinedButtonMediumLowPadding_Buttons_en","",0,], ["libraries.designsystem.theme.components_OutlinedButtonMedium_Buttons_en","",0,], ["libraries.designsystem.theme.components_OutlinedButtonSmall_Buttons_en","",0,], -["libraries.mediaviewer.impl.local.pdf_PdfPagesErrorView_Day_0_en","libraries.mediaviewer.impl.local.pdf_PdfPagesErrorView_Night_0_en",20420,], -["features.rolesandpermissions.impl.roles_PendingMemberRowWithLongName_Day_0_en","features.rolesandpermissions.impl.roles_PendingMemberRowWithLongName_Night_0_en",20420,], -["libraries.permissions.api_PermissionsView_Day_0_en","libraries.permissions.api_PermissionsView_Night_0_en",20420,], -["libraries.permissions.api_PermissionsView_Day_1_en","libraries.permissions.api_PermissionsView_Night_1_en",20420,], -["libraries.permissions.api_PermissionsView_Day_2_en","libraries.permissions.api_PermissionsView_Night_2_en",20420,], -["libraries.permissions.api_PermissionsView_Day_3_en","libraries.permissions.api_PermissionsView_Night_3_en",20420,], +["libraries.mediaviewer.impl.local.pdf_PdfPagesErrorView_Day_0_en","libraries.mediaviewer.impl.local.pdf_PdfPagesErrorView_Night_0_en",20466,], +["features.rolesandpermissions.impl.roles_PendingMemberRowWithLongName_Day_0_en","features.rolesandpermissions.impl.roles_PendingMemberRowWithLongName_Night_0_en",20467,], +["libraries.permissions.api_PermissionsView_Day_0_en","libraries.permissions.api_PermissionsView_Night_0_en",20466,], +["libraries.permissions.api_PermissionsView_Day_1_en","libraries.permissions.api_PermissionsView_Night_1_en",20466,], +["libraries.permissions.api_PermissionsView_Day_2_en","libraries.permissions.api_PermissionsView_Night_2_en",20466,], +["libraries.permissions.api_PermissionsView_Day_3_en","libraries.permissions.api_PermissionsView_Night_3_en",20466,], ["features.lockscreen.impl.components_PinEntryTextField_Day_0_en","features.lockscreen.impl.components_PinEntryTextField_Night_0_en",0,], ["libraries.designsystem.components_PinIcon_Day_0_en","libraries.designsystem.components_PinIcon_Night_0_en",0,], ["features.lockscreen.impl.unlock.keypad_PinKeypad_Day_0_en","features.lockscreen.impl.unlock.keypad_PinKeypad_Night_0_en",0,], -["features.lockscreen.impl.unlock_PinUnlockViewInApp_Day_0_en","features.lockscreen.impl.unlock_PinUnlockViewInApp_Night_0_en",20420,], -["features.lockscreen.impl.unlock_PinUnlockViewInApp_Day_1_en","features.lockscreen.impl.unlock_PinUnlockViewInApp_Night_1_en",20420,], -["features.lockscreen.impl.unlock_PinUnlockViewInApp_Day_2_en","features.lockscreen.impl.unlock_PinUnlockViewInApp_Night_2_en",20420,], -["features.lockscreen.impl.unlock_PinUnlockViewInApp_Day_3_en","features.lockscreen.impl.unlock_PinUnlockViewInApp_Night_3_en",20420,], -["features.lockscreen.impl.unlock_PinUnlockViewInApp_Day_4_en","features.lockscreen.impl.unlock_PinUnlockViewInApp_Night_4_en",20420,], -["features.lockscreen.impl.unlock_PinUnlockViewInApp_Day_5_en","features.lockscreen.impl.unlock_PinUnlockViewInApp_Night_5_en",20420,], -["features.lockscreen.impl.unlock_PinUnlockViewInApp_Day_6_en","features.lockscreen.impl.unlock_PinUnlockViewInApp_Night_6_en",20420,], -["features.lockscreen.impl.unlock_PinUnlockViewInApp_Day_7_en","features.lockscreen.impl.unlock_PinUnlockViewInApp_Night_7_en",20420,], -["features.lockscreen.impl.unlock_PinUnlockView_Day_0_en","features.lockscreen.impl.unlock_PinUnlockView_Night_0_en",20420,], -["features.lockscreen.impl.unlock_PinUnlockView_Day_1_en","features.lockscreen.impl.unlock_PinUnlockView_Night_1_en",20420,], -["features.lockscreen.impl.unlock_PinUnlockView_Day_2_en","features.lockscreen.impl.unlock_PinUnlockView_Night_2_en",20420,], -["features.lockscreen.impl.unlock_PinUnlockView_Day_3_en","features.lockscreen.impl.unlock_PinUnlockView_Night_3_en",20420,], -["features.lockscreen.impl.unlock_PinUnlockView_Day_4_en","features.lockscreen.impl.unlock_PinUnlockView_Night_4_en",20420,], -["features.lockscreen.impl.unlock_PinUnlockView_Day_5_en","features.lockscreen.impl.unlock_PinUnlockView_Night_5_en",20420,], -["features.lockscreen.impl.unlock_PinUnlockView_Day_6_en","features.lockscreen.impl.unlock_PinUnlockView_Night_6_en",20420,], -["features.lockscreen.impl.unlock_PinUnlockView_Day_7_en","features.lockscreen.impl.unlock_PinUnlockView_Night_7_en",20420,], +["features.lockscreen.impl.unlock_PinUnlockViewInApp_Day_0_en","features.lockscreen.impl.unlock_PinUnlockViewInApp_Night_0_en",20466,], +["features.lockscreen.impl.unlock_PinUnlockViewInApp_Day_1_en","features.lockscreen.impl.unlock_PinUnlockViewInApp_Night_1_en",20466,], +["features.lockscreen.impl.unlock_PinUnlockViewInApp_Day_2_en","features.lockscreen.impl.unlock_PinUnlockViewInApp_Night_2_en",20466,], +["features.lockscreen.impl.unlock_PinUnlockViewInApp_Day_3_en","features.lockscreen.impl.unlock_PinUnlockViewInApp_Night_3_en",20466,], +["features.lockscreen.impl.unlock_PinUnlockViewInApp_Day_4_en","features.lockscreen.impl.unlock_PinUnlockViewInApp_Night_4_en",20466,], +["features.lockscreen.impl.unlock_PinUnlockViewInApp_Day_5_en","features.lockscreen.impl.unlock_PinUnlockViewInApp_Night_5_en",20466,], +["features.lockscreen.impl.unlock_PinUnlockViewInApp_Day_6_en","features.lockscreen.impl.unlock_PinUnlockViewInApp_Night_6_en",20466,], +["features.lockscreen.impl.unlock_PinUnlockViewInApp_Day_7_en","features.lockscreen.impl.unlock_PinUnlockViewInApp_Night_7_en",20466,], +["features.lockscreen.impl.unlock_PinUnlockView_Day_0_en","features.lockscreen.impl.unlock_PinUnlockView_Night_0_en",20466,], +["features.lockscreen.impl.unlock_PinUnlockView_Day_1_en","features.lockscreen.impl.unlock_PinUnlockView_Night_1_en",20466,], +["features.lockscreen.impl.unlock_PinUnlockView_Day_2_en","features.lockscreen.impl.unlock_PinUnlockView_Night_2_en",20466,], +["features.lockscreen.impl.unlock_PinUnlockView_Day_3_en","features.lockscreen.impl.unlock_PinUnlockView_Night_3_en",20466,], +["features.lockscreen.impl.unlock_PinUnlockView_Day_4_en","features.lockscreen.impl.unlock_PinUnlockView_Night_4_en",20466,], +["features.lockscreen.impl.unlock_PinUnlockView_Day_5_en","features.lockscreen.impl.unlock_PinUnlockView_Night_5_en",20466,], +["features.lockscreen.impl.unlock_PinUnlockView_Day_6_en","features.lockscreen.impl.unlock_PinUnlockView_Night_6_en",20466,], +["features.lockscreen.impl.unlock_PinUnlockView_Day_7_en","features.lockscreen.impl.unlock_PinUnlockView_Night_7_en",20466,], ["features.messages.impl.pinned.banner_PinnedMessagesBannerView_Day_0_en","features.messages.impl.pinned.banner_PinnedMessagesBannerView_Night_0_en",0,], -["features.messages.impl.pinned.banner_PinnedMessagesBannerView_Day_10_en","features.messages.impl.pinned.banner_PinnedMessagesBannerView_Night_10_en",20420,], -["features.messages.impl.pinned.banner_PinnedMessagesBannerView_Day_1_en","features.messages.impl.pinned.banner_PinnedMessagesBannerView_Night_1_en",20420,], -["features.messages.impl.pinned.banner_PinnedMessagesBannerView_Day_2_en","features.messages.impl.pinned.banner_PinnedMessagesBannerView_Night_2_en",20420,], -["features.messages.impl.pinned.banner_PinnedMessagesBannerView_Day_3_en","features.messages.impl.pinned.banner_PinnedMessagesBannerView_Night_3_en",20420,], -["features.messages.impl.pinned.banner_PinnedMessagesBannerView_Day_4_en","features.messages.impl.pinned.banner_PinnedMessagesBannerView_Night_4_en",20420,], -["features.messages.impl.pinned.banner_PinnedMessagesBannerView_Day_5_en","features.messages.impl.pinned.banner_PinnedMessagesBannerView_Night_5_en",20420,], -["features.messages.impl.pinned.banner_PinnedMessagesBannerView_Day_6_en","features.messages.impl.pinned.banner_PinnedMessagesBannerView_Night_6_en",20420,], -["features.messages.impl.pinned.banner_PinnedMessagesBannerView_Day_7_en","features.messages.impl.pinned.banner_PinnedMessagesBannerView_Night_7_en",20420,], -["features.messages.impl.pinned.banner_PinnedMessagesBannerView_Day_8_en","features.messages.impl.pinned.banner_PinnedMessagesBannerView_Night_8_en",20420,], -["features.messages.impl.pinned.banner_PinnedMessagesBannerView_Day_9_en","features.messages.impl.pinned.banner_PinnedMessagesBannerView_Night_9_en",20420,], -["features.messages.impl.pinned.list_PinnedMessagesListView_Day_0_en","features.messages.impl.pinned.list_PinnedMessagesListView_Night_0_en",20420,], -["features.messages.impl.pinned.list_PinnedMessagesListView_Day_1_en","features.messages.impl.pinned.list_PinnedMessagesListView_Night_1_en",20420,], -["features.messages.impl.pinned.list_PinnedMessagesListView_Day_2_en","features.messages.impl.pinned.list_PinnedMessagesListView_Night_2_en",20420,], -["features.messages.impl.pinned.list_PinnedMessagesListView_Day_3_en","features.messages.impl.pinned.list_PinnedMessagesListView_Night_3_en",20420,], +["features.messages.impl.pinned.banner_PinnedMessagesBannerView_Day_10_en","features.messages.impl.pinned.banner_PinnedMessagesBannerView_Night_10_en",20466,], +["features.messages.impl.pinned.banner_PinnedMessagesBannerView_Day_1_en","features.messages.impl.pinned.banner_PinnedMessagesBannerView_Night_1_en",20466,], +["features.messages.impl.pinned.banner_PinnedMessagesBannerView_Day_2_en","features.messages.impl.pinned.banner_PinnedMessagesBannerView_Night_2_en",20466,], +["features.messages.impl.pinned.banner_PinnedMessagesBannerView_Day_3_en","features.messages.impl.pinned.banner_PinnedMessagesBannerView_Night_3_en",20466,], +["features.messages.impl.pinned.banner_PinnedMessagesBannerView_Day_4_en","features.messages.impl.pinned.banner_PinnedMessagesBannerView_Night_4_en",20466,], +["features.messages.impl.pinned.banner_PinnedMessagesBannerView_Day_5_en","features.messages.impl.pinned.banner_PinnedMessagesBannerView_Night_5_en",20466,], +["features.messages.impl.pinned.banner_PinnedMessagesBannerView_Day_6_en","features.messages.impl.pinned.banner_PinnedMessagesBannerView_Night_6_en",20466,], +["features.messages.impl.pinned.banner_PinnedMessagesBannerView_Day_7_en","features.messages.impl.pinned.banner_PinnedMessagesBannerView_Night_7_en",20466,], +["features.messages.impl.pinned.banner_PinnedMessagesBannerView_Day_8_en","features.messages.impl.pinned.banner_PinnedMessagesBannerView_Night_8_en",20466,], +["features.messages.impl.pinned.banner_PinnedMessagesBannerView_Day_9_en","features.messages.impl.pinned.banner_PinnedMessagesBannerView_Night_9_en",20466,], +["features.messages.impl.pinned.list_PinnedMessagesListView_Day_0_en","features.messages.impl.pinned.list_PinnedMessagesListView_Night_0_en",20466,], +["features.messages.impl.pinned.list_PinnedMessagesListView_Day_1_en","features.messages.impl.pinned.list_PinnedMessagesListView_Night_1_en",20466,], +["features.messages.impl.pinned.list_PinnedMessagesListView_Day_2_en","features.messages.impl.pinned.list_PinnedMessagesListView_Night_2_en",20466,], +["features.messages.impl.pinned.list_PinnedMessagesListView_Day_3_en","features.messages.impl.pinned.list_PinnedMessagesListView_Night_3_en",20466,], ["libraries.designsystem.atomic.atoms_PlaceholderAtom_Day_0_en","libraries.designsystem.atomic.atoms_PlaceholderAtom_Night_0_en",0,], -["features.poll.api.pollcontent_PollAnswerViewDisclosedNotSelected_Day_0_en","features.poll.api.pollcontent_PollAnswerViewDisclosedNotSelected_Night_0_en",20420,], -["features.poll.api.pollcontent_PollAnswerViewDisclosedSelected_Day_0_en","features.poll.api.pollcontent_PollAnswerViewDisclosedSelected_Night_0_en",20420,], -["features.poll.api.pollcontent_PollAnswerViewEndedSelected_Day_0_en","features.poll.api.pollcontent_PollAnswerViewEndedSelected_Night_0_en",20420,], -["features.poll.api.pollcontent_PollAnswerViewEndedWinnerNotSelected_Day_0_en","features.poll.api.pollcontent_PollAnswerViewEndedWinnerNotSelected_Night_0_en",20420,], -["features.poll.api.pollcontent_PollAnswerViewEndedWinnerSelected_Day_0_en","features.poll.api.pollcontent_PollAnswerViewEndedWinnerSelected_Night_0_en",20420,], +["libraries.designsystem.atomic.atoms_PlaybackSpeedButton_Day_0_en","libraries.designsystem.atomic.atoms_PlaybackSpeedButton_Night_0_en",0,], +["features.poll.api.pollcontent_PollAnswerViewDisclosedNotSelected_Day_0_en","features.poll.api.pollcontent_PollAnswerViewDisclosedNotSelected_Night_0_en",20466,], +["features.poll.api.pollcontent_PollAnswerViewDisclosedSelected_Day_0_en","features.poll.api.pollcontent_PollAnswerViewDisclosedSelected_Night_0_en",20466,], +["features.poll.api.pollcontent_PollAnswerViewEndedSelected_Day_0_en","features.poll.api.pollcontent_PollAnswerViewEndedSelected_Night_0_en",20466,], +["features.poll.api.pollcontent_PollAnswerViewEndedWinnerNotSelected_Day_0_en","features.poll.api.pollcontent_PollAnswerViewEndedWinnerNotSelected_Night_0_en",20466,], +["features.poll.api.pollcontent_PollAnswerViewEndedWinnerSelected_Day_0_en","features.poll.api.pollcontent_PollAnswerViewEndedWinnerSelected_Night_0_en",20466,], ["features.poll.api.pollcontent_PollAnswerViewUndisclosedNotSelected_Day_0_en","features.poll.api.pollcontent_PollAnswerViewUndisclosedNotSelected_Night_0_en",0,], ["features.poll.api.pollcontent_PollAnswerViewUndisclosedSelected_Day_0_en","features.poll.api.pollcontent_PollAnswerViewUndisclosedSelected_Night_0_en",0,], -["features.poll.api.pollcontent_PollContentViewCreatorEditable_Day_0_en","features.poll.api.pollcontent_PollContentViewCreatorEditable_Night_0_en",20420,], -["features.poll.api.pollcontent_PollContentViewCreatorEnded_Day_0_en","features.poll.api.pollcontent_PollContentViewCreatorEnded_Night_0_en",20420,], -["features.poll.api.pollcontent_PollContentViewCreator_Day_0_en","features.poll.api.pollcontent_PollContentViewCreator_Night_0_en",20420,], -["features.poll.api.pollcontent_PollContentViewDisclosed_Day_0_en","features.poll.api.pollcontent_PollContentViewDisclosed_Night_0_en",20420,], -["features.poll.api.pollcontent_PollContentViewEnded_Day_0_en","features.poll.api.pollcontent_PollContentViewEnded_Night_0_en",20420,], -["features.poll.api.pollcontent_PollContentViewUndisclosed_Day_0_en","features.poll.api.pollcontent_PollContentViewUndisclosed_Night_0_en",20420,], -["features.poll.impl.history_PollHistoryView_Day_0_en","features.poll.impl.history_PollHistoryView_Night_0_en",20420,], -["features.poll.impl.history_PollHistoryView_Day_1_en","features.poll.impl.history_PollHistoryView_Night_1_en",20420,], -["features.poll.impl.history_PollHistoryView_Day_2_en","features.poll.impl.history_PollHistoryView_Night_2_en",20420,], -["features.poll.impl.history_PollHistoryView_Day_3_en","features.poll.impl.history_PollHistoryView_Night_3_en",20420,], -["features.poll.impl.history_PollHistoryView_Day_4_en","features.poll.impl.history_PollHistoryView_Night_4_en",20420,], +["features.poll.api.pollcontent_PollContentViewCreatorEditable_Day_0_en","features.poll.api.pollcontent_PollContentViewCreatorEditable_Night_0_en",20466,], +["features.poll.api.pollcontent_PollContentViewCreatorEnded_Day_0_en","features.poll.api.pollcontent_PollContentViewCreatorEnded_Night_0_en",20466,], +["features.poll.api.pollcontent_PollContentViewCreator_Day_0_en","features.poll.api.pollcontent_PollContentViewCreator_Night_0_en",20466,], +["features.poll.api.pollcontent_PollContentViewDisclosed_Day_0_en","features.poll.api.pollcontent_PollContentViewDisclosed_Night_0_en",20466,], +["features.poll.api.pollcontent_PollContentViewEnded_Day_0_en","features.poll.api.pollcontent_PollContentViewEnded_Night_0_en",20466,], +["features.poll.api.pollcontent_PollContentViewUndisclosed_Day_0_en","features.poll.api.pollcontent_PollContentViewUndisclosed_Night_0_en",20466,], +["features.poll.impl.history_PollHistoryView_Day_0_en","features.poll.impl.history_PollHistoryView_Night_0_en",20466,], +["features.poll.impl.history_PollHistoryView_Day_1_en","features.poll.impl.history_PollHistoryView_Night_1_en",20466,], +["features.poll.impl.history_PollHistoryView_Day_2_en","features.poll.impl.history_PollHistoryView_Night_2_en",20466,], +["features.poll.impl.history_PollHistoryView_Day_3_en","features.poll.impl.history_PollHistoryView_Night_3_en",20466,], +["features.poll.impl.history_PollHistoryView_Day_4_en","features.poll.impl.history_PollHistoryView_Night_4_en",20466,], ["features.poll.api.pollcontent_PollTitleView_Day_0_en","features.poll.api.pollcontent_PollTitleView_Night_0_en",0,], ["libraries.designsystem.components.preferences_PreferenceCategory_Preferences_en","",0,], ["libraries.designsystem.components.preferences_PreferenceCheckbox_Preferences_en","",0,], @@ -802,205 +841,208 @@ export const screenshots = [ ["libraries.designsystem.components.preferences_PreferenceRow_Preferences_en","",0,], ["libraries.designsystem.components.preferences_PreferenceSlide_Preferences_en","",0,], ["libraries.designsystem.components.preferences_PreferenceSwitch_Preferences_en","",0,], -["features.preferences.impl.root_PreferencesRootViewDark_0_en","",20420,], -["features.preferences.impl.root_PreferencesRootViewDark_1_en","",20420,], -["features.preferences.impl.root_PreferencesRootViewLight_0_en","",20420,], -["features.preferences.impl.root_PreferencesRootViewLight_1_en","",20420,], +["features.preferences.impl.root_PreferencesRootViewDark_0_en","",20466,], +["features.preferences.impl.root_PreferencesRootViewDark_1_en","",20466,], +["features.preferences.impl.root_PreferencesRootViewLight_0_en","",20466,], +["features.preferences.impl.root_PreferencesRootViewLight_1_en","",20466,], ["features.messages.impl.timeline.components.event_ProgressButton_Day_0_en","features.messages.impl.timeline.components.event_ProgressButton_Night_0_en",0,], -["libraries.designsystem.components_ProgressDialogContent_Dialogs_en","",20420,], -["libraries.designsystem.components_ProgressDialogWithContent_Day_0_en","libraries.designsystem.components_ProgressDialogWithContent_Night_0_en",20420,], +["libraries.designsystem.components_ProgressDialogContent_Dialogs_en","",20466,], +["libraries.designsystem.components_ProgressDialogWithContent_Day_0_en","libraries.designsystem.components_ProgressDialogWithContent_Night_0_en",20466,], ["libraries.designsystem.components_ProgressDialogWithTextAndContent_Day_0_en","libraries.designsystem.components_ProgressDialogWithTextAndContent_Night_0_en",0,], -["libraries.designsystem.components_ProgressDialog_Day_0_en","libraries.designsystem.components_ProgressDialog_Night_0_en",20420,], -["features.messages.impl.timeline.protection_ProtectedView_Day_0_en","features.messages.impl.timeline.protection_ProtectedView_Night_0_en",20420,], -["features.messages.impl.timeline.protection_ProtectedView_Day_1_en","features.messages.impl.timeline.protection_ProtectedView_Night_1_en",20420,], -["features.messages.impl.timeline.protection_ProtectedView_Day_2_en","features.messages.impl.timeline.protection_ProtectedView_Night_2_en",20420,], -["features.messages.impl.timeline.protection_ProtectedView_Day_3_en","features.messages.impl.timeline.protection_ProtectedView_Night_3_en",20420,], -["libraries.troubleshoot.impl.history_PushHistoryView_Day_0_en","libraries.troubleshoot.impl.history_PushHistoryView_Night_0_en",20420,], -["libraries.troubleshoot.impl.history_PushHistoryView_Day_1_en","libraries.troubleshoot.impl.history_PushHistoryView_Night_1_en",20420,], -["libraries.troubleshoot.impl.history_PushHistoryView_Day_2_en","libraries.troubleshoot.impl.history_PushHistoryView_Night_2_en",20420,], -["libraries.troubleshoot.impl.history_PushHistoryView_Day_3_en","libraries.troubleshoot.impl.history_PushHistoryView_Night_3_en",20420,], -["features.login.impl.screens.qrcode.confirmation_QrCodeConfirmationView_Day_0_en","features.login.impl.screens.qrcode.confirmation_QrCodeConfirmationView_Night_0_en",20420,], -["features.login.impl.screens.qrcode.confirmation_QrCodeConfirmationView_Day_1_en","features.login.impl.screens.qrcode.confirmation_QrCodeConfirmationView_Night_1_en",20420,], -["features.login.impl.screens.qrcode.confirmation_QrCodeConfirmationView_Day_2_en","features.login.impl.screens.qrcode.confirmation_QrCodeConfirmationView_Night_2_en",20420,], -["features.login.impl.screens.qrcode.error_QrCodeErrorView_Day_0_en","features.login.impl.screens.qrcode.error_QrCodeErrorView_Night_0_en",20420,], -["features.login.impl.screens.qrcode.error_QrCodeErrorView_Day_1_en","features.login.impl.screens.qrcode.error_QrCodeErrorView_Night_1_en",20420,], -["features.login.impl.screens.qrcode.error_QrCodeErrorView_Day_2_en","features.login.impl.screens.qrcode.error_QrCodeErrorView_Night_2_en",20420,], -["features.login.impl.screens.qrcode.error_QrCodeErrorView_Day_3_en","features.login.impl.screens.qrcode.error_QrCodeErrorView_Night_3_en",20420,], -["features.login.impl.screens.qrcode.error_QrCodeErrorView_Day_4_en","features.login.impl.screens.qrcode.error_QrCodeErrorView_Night_4_en",20420,], -["features.login.impl.screens.qrcode.error_QrCodeErrorView_Day_5_en","features.login.impl.screens.qrcode.error_QrCodeErrorView_Night_5_en",20420,], -["features.login.impl.screens.qrcode.error_QrCodeErrorView_Day_6_en","features.login.impl.screens.qrcode.error_QrCodeErrorView_Night_6_en",20420,], -["features.login.impl.screens.qrcode.intro_QrCodeIntroView_Day_0_en","features.login.impl.screens.qrcode.intro_QrCodeIntroView_Night_0_en",20420,], -["features.login.impl.screens.qrcode.intro_QrCodeIntroView_Day_1_en","features.login.impl.screens.qrcode.intro_QrCodeIntroView_Night_1_en",20420,], -["features.login.impl.screens.qrcode.scan_QrCodeScanView_Day_0_en","features.login.impl.screens.qrcode.scan_QrCodeScanView_Night_0_en",20420,], -["features.login.impl.screens.qrcode.scan_QrCodeScanView_Day_1_en","features.login.impl.screens.qrcode.scan_QrCodeScanView_Night_1_en",20420,], -["features.login.impl.screens.qrcode.scan_QrCodeScanView_Day_2_en","features.login.impl.screens.qrcode.scan_QrCodeScanView_Night_2_en",20420,], -["features.login.impl.screens.qrcode.scan_QrCodeScanView_Day_3_en","features.login.impl.screens.qrcode.scan_QrCodeScanView_Night_3_en",20420,], -["features.login.impl.screens.qrcode.scan_QrCodeScanView_Day_4_en","features.login.impl.screens.qrcode.scan_QrCodeScanView_Night_4_en",20420,], -["features.login.impl.screens.qrcode.scan_QrCodeScanView_Day_5_en","features.login.impl.screens.qrcode.scan_QrCodeScanView_Night_5_en",20420,], +["libraries.designsystem.components_ProgressDialog_Day_0_en","libraries.designsystem.components_ProgressDialog_Night_0_en",20466,], +["features.messages.impl.timeline.protection_ProtectedView_Day_0_en","features.messages.impl.timeline.protection_ProtectedView_Night_0_en",20466,], +["features.messages.impl.timeline.protection_ProtectedView_Day_1_en","features.messages.impl.timeline.protection_ProtectedView_Night_1_en",20466,], +["features.messages.impl.timeline.protection_ProtectedView_Day_2_en","features.messages.impl.timeline.protection_ProtectedView_Night_2_en",20466,], +["features.messages.impl.timeline.protection_ProtectedView_Day_3_en","features.messages.impl.timeline.protection_ProtectedView_Night_3_en",20466,], +["libraries.troubleshoot.impl.history_PushHistoryView_Day_0_en","libraries.troubleshoot.impl.history_PushHistoryView_Night_0_en",20466,], +["libraries.troubleshoot.impl.history_PushHistoryView_Day_1_en","libraries.troubleshoot.impl.history_PushHistoryView_Night_1_en",20466,], +["libraries.troubleshoot.impl.history_PushHistoryView_Day_2_en","libraries.troubleshoot.impl.history_PushHistoryView_Night_2_en",20466,], +["libraries.troubleshoot.impl.history_PushHistoryView_Day_3_en","libraries.troubleshoot.impl.history_PushHistoryView_Night_3_en",20466,], +["features.login.impl.screens.qrcode.confirmation_QrCodeConfirmationView_Day_0_en","features.login.impl.screens.qrcode.confirmation_QrCodeConfirmationView_Night_0_en",20466,], +["features.login.impl.screens.qrcode.confirmation_QrCodeConfirmationView_Day_1_en","features.login.impl.screens.qrcode.confirmation_QrCodeConfirmationView_Night_1_en",20466,], +["features.login.impl.screens.qrcode.confirmation_QrCodeConfirmationView_Day_2_en","features.login.impl.screens.qrcode.confirmation_QrCodeConfirmationView_Night_2_en",20466,], +["features.login.impl.screens.qrcode.error_QrCodeErrorView_Day_0_en","features.login.impl.screens.qrcode.error_QrCodeErrorView_Night_0_en",20466,], +["features.login.impl.screens.qrcode.error_QrCodeErrorView_Day_1_en","features.login.impl.screens.qrcode.error_QrCodeErrorView_Night_1_en",20466,], +["features.login.impl.screens.qrcode.error_QrCodeErrorView_Day_2_en","features.login.impl.screens.qrcode.error_QrCodeErrorView_Night_2_en",20466,], +["features.login.impl.screens.qrcode.error_QrCodeErrorView_Day_3_en","features.login.impl.screens.qrcode.error_QrCodeErrorView_Night_3_en",20466,], +["features.login.impl.screens.qrcode.error_QrCodeErrorView_Day_4_en","features.login.impl.screens.qrcode.error_QrCodeErrorView_Night_4_en",20466,], +["features.login.impl.screens.qrcode.error_QrCodeErrorView_Day_5_en","features.login.impl.screens.qrcode.error_QrCodeErrorView_Night_5_en",20466,], +["features.login.impl.screens.qrcode.error_QrCodeErrorView_Day_6_en","features.login.impl.screens.qrcode.error_QrCodeErrorView_Night_6_en",20466,], +["features.login.impl.screens.qrcode.intro_QrCodeIntroView_Day_0_en","features.login.impl.screens.qrcode.intro_QrCodeIntroView_Night_0_en",20466,], +["features.login.impl.screens.qrcode.intro_QrCodeIntroView_Day_1_en","features.login.impl.screens.qrcode.intro_QrCodeIntroView_Night_1_en",20466,], +["features.login.impl.screens.qrcode.scan_QrCodeScanView_Day_0_en","features.login.impl.screens.qrcode.scan_QrCodeScanView_Night_0_en",20466,], +["features.login.impl.screens.qrcode.scan_QrCodeScanView_Day_1_en","features.login.impl.screens.qrcode.scan_QrCodeScanView_Night_1_en",20466,], +["features.login.impl.screens.qrcode.scan_QrCodeScanView_Day_2_en","features.login.impl.screens.qrcode.scan_QrCodeScanView_Night_2_en",20466,], +["features.login.impl.screens.qrcode.scan_QrCodeScanView_Day_3_en","features.login.impl.screens.qrcode.scan_QrCodeScanView_Night_3_en",20466,], +["features.login.impl.screens.qrcode.scan_QrCodeScanView_Day_4_en","features.login.impl.screens.qrcode.scan_QrCodeScanView_Night_4_en",20466,], +["features.login.impl.screens.qrcode.scan_QrCodeScanView_Day_5_en","features.login.impl.screens.qrcode.scan_QrCodeScanView_Night_5_en",20466,], +["libraries.qrcode_QrCodeView_en","",0,], ["libraries.designsystem.theme.components_RadioButton_Toggles_en","",0,], -["features.rageshake.api.detection_RageshakeDialogContent_Day_0_en","features.rageshake.api.detection_RageshakeDialogContent_Night_0_en",20420,], -["features.rageshake.api.preferences_RageshakePreferencesView_Day_0_en","features.rageshake.api.preferences_RageshakePreferencesView_Night_0_en",20420,], +["features.rageshake.api.detection_RageshakeDialogContent_Day_0_en","features.rageshake.api.detection_RageshakeDialogContent_Night_0_en",20466,], +["features.rageshake.api.preferences_RageshakePreferencesView_Day_0_en","features.rageshake.api.preferences_RageshakePreferencesView_Night_0_en",20466,], ["features.rageshake.api.preferences_RageshakePreferencesView_Day_1_en","features.rageshake.api.preferences_RageshakePreferencesView_Night_1_en",0,], ["features.messages.impl.timeline.components.reactionsummary_ReactionSummaryViewContent_Day_0_en","features.messages.impl.timeline.components.reactionsummary_ReactionSummaryViewContent_Night_0_en",0,], -["features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Day_0_en","features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Night_0_en",20420,], -["features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Day_1_en","features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Night_1_en",20420,], -["features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Day_2_en","features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Night_2_en",20420,], -["features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Day_3_en","features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Night_3_en",20420,], -["features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Day_4_en","features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Night_4_en",20420,], -["features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Day_5_en","features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Night_5_en",20420,], -["features.securebackup.impl.setup.views_RecoveryKeyView_Day_0_en","features.securebackup.impl.setup.views_RecoveryKeyView_Night_0_en",20420,], -["features.securebackup.impl.setup.views_RecoveryKeyView_Day_10_en","features.securebackup.impl.setup.views_RecoveryKeyView_Night_10_en",20420,], -["features.securebackup.impl.setup.views_RecoveryKeyView_Day_11_en","features.securebackup.impl.setup.views_RecoveryKeyView_Night_11_en",20420,], -["features.securebackup.impl.setup.views_RecoveryKeyView_Day_12_en","features.securebackup.impl.setup.views_RecoveryKeyView_Night_12_en",20420,], -["features.securebackup.impl.setup.views_RecoveryKeyView_Day_13_en","features.securebackup.impl.setup.views_RecoveryKeyView_Night_13_en",20420,], -["features.securebackup.impl.setup.views_RecoveryKeyView_Day_14_en","features.securebackup.impl.setup.views_RecoveryKeyView_Night_14_en",20420,], -["features.securebackup.impl.setup.views_RecoveryKeyView_Day_1_en","features.securebackup.impl.setup.views_RecoveryKeyView_Night_1_en",20420,], -["features.securebackup.impl.setup.views_RecoveryKeyView_Day_2_en","features.securebackup.impl.setup.views_RecoveryKeyView_Night_2_en",20420,], -["features.securebackup.impl.setup.views_RecoveryKeyView_Day_3_en","features.securebackup.impl.setup.views_RecoveryKeyView_Night_3_en",20420,], -["features.securebackup.impl.setup.views_RecoveryKeyView_Day_4_en","features.securebackup.impl.setup.views_RecoveryKeyView_Night_4_en",20420,], -["features.securebackup.impl.setup.views_RecoveryKeyView_Day_5_en","features.securebackup.impl.setup.views_RecoveryKeyView_Night_5_en",20420,], -["features.securebackup.impl.setup.views_RecoveryKeyView_Day_6_en","features.securebackup.impl.setup.views_RecoveryKeyView_Night_6_en",20420,], -["features.securebackup.impl.setup.views_RecoveryKeyView_Day_7_en","features.securebackup.impl.setup.views_RecoveryKeyView_Night_7_en",20420,], -["features.securebackup.impl.setup.views_RecoveryKeyView_Day_8_en","features.securebackup.impl.setup.views_RecoveryKeyView_Night_8_en",20420,], -["features.securebackup.impl.setup.views_RecoveryKeyView_Day_9_en","features.securebackup.impl.setup.views_RecoveryKeyView_Night_9_en",20420,], +["features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Day_0_en","features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Night_0_en",20466,], +["features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Day_1_en","features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Night_1_en",20466,], +["features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Day_2_en","features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Night_2_en",20466,], +["features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Day_3_en","features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Night_3_en",20466,], +["features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Day_4_en","features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Night_4_en",20466,], +["features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Day_5_en","features.messages.impl.timeline.components.receipt.bottomsheet_ReadReceiptBottomSheet_Night_5_en",20466,], +["features.securebackup.impl.setup.views_RecoveryKeyView_Day_0_en","features.securebackup.impl.setup.views_RecoveryKeyView_Night_0_en",20466,], +["features.securebackup.impl.setup.views_RecoveryKeyView_Day_10_en","features.securebackup.impl.setup.views_RecoveryKeyView_Night_10_en",20466,], +["features.securebackup.impl.setup.views_RecoveryKeyView_Day_11_en","features.securebackup.impl.setup.views_RecoveryKeyView_Night_11_en",20466,], +["features.securebackup.impl.setup.views_RecoveryKeyView_Day_12_en","features.securebackup.impl.setup.views_RecoveryKeyView_Night_12_en",20466,], +["features.securebackup.impl.setup.views_RecoveryKeyView_Day_13_en","features.securebackup.impl.setup.views_RecoveryKeyView_Night_13_en",20466,], +["features.securebackup.impl.setup.views_RecoveryKeyView_Day_14_en","features.securebackup.impl.setup.views_RecoveryKeyView_Night_14_en",20466,], +["features.securebackup.impl.setup.views_RecoveryKeyView_Day_1_en","features.securebackup.impl.setup.views_RecoveryKeyView_Night_1_en",20466,], +["features.securebackup.impl.setup.views_RecoveryKeyView_Day_2_en","features.securebackup.impl.setup.views_RecoveryKeyView_Night_2_en",20466,], +["features.securebackup.impl.setup.views_RecoveryKeyView_Day_3_en","features.securebackup.impl.setup.views_RecoveryKeyView_Night_3_en",20466,], +["features.securebackup.impl.setup.views_RecoveryKeyView_Day_4_en","features.securebackup.impl.setup.views_RecoveryKeyView_Night_4_en",20466,], +["features.securebackup.impl.setup.views_RecoveryKeyView_Day_5_en","features.securebackup.impl.setup.views_RecoveryKeyView_Night_5_en",20466,], +["features.securebackup.impl.setup.views_RecoveryKeyView_Day_6_en","features.securebackup.impl.setup.views_RecoveryKeyView_Night_6_en",20466,], +["features.securebackup.impl.setup.views_RecoveryKeyView_Day_7_en","features.securebackup.impl.setup.views_RecoveryKeyView_Night_7_en",20466,], +["features.securebackup.impl.setup.views_RecoveryKeyView_Day_8_en","features.securebackup.impl.setup.views_RecoveryKeyView_Night_8_en",20466,], +["features.securebackup.impl.setup.views_RecoveryKeyView_Day_9_en","features.securebackup.impl.setup.views_RecoveryKeyView_Night_9_en",20466,], ["libraries.designsystem.atomic.atoms_RedIndicatorAtom_Day_0_en","libraries.designsystem.atomic.atoms_RedIndicatorAtom_Night_0_en",0,], ["features.messages.impl.timeline.components_ReplySwipeIndicator_Day_0_en","features.messages.impl.timeline.components_ReplySwipeIndicator_Night_0_en",0,], -["features.messages.impl.report_ReportMessageView_Day_0_en","features.messages.impl.report_ReportMessageView_Night_0_en",20420,], -["features.messages.impl.report_ReportMessageView_Day_1_en","features.messages.impl.report_ReportMessageView_Night_1_en",20420,], -["features.messages.impl.report_ReportMessageView_Day_2_en","features.messages.impl.report_ReportMessageView_Night_2_en",20420,], -["features.messages.impl.report_ReportMessageView_Day_3_en","features.messages.impl.report_ReportMessageView_Night_3_en",20420,], -["features.messages.impl.report_ReportMessageView_Day_4_en","features.messages.impl.report_ReportMessageView_Night_4_en",20420,], -["features.messages.impl.report_ReportMessageView_Day_5_en","features.messages.impl.report_ReportMessageView_Night_5_en",20420,], -["features.reportroom.impl_ReportRoomView_Day_0_en","features.reportroom.impl_ReportRoomView_Night_0_en",20420,], -["features.reportroom.impl_ReportRoomView_Day_1_en","features.reportroom.impl_ReportRoomView_Night_1_en",20420,], -["features.reportroom.impl_ReportRoomView_Day_2_en","features.reportroom.impl_ReportRoomView_Night_2_en",20420,], -["features.reportroom.impl_ReportRoomView_Day_3_en","features.reportroom.impl_ReportRoomView_Night_3_en",20420,], -["features.reportroom.impl_ReportRoomView_Day_4_en","features.reportroom.impl_ReportRoomView_Night_4_en",20420,], -["features.securebackup.impl.reset.password_ResetIdentityPasswordView_Day_0_en","features.securebackup.impl.reset.password_ResetIdentityPasswordView_Night_0_en",20420,], -["features.securebackup.impl.reset.password_ResetIdentityPasswordView_Day_1_en","features.securebackup.impl.reset.password_ResetIdentityPasswordView_Night_1_en",20420,], -["features.securebackup.impl.reset.password_ResetIdentityPasswordView_Day_2_en","features.securebackup.impl.reset.password_ResetIdentityPasswordView_Night_2_en",20420,], -["features.securebackup.impl.reset.password_ResetIdentityPasswordView_Day_3_en","features.securebackup.impl.reset.password_ResetIdentityPasswordView_Night_3_en",20420,], -["features.securebackup.impl.reset.root_ResetIdentityRootView_Day_0_en","features.securebackup.impl.reset.root_ResetIdentityRootView_Night_0_en",20420,], -["features.securebackup.impl.reset.root_ResetIdentityRootView_Day_1_en","features.securebackup.impl.reset.root_ResetIdentityRootView_Night_1_en",20420,], +["features.messages.impl.report_ReportMessageView_Day_0_en","features.messages.impl.report_ReportMessageView_Night_0_en",20466,], +["features.messages.impl.report_ReportMessageView_Day_1_en","features.messages.impl.report_ReportMessageView_Night_1_en",20466,], +["features.messages.impl.report_ReportMessageView_Day_2_en","features.messages.impl.report_ReportMessageView_Night_2_en",20466,], +["features.messages.impl.report_ReportMessageView_Day_3_en","features.messages.impl.report_ReportMessageView_Night_3_en",20466,], +["features.messages.impl.report_ReportMessageView_Day_4_en","features.messages.impl.report_ReportMessageView_Night_4_en",20466,], +["features.messages.impl.report_ReportMessageView_Day_5_en","features.messages.impl.report_ReportMessageView_Night_5_en",20466,], +["features.reportroom.impl_ReportRoomView_Day_0_en","features.reportroom.impl_ReportRoomView_Night_0_en",20466,], +["features.reportroom.impl_ReportRoomView_Day_1_en","features.reportroom.impl_ReportRoomView_Night_1_en",20466,], +["features.reportroom.impl_ReportRoomView_Day_2_en","features.reportroom.impl_ReportRoomView_Night_2_en",20466,], +["features.reportroom.impl_ReportRoomView_Day_3_en","features.reportroom.impl_ReportRoomView_Night_3_en",20466,], +["features.reportroom.impl_ReportRoomView_Day_4_en","features.reportroom.impl_ReportRoomView_Night_4_en",20466,], +["features.securebackup.impl.reset.password_ResetIdentityPasswordView_Day_0_en","features.securebackup.impl.reset.password_ResetIdentityPasswordView_Night_0_en",20466,], +["features.securebackup.impl.reset.password_ResetIdentityPasswordView_Day_1_en","features.securebackup.impl.reset.password_ResetIdentityPasswordView_Night_1_en",20466,], +["features.securebackup.impl.reset.password_ResetIdentityPasswordView_Day_2_en","features.securebackup.impl.reset.password_ResetIdentityPasswordView_Night_2_en",20466,], +["features.securebackup.impl.reset.password_ResetIdentityPasswordView_Day_3_en","features.securebackup.impl.reset.password_ResetIdentityPasswordView_Night_3_en",20466,], +["features.securebackup.impl.reset.root_ResetIdentityRootView_Day_0_en","features.securebackup.impl.reset.root_ResetIdentityRootView_Night_0_en",20466,], +["features.securebackup.impl.reset.root_ResetIdentityRootView_Day_1_en","features.securebackup.impl.reset.root_ResetIdentityRootView_Night_1_en",20466,], ["features.messages.impl.crypto.sendfailure.resolve_ResolveVerifiedUserSendFailureView_Day_0_en","features.messages.impl.crypto.sendfailure.resolve_ResolveVerifiedUserSendFailureView_Night_0_en",0,], -["features.messages.impl.crypto.sendfailure.resolve_ResolveVerifiedUserSendFailureView_Day_1_en","features.messages.impl.crypto.sendfailure.resolve_ResolveVerifiedUserSendFailureView_Night_1_en",20420,], -["features.messages.impl.crypto.sendfailure.resolve_ResolveVerifiedUserSendFailureView_Day_2_en","features.messages.impl.crypto.sendfailure.resolve_ResolveVerifiedUserSendFailureView_Night_2_en",20420,], -["libraries.designsystem.components.dialogs_RetryDialogContent_Dialogs_en","",20420,], -["libraries.designsystem.components.dialogs_RetryDialog_Day_0_en","libraries.designsystem.components.dialogs_RetryDialog_Night_0_en",20420,], -["features.rolesandpermissions.impl.root_RolesAndPermissionsView_Day_0_en","features.rolesandpermissions.impl.root_RolesAndPermissionsView_Night_0_en",20420,], -["features.rolesandpermissions.impl.root_RolesAndPermissionsView_Day_1_en","features.rolesandpermissions.impl.root_RolesAndPermissionsView_Night_1_en",20420,], -["features.rolesandpermissions.impl.root_RolesAndPermissionsView_Day_2_en","features.rolesandpermissions.impl.root_RolesAndPermissionsView_Night_2_en",20420,], -["features.rolesandpermissions.impl.root_RolesAndPermissionsView_Day_3_en","features.rolesandpermissions.impl.root_RolesAndPermissionsView_Night_3_en",20420,], -["features.rolesandpermissions.impl.root_RolesAndPermissionsView_Day_4_en","features.rolesandpermissions.impl.root_RolesAndPermissionsView_Night_4_en",20420,], -["features.rolesandpermissions.impl.root_RolesAndPermissionsView_Day_5_en","features.rolesandpermissions.impl.root_RolesAndPermissionsView_Night_5_en",20420,], -["features.rolesandpermissions.impl.root_RolesAndPermissionsView_Day_6_en","features.rolesandpermissions.impl.root_RolesAndPermissionsView_Night_6_en",20420,], -["features.rolesandpermissions.impl.root_RolesAndPermissionsView_Day_7_en","features.rolesandpermissions.impl.root_RolesAndPermissionsView_Night_7_en",20420,], -["features.rolesandpermissions.impl.root_RolesAndPermissionsView_Day_8_en","features.rolesandpermissions.impl.root_RolesAndPermissionsView_Night_8_en",20420,], +["features.messages.impl.crypto.sendfailure.resolve_ResolveVerifiedUserSendFailureView_Day_1_en","features.messages.impl.crypto.sendfailure.resolve_ResolveVerifiedUserSendFailureView_Night_1_en",20466,], +["features.messages.impl.crypto.sendfailure.resolve_ResolveVerifiedUserSendFailureView_Day_2_en","features.messages.impl.crypto.sendfailure.resolve_ResolveVerifiedUserSendFailureView_Night_2_en",20466,], +["libraries.designsystem.components.dialogs_RetryDialogContent_Dialogs_en","",20466,], +["libraries.designsystem.components.dialogs_RetryDialog_Day_0_en","libraries.designsystem.components.dialogs_RetryDialog_Night_0_en",20466,], +["features.rolesandpermissions.impl.root_RolesAndPermissionsView_Day_0_en","features.rolesandpermissions.impl.root_RolesAndPermissionsView_Night_0_en",20466,], +["features.rolesandpermissions.impl.root_RolesAndPermissionsView_Day_1_en","features.rolesandpermissions.impl.root_RolesAndPermissionsView_Night_1_en",20466,], +["features.rolesandpermissions.impl.root_RolesAndPermissionsView_Day_2_en","features.rolesandpermissions.impl.root_RolesAndPermissionsView_Night_2_en",20466,], +["features.rolesandpermissions.impl.root_RolesAndPermissionsView_Day_3_en","features.rolesandpermissions.impl.root_RolesAndPermissionsView_Night_3_en",20466,], +["features.rolesandpermissions.impl.root_RolesAndPermissionsView_Day_4_en","features.rolesandpermissions.impl.root_RolesAndPermissionsView_Night_4_en",20466,], +["features.rolesandpermissions.impl.root_RolesAndPermissionsView_Day_5_en","features.rolesandpermissions.impl.root_RolesAndPermissionsView_Night_5_en",20466,], +["features.rolesandpermissions.impl.root_RolesAndPermissionsView_Day_6_en","features.rolesandpermissions.impl.root_RolesAndPermissionsView_Night_6_en",20466,], +["features.rolesandpermissions.impl.root_RolesAndPermissionsView_Day_7_en","features.rolesandpermissions.impl.root_RolesAndPermissionsView_Night_7_en",20466,], +["features.rolesandpermissions.impl.root_RolesAndPermissionsView_Day_8_en","features.rolesandpermissions.impl.root_RolesAndPermissionsView_Night_8_en",20466,], ["libraries.matrix.ui.room.address_RoomAddressField_Day_0_en","libraries.matrix.ui.room.address_RoomAddressField_Night_0_en",0,], ["features.roomaliasresolver.impl_RoomAliasResolverView_Day_0_en","features.roomaliasresolver.impl_RoomAliasResolverView_Night_0_en",0,], -["features.roomaliasresolver.impl_RoomAliasResolverView_Day_1_en","features.roomaliasresolver.impl_RoomAliasResolverView_Night_1_en",20420,], -["features.roomaliasresolver.impl_RoomAliasResolverView_Day_2_en","features.roomaliasresolver.impl_RoomAliasResolverView_Night_2_en",20420,], -["features.roomdetails.impl_RoomDetailsDark_0_en","",20420,], -["features.roomdetails.impl_RoomDetailsDark_10_en","",20420,], -["features.roomdetails.impl_RoomDetailsDark_11_en","",20420,], -["features.roomdetails.impl_RoomDetailsDark_12_en","",20420,], -["features.roomdetails.impl_RoomDetailsDark_13_en","",20420,], -["features.roomdetails.impl_RoomDetailsDark_14_en","",20420,], -["features.roomdetails.impl_RoomDetailsDark_15_en","",20420,], -["features.roomdetails.impl_RoomDetailsDark_16_en","",20420,], -["features.roomdetails.impl_RoomDetailsDark_17_en","",20420,], -["features.roomdetails.impl_RoomDetailsDark_18_en","",20420,], -["features.roomdetails.impl_RoomDetailsDark_19_en","",20420,], -["features.roomdetails.impl_RoomDetailsDark_1_en","",20420,], -["features.roomdetails.impl_RoomDetailsDark_2_en","",20420,], -["features.roomdetails.impl_RoomDetailsDark_3_en","",20420,], -["features.roomdetails.impl_RoomDetailsDark_4_en","",20420,], -["features.roomdetails.impl_RoomDetailsDark_5_en","",20420,], -["features.roomdetails.impl_RoomDetailsDark_6_en","",20420,], -["features.roomdetails.impl_RoomDetailsDark_7_en","",20420,], -["features.roomdetails.impl_RoomDetailsDark_8_en","",20420,], -["features.roomdetails.impl_RoomDetailsDark_9_en","",20420,], -["features.roomdetails.impl.edit_RoomDetailsEditView_Day_0_en","features.roomdetails.impl.edit_RoomDetailsEditView_Night_0_en",20420,], -["features.roomdetails.impl.edit_RoomDetailsEditView_Day_1_en","features.roomdetails.impl.edit_RoomDetailsEditView_Night_1_en",20420,], -["features.roomdetails.impl.edit_RoomDetailsEditView_Day_2_en","features.roomdetails.impl.edit_RoomDetailsEditView_Night_2_en",20420,], -["features.roomdetails.impl.edit_RoomDetailsEditView_Day_3_en","features.roomdetails.impl.edit_RoomDetailsEditView_Night_3_en",20420,], -["features.roomdetails.impl.edit_RoomDetailsEditView_Day_4_en","features.roomdetails.impl.edit_RoomDetailsEditView_Night_4_en",20420,], -["features.roomdetails.impl.edit_RoomDetailsEditView_Day_5_en","features.roomdetails.impl.edit_RoomDetailsEditView_Night_5_en",20420,], -["features.roomdetails.impl.edit_RoomDetailsEditView_Day_6_en","features.roomdetails.impl.edit_RoomDetailsEditView_Night_6_en",20420,], -["features.roomdetails.impl.edit_RoomDetailsEditView_Day_7_en","features.roomdetails.impl.edit_RoomDetailsEditView_Night_7_en",20420,], -["features.roomdetails.impl.edit_RoomDetailsEditView_Day_8_en","features.roomdetails.impl.edit_RoomDetailsEditView_Night_8_en",20420,], -["features.roomdetails.impl_RoomDetails_0_en","",20420,], -["features.roomdetails.impl_RoomDetails_10_en","",20420,], -["features.roomdetails.impl_RoomDetails_11_en","",20420,], -["features.roomdetails.impl_RoomDetails_12_en","",20420,], -["features.roomdetails.impl_RoomDetails_13_en","",20420,], -["features.roomdetails.impl_RoomDetails_14_en","",20420,], -["features.roomdetails.impl_RoomDetails_15_en","",20420,], -["features.roomdetails.impl_RoomDetails_16_en","",20420,], -["features.roomdetails.impl_RoomDetails_17_en","",20420,], -["features.roomdetails.impl_RoomDetails_18_en","",20420,], -["features.roomdetails.impl_RoomDetails_19_en","",20420,], -["features.roomdetails.impl_RoomDetails_1_en","",20420,], -["features.roomdetails.impl_RoomDetails_2_en","",20420,], -["features.roomdetails.impl_RoomDetails_3_en","",20420,], -["features.roomdetails.impl_RoomDetails_4_en","",20420,], -["features.roomdetails.impl_RoomDetails_5_en","",20420,], -["features.roomdetails.impl_RoomDetails_6_en","",20420,], -["features.roomdetails.impl_RoomDetails_7_en","",20420,], -["features.roomdetails.impl_RoomDetails_8_en","",20420,], -["features.roomdetails.impl_RoomDetails_9_en","",20420,], -["features.roomdirectory.impl.root_RoomDirectoryView_Day_0_en","features.roomdirectory.impl.root_RoomDirectoryView_Night_0_en",20420,], -["features.roomdirectory.impl.root_RoomDirectoryView_Day_1_en","features.roomdirectory.impl.root_RoomDirectoryView_Night_1_en",20420,], -["features.roomdirectory.impl.root_RoomDirectoryView_Day_2_en","features.roomdirectory.impl.root_RoomDirectoryView_Night_2_en",20420,], -["features.roomdetails.impl.invite_RoomInviteMembersView_Day_0_en","features.roomdetails.impl.invite_RoomInviteMembersView_Night_0_en",20420,], -["features.roomdetails.impl.invite_RoomInviteMembersView_Day_1_en","features.roomdetails.impl.invite_RoomInviteMembersView_Night_1_en",20420,], -["features.roomdetails.impl.invite_RoomInviteMembersView_Day_2_en","features.roomdetails.impl.invite_RoomInviteMembersView_Night_2_en",20420,], -["features.roomdetails.impl.invite_RoomInviteMembersView_Day_3_en","features.roomdetails.impl.invite_RoomInviteMembersView_Night_3_en",20420,], -["features.home.impl.components_RoomListContentView_Day_0_en","features.home.impl.components_RoomListContentView_Night_0_en",20420,], -["features.home.impl.components_RoomListContentView_Day_1_en","features.home.impl.components_RoomListContentView_Night_1_en",20420,], +["features.roomaliasresolver.impl_RoomAliasResolverView_Day_1_en","features.roomaliasresolver.impl_RoomAliasResolverView_Night_1_en",20466,], +["features.roomaliasresolver.impl_RoomAliasResolverView_Day_2_en","features.roomaliasresolver.impl_RoomAliasResolverView_Night_2_en",20466,], +["features.roomdetails.impl_RoomDetailsA11y_en","",0,], +["features.roomdetails.impl_RoomDetailsDark_0_en","",20466,], +["features.roomdetails.impl_RoomDetailsDark_10_en","",20466,], +["features.roomdetails.impl_RoomDetailsDark_11_en","",20466,], +["features.roomdetails.impl_RoomDetailsDark_12_en","",20466,], +["features.roomdetails.impl_RoomDetailsDark_13_en","",20466,], +["features.roomdetails.impl_RoomDetailsDark_14_en","",20466,], +["features.roomdetails.impl_RoomDetailsDark_15_en","",20466,], +["features.roomdetails.impl_RoomDetailsDark_16_en","",20466,], +["features.roomdetails.impl_RoomDetailsDark_17_en","",20466,], +["features.roomdetails.impl_RoomDetailsDark_18_en","",20466,], +["features.roomdetails.impl_RoomDetailsDark_19_en","",20466,], +["features.roomdetails.impl_RoomDetailsDark_1_en","",20466,], +["features.roomdetails.impl_RoomDetailsDark_2_en","",20466,], +["features.roomdetails.impl_RoomDetailsDark_3_en","",20466,], +["features.roomdetails.impl_RoomDetailsDark_4_en","",20466,], +["features.roomdetails.impl_RoomDetailsDark_5_en","",20466,], +["features.roomdetails.impl_RoomDetailsDark_6_en","",20466,], +["features.roomdetails.impl_RoomDetailsDark_7_en","",20466,], +["features.roomdetails.impl_RoomDetailsDark_8_en","",20466,], +["features.roomdetails.impl_RoomDetailsDark_9_en","",20466,], +["features.roomdetailsedit.impl_RoomDetailsEditView_Day_0_en","features.roomdetailsedit.impl_RoomDetailsEditView_Night_0_en",20466,], +["features.roomdetailsedit.impl_RoomDetailsEditView_Day_1_en","features.roomdetailsedit.impl_RoomDetailsEditView_Night_1_en",20466,], +["features.roomdetailsedit.impl_RoomDetailsEditView_Day_2_en","features.roomdetailsedit.impl_RoomDetailsEditView_Night_2_en",20466,], +["features.roomdetailsedit.impl_RoomDetailsEditView_Day_3_en","features.roomdetailsedit.impl_RoomDetailsEditView_Night_3_en",20466,], +["features.roomdetailsedit.impl_RoomDetailsEditView_Day_4_en","features.roomdetailsedit.impl_RoomDetailsEditView_Night_4_en",20466,], +["features.roomdetailsedit.impl_RoomDetailsEditView_Day_5_en","features.roomdetailsedit.impl_RoomDetailsEditView_Night_5_en",20466,], +["features.roomdetailsedit.impl_RoomDetailsEditView_Day_6_en","features.roomdetailsedit.impl_RoomDetailsEditView_Night_6_en",20466,], +["features.roomdetailsedit.impl_RoomDetailsEditView_Day_7_en","features.roomdetailsedit.impl_RoomDetailsEditView_Night_7_en",20466,], +["features.roomdetailsedit.impl_RoomDetailsEditView_Day_8_en","features.roomdetailsedit.impl_RoomDetailsEditView_Night_8_en",20466,], +["features.roomdetailsedit.impl_RoomDetailsEditView_Day_9_en","features.roomdetailsedit.impl_RoomDetailsEditView_Night_9_en",20466,], +["features.roomdetails.impl_RoomDetails_0_en","",20466,], +["features.roomdetails.impl_RoomDetails_10_en","",20466,], +["features.roomdetails.impl_RoomDetails_11_en","",20466,], +["features.roomdetails.impl_RoomDetails_12_en","",20466,], +["features.roomdetails.impl_RoomDetails_13_en","",20466,], +["features.roomdetails.impl_RoomDetails_14_en","",20466,], +["features.roomdetails.impl_RoomDetails_15_en","",20466,], +["features.roomdetails.impl_RoomDetails_16_en","",20466,], +["features.roomdetails.impl_RoomDetails_17_en","",20466,], +["features.roomdetails.impl_RoomDetails_18_en","",20466,], +["features.roomdetails.impl_RoomDetails_19_en","",20466,], +["features.roomdetails.impl_RoomDetails_1_en","",20466,], +["features.roomdetails.impl_RoomDetails_2_en","",20466,], +["features.roomdetails.impl_RoomDetails_3_en","",20466,], +["features.roomdetails.impl_RoomDetails_4_en","",20466,], +["features.roomdetails.impl_RoomDetails_5_en","",20466,], +["features.roomdetails.impl_RoomDetails_6_en","",20466,], +["features.roomdetails.impl_RoomDetails_7_en","",20466,], +["features.roomdetails.impl_RoomDetails_8_en","",20466,], +["features.roomdetails.impl_RoomDetails_9_en","",20466,], +["features.roomdirectory.impl.root_RoomDirectoryView_Day_0_en","features.roomdirectory.impl.root_RoomDirectoryView_Night_0_en",20466,], +["features.roomdirectory.impl.root_RoomDirectoryView_Day_1_en","features.roomdirectory.impl.root_RoomDirectoryView_Night_1_en",20466,], +["features.roomdirectory.impl.root_RoomDirectoryView_Day_2_en","features.roomdirectory.impl.root_RoomDirectoryView_Night_2_en",20466,], +["features.roomdetails.impl.invite_RoomInviteMembersView_Day_0_en","features.roomdetails.impl.invite_RoomInviteMembersView_Night_0_en",20466,], +["features.roomdetails.impl.invite_RoomInviteMembersView_Day_1_en","features.roomdetails.impl.invite_RoomInviteMembersView_Night_1_en",20466,], +["features.roomdetails.impl.invite_RoomInviteMembersView_Day_2_en","features.roomdetails.impl.invite_RoomInviteMembersView_Night_2_en",20466,], +["features.roomdetails.impl.invite_RoomInviteMembersView_Day_3_en","features.roomdetails.impl.invite_RoomInviteMembersView_Night_3_en",20466,], +["features.home.impl.components_RoomListContentView_Day_0_en","features.home.impl.components_RoomListContentView_Night_0_en",20466,], +["features.home.impl.components_RoomListContentView_Day_1_en","features.home.impl.components_RoomListContentView_Night_1_en",20466,], ["features.home.impl.components_RoomListContentView_Day_2_en","features.home.impl.components_RoomListContentView_Night_2_en",0,], -["features.home.impl.components_RoomListContentView_Day_3_en","features.home.impl.components_RoomListContentView_Night_3_en",20420,], -["features.home.impl.components_RoomListContentView_Day_4_en","features.home.impl.components_RoomListContentView_Night_4_en",20420,], -["features.home.impl.components_RoomListContentView_Day_5_en","features.home.impl.components_RoomListContentView_Night_5_en",20420,], -["features.home.impl.roomlist_RoomListDeclineInviteMenuContent_Day_0_en","features.home.impl.roomlist_RoomListDeclineInviteMenuContent_Night_0_en",20420,], -["features.home.impl.filters_RoomListFiltersView_Day_0_en","features.home.impl.filters_RoomListFiltersView_Night_0_en",20420,], -["features.home.impl.filters_RoomListFiltersView_Day_1_en","features.home.impl.filters_RoomListFiltersView_Night_1_en",20420,], -["features.home.impl.roomlist_RoomListModalBottomSheetContent_Day_0_en","features.home.impl.roomlist_RoomListModalBottomSheetContent_Night_0_en",20420,], -["features.home.impl.roomlist_RoomListModalBottomSheetContent_Day_1_en","features.home.impl.roomlist_RoomListModalBottomSheetContent_Night_1_en",20420,], -["features.home.impl.roomlist_RoomListModalBottomSheetContent_Day_2_en","features.home.impl.roomlist_RoomListModalBottomSheetContent_Night_2_en",20420,], +["features.home.impl.components_RoomListContentView_Day_3_en","features.home.impl.components_RoomListContentView_Night_3_en",20466,], +["features.home.impl.components_RoomListContentView_Day_4_en","features.home.impl.components_RoomListContentView_Night_4_en",20466,], +["features.home.impl.components_RoomListContentView_Day_5_en","features.home.impl.components_RoomListContentView_Night_5_en",20466,], +["features.home.impl.roomlist_RoomListDeclineInviteMenuContent_Day_0_en","features.home.impl.roomlist_RoomListDeclineInviteMenuContent_Night_0_en",20466,], +["features.home.impl.filters_RoomListFiltersView_Day_0_en","features.home.impl.filters_RoomListFiltersView_Night_0_en",20466,], +["features.home.impl.filters_RoomListFiltersView_Day_1_en","features.home.impl.filters_RoomListFiltersView_Night_1_en",20466,], +["features.home.impl.roomlist_RoomListModalBottomSheetContent_Day_0_en","features.home.impl.roomlist_RoomListModalBottomSheetContent_Night_0_en",20466,], +["features.home.impl.roomlist_RoomListModalBottomSheetContent_Day_1_en","features.home.impl.roomlist_RoomListModalBottomSheetContent_Night_1_en",20466,], +["features.home.impl.roomlist_RoomListModalBottomSheetContent_Day_2_en","features.home.impl.roomlist_RoomListModalBottomSheetContent_Night_2_en",20466,], ["features.home.impl.search_RoomListSearchContent_Day_0_en","features.home.impl.search_RoomListSearchContent_Night_0_en",0,], -["features.home.impl.search_RoomListSearchContent_Day_1_en","features.home.impl.search_RoomListSearchContent_Night_1_en",20420,], -["features.roomdetails.impl.members_RoomMemberListView_Day_0_en","features.roomdetails.impl.members_RoomMemberListView_Night_0_en",20420,], -["features.roomdetails.impl.members_RoomMemberListView_Day_1_en","features.roomdetails.impl.members_RoomMemberListView_Night_1_en",20420,], -["features.roomdetails.impl.members_RoomMemberListView_Day_2_en","features.roomdetails.impl.members_RoomMemberListView_Night_2_en",20420,], -["features.roomdetails.impl.members_RoomMemberListView_Day_3_en","features.roomdetails.impl.members_RoomMemberListView_Night_3_en",20420,], -["features.roomdetails.impl.members_RoomMemberListView_Day_4_en","features.roomdetails.impl.members_RoomMemberListView_Night_4_en",20420,], -["features.roomdetails.impl.members_RoomMemberListView_Day_5_en","features.roomdetails.impl.members_RoomMemberListView_Night_5_en",20420,], -["features.roomdetails.impl.members_RoomMemberListView_Day_6_en","features.roomdetails.impl.members_RoomMemberListView_Night_6_en",0,], -["features.roommembermoderation.impl_RoomMemberModerationView_Day_0_en","features.roommembermoderation.impl_RoomMemberModerationView_Night_0_en",20420,], -["features.roommembermoderation.impl_RoomMemberModerationView_Day_1_en","features.roommembermoderation.impl_RoomMemberModerationView_Night_1_en",20420,], -["features.roommembermoderation.impl_RoomMemberModerationView_Day_2_en","features.roommembermoderation.impl_RoomMemberModerationView_Night_2_en",20420,], -["features.roommembermoderation.impl_RoomMemberModerationView_Day_3_en","features.roommembermoderation.impl_RoomMemberModerationView_Night_3_en",20420,], -["features.roommembermoderation.impl_RoomMemberModerationView_Day_4_en","features.roommembermoderation.impl_RoomMemberModerationView_Night_4_en",20420,], -["features.roommembermoderation.impl_RoomMemberModerationView_Day_5_en","features.roommembermoderation.impl_RoomMemberModerationView_Night_5_en",20420,], -["features.roommembermoderation.impl_RoomMemberModerationView_Day_6_en","features.roommembermoderation.impl_RoomMemberModerationView_Night_6_en",20420,], -["features.roommembermoderation.impl_RoomMemberModerationView_Day_7_en","features.roommembermoderation.impl_RoomMemberModerationView_Night_7_en",20420,], -["features.roommembermoderation.impl_RoomMemberModerationView_Day_8_en","features.roommembermoderation.impl_RoomMemberModerationView_Night_8_en",20420,], +["features.home.impl.search_RoomListSearchContent_Day_1_en","features.home.impl.search_RoomListSearchContent_Night_1_en",20466,], +["features.roomdetails.impl.members_RoomMemberListView_Day_0_en","features.roomdetails.impl.members_RoomMemberListView_Night_0_en",20466,], +["features.roomdetails.impl.members_RoomMemberListView_Day_1_en","features.roomdetails.impl.members_RoomMemberListView_Night_1_en",20466,], +["features.roomdetails.impl.members_RoomMemberListView_Day_2_en","features.roomdetails.impl.members_RoomMemberListView_Night_2_en",20466,], +["features.roomdetails.impl.members_RoomMemberListView_Day_3_en","features.roomdetails.impl.members_RoomMemberListView_Night_3_en",20466,], +["features.roomdetails.impl.members_RoomMemberListView_Day_4_en","features.roomdetails.impl.members_RoomMemberListView_Night_4_en",20466,], +["features.roomdetails.impl.members_RoomMemberListView_Day_5_en","features.roomdetails.impl.members_RoomMemberListView_Night_5_en",20466,], +["features.roomdetails.impl.members_RoomMemberListView_Day_6_en","features.roomdetails.impl.members_RoomMemberListView_Night_6_en",20466,], +["features.roommembermoderation.impl_RoomMemberModerationView_Day_0_en","features.roommembermoderation.impl_RoomMemberModerationView_Night_0_en",20466,], +["features.roommembermoderation.impl_RoomMemberModerationView_Day_1_en","features.roommembermoderation.impl_RoomMemberModerationView_Night_1_en",20466,], +["features.roommembermoderation.impl_RoomMemberModerationView_Day_2_en","features.roommembermoderation.impl_RoomMemberModerationView_Night_2_en",20466,], +["features.roommembermoderation.impl_RoomMemberModerationView_Day_3_en","features.roommembermoderation.impl_RoomMemberModerationView_Night_3_en",20466,], +["features.roommembermoderation.impl_RoomMemberModerationView_Day_4_en","features.roommembermoderation.impl_RoomMemberModerationView_Night_4_en",20466,], +["features.roommembermoderation.impl_RoomMemberModerationView_Day_5_en","features.roommembermoderation.impl_RoomMemberModerationView_Night_5_en",20466,], +["features.roommembermoderation.impl_RoomMemberModerationView_Day_6_en","features.roommembermoderation.impl_RoomMemberModerationView_Night_6_en",20466,], +["features.roommembermoderation.impl_RoomMemberModerationView_Day_7_en","features.roommembermoderation.impl_RoomMemberModerationView_Night_7_en",20466,], +["features.roommembermoderation.impl_RoomMemberModerationView_Day_8_en","features.roommembermoderation.impl_RoomMemberModerationView_Night_8_en",20466,], ["features.roommembermoderation.impl_RoomMemberModerationView_Day_9_en","features.roommembermoderation.impl_RoomMemberModerationView_Night_9_en",0,], -["features.roomdetails.impl.notificationsettings_RoomNotificationSettingsOption_Day_0_en","features.roomdetails.impl.notificationsettings_RoomNotificationSettingsOption_Night_0_en",20420,], -["features.roomdetails.impl.notificationsettings_RoomNotificationSettingsView_Day_0_en","features.roomdetails.impl.notificationsettings_RoomNotificationSettingsView_Night_0_en",20420,], -["features.roomdetails.impl.notificationsettings_RoomNotificationSettingsView_Day_1_en","features.roomdetails.impl.notificationsettings_RoomNotificationSettingsView_Night_1_en",20420,], -["features.roomdetails.impl.notificationsettings_RoomNotificationSettingsView_Day_2_en","features.roomdetails.impl.notificationsettings_RoomNotificationSettingsView_Night_2_en",20420,], -["features.roomdetails.impl.notificationsettings_RoomNotificationSettingsView_Day_3_en","features.roomdetails.impl.notificationsettings_RoomNotificationSettingsView_Night_3_en",20420,], -["features.roomdetails.impl.notificationsettings_RoomNotificationSettingsView_Day_4_en","features.roomdetails.impl.notificationsettings_RoomNotificationSettingsView_Night_4_en",20420,], -["features.roomdetails.impl.notificationsettings_RoomNotificationSettingsView_Day_5_en","features.roomdetails.impl.notificationsettings_RoomNotificationSettingsView_Night_5_en",20420,], -["features.roomdetails.impl.notificationsettings_RoomNotificationSettingsView_Day_6_en","features.roomdetails.impl.notificationsettings_RoomNotificationSettingsView_Night_6_en",20420,], -["libraries.roomselect.impl_RoomSelectView_Day_0_en","libraries.roomselect.impl_RoomSelectView_Night_0_en",20420,], -["libraries.roomselect.impl_RoomSelectView_Day_1_en","libraries.roomselect.impl_RoomSelectView_Night_1_en",20420,], -["libraries.roomselect.impl_RoomSelectView_Day_2_en","libraries.roomselect.impl_RoomSelectView_Night_2_en",20420,], -["libraries.roomselect.impl_RoomSelectView_Day_3_en","libraries.roomselect.impl_RoomSelectView_Night_3_en",20420,], -["libraries.roomselect.impl_RoomSelectView_Day_4_en","libraries.roomselect.impl_RoomSelectView_Night_4_en",20420,], -["libraries.roomselect.impl_RoomSelectView_Day_5_en","libraries.roomselect.impl_RoomSelectView_Night_5_en",20420,], +["features.roomdetails.impl.notificationsettings_RoomNotificationSettingsOption_Day_0_en","features.roomdetails.impl.notificationsettings_RoomNotificationSettingsOption_Night_0_en",20466,], +["features.roomdetails.impl.notificationsettings_RoomNotificationSettingsView_Day_0_en","features.roomdetails.impl.notificationsettings_RoomNotificationSettingsView_Night_0_en",20466,], +["features.roomdetails.impl.notificationsettings_RoomNotificationSettingsView_Day_1_en","features.roomdetails.impl.notificationsettings_RoomNotificationSettingsView_Night_1_en",20466,], +["features.roomdetails.impl.notificationsettings_RoomNotificationSettingsView_Day_2_en","features.roomdetails.impl.notificationsettings_RoomNotificationSettingsView_Night_2_en",20466,], +["features.roomdetails.impl.notificationsettings_RoomNotificationSettingsView_Day_3_en","features.roomdetails.impl.notificationsettings_RoomNotificationSettingsView_Night_3_en",20466,], +["features.roomdetails.impl.notificationsettings_RoomNotificationSettingsView_Day_4_en","features.roomdetails.impl.notificationsettings_RoomNotificationSettingsView_Night_4_en",20466,], +["features.roomdetails.impl.notificationsettings_RoomNotificationSettingsView_Day_5_en","features.roomdetails.impl.notificationsettings_RoomNotificationSettingsView_Night_5_en",20466,], +["features.roomdetails.impl.notificationsettings_RoomNotificationSettingsView_Day_6_en","features.roomdetails.impl.notificationsettings_RoomNotificationSettingsView_Night_6_en",20466,], +["libraries.roomselect.impl_RoomSelectView_Day_0_en","libraries.roomselect.impl_RoomSelectView_Night_0_en",20466,], +["libraries.roomselect.impl_RoomSelectView_Day_1_en","libraries.roomselect.impl_RoomSelectView_Night_1_en",20466,], +["libraries.roomselect.impl_RoomSelectView_Day_2_en","libraries.roomselect.impl_RoomSelectView_Night_2_en",20466,], +["libraries.roomselect.impl_RoomSelectView_Day_3_en","libraries.roomselect.impl_RoomSelectView_Night_3_en",20466,], +["libraries.roomselect.impl_RoomSelectView_Day_4_en","libraries.roomselect.impl_RoomSelectView_Night_4_en",20466,], +["libraries.roomselect.impl_RoomSelectView_Day_5_en","libraries.roomselect.impl_RoomSelectView_Night_5_en",20466,], ["features.home.impl.components_RoomSummaryPlaceholderRow_Day_0_en","features.home.impl.components_RoomSummaryPlaceholderRow_Night_0_en",0,], ["features.home.impl.components_RoomSummaryRow_Day_0_en","features.home.impl.components_RoomSummaryRow_Night_0_en",0,], ["features.home.impl.components_RoomSummaryRow_Day_10_en","features.home.impl.components_RoomSummaryRow_Night_10_en",0,], @@ -1023,14 +1065,16 @@ export const screenshots = [ ["features.home.impl.components_RoomSummaryRow_Day_26_en","features.home.impl.components_RoomSummaryRow_Night_26_en",0,], ["features.home.impl.components_RoomSummaryRow_Day_27_en","features.home.impl.components_RoomSummaryRow_Night_27_en",0,], ["features.home.impl.components_RoomSummaryRow_Day_28_en","features.home.impl.components_RoomSummaryRow_Night_28_en",0,], -["features.home.impl.components_RoomSummaryRow_Day_29_en","features.home.impl.components_RoomSummaryRow_Night_29_en",20420,], -["features.home.impl.components_RoomSummaryRow_Day_2_en","features.home.impl.components_RoomSummaryRow_Night_2_en",20420,], -["features.home.impl.components_RoomSummaryRow_Day_30_en","features.home.impl.components_RoomSummaryRow_Night_30_en",20420,], -["features.home.impl.components_RoomSummaryRow_Day_31_en","features.home.impl.components_RoomSummaryRow_Night_31_en",20420,], -["features.home.impl.components_RoomSummaryRow_Day_32_en","features.home.impl.components_RoomSummaryRow_Night_32_en",20420,], -["features.home.impl.components_RoomSummaryRow_Day_33_en","features.home.impl.components_RoomSummaryRow_Night_33_en",20420,], -["features.home.impl.components_RoomSummaryRow_Day_34_en","features.home.impl.components_RoomSummaryRow_Night_34_en",20420,], -["features.home.impl.components_RoomSummaryRow_Day_35_en","features.home.impl.components_RoomSummaryRow_Night_35_en",20420,], +["features.home.impl.components_RoomSummaryRow_Day_29_en","features.home.impl.components_RoomSummaryRow_Night_29_en",20466,], +["features.home.impl.components_RoomSummaryRow_Day_2_en","features.home.impl.components_RoomSummaryRow_Night_2_en",20466,], +["features.home.impl.components_RoomSummaryRow_Day_30_en","features.home.impl.components_RoomSummaryRow_Night_30_en",20466,], +["features.home.impl.components_RoomSummaryRow_Day_31_en","features.home.impl.components_RoomSummaryRow_Night_31_en",20466,], +["features.home.impl.components_RoomSummaryRow_Day_32_en","features.home.impl.components_RoomSummaryRow_Night_32_en",20466,], +["features.home.impl.components_RoomSummaryRow_Day_33_en","features.home.impl.components_RoomSummaryRow_Night_33_en",20466,], +["features.home.impl.components_RoomSummaryRow_Day_34_en","features.home.impl.components_RoomSummaryRow_Night_34_en",20466,], +["features.home.impl.components_RoomSummaryRow_Day_35_en","features.home.impl.components_RoomSummaryRow_Night_35_en",20466,], +["features.home.impl.components_RoomSummaryRow_Day_36_en","features.home.impl.components_RoomSummaryRow_Night_36_en",0,], +["features.home.impl.components_RoomSummaryRow_Day_37_en","features.home.impl.components_RoomSummaryRow_Night_37_en",20467,], ["features.home.impl.components_RoomSummaryRow_Day_3_en","features.home.impl.components_RoomSummaryRow_Night_3_en",0,], ["features.home.impl.components_RoomSummaryRow_Day_4_en","features.home.impl.components_RoomSummaryRow_Night_4_en",0,], ["features.home.impl.components_RoomSummaryRow_Day_5_en","features.home.impl.components_RoomSummaryRow_Night_5_en",0,], @@ -1038,105 +1082,117 @@ export const screenshots = [ ["features.home.impl.components_RoomSummaryRow_Day_7_en","features.home.impl.components_RoomSummaryRow_Night_7_en",0,], ["features.home.impl.components_RoomSummaryRow_Day_8_en","features.home.impl.components_RoomSummaryRow_Night_8_en",0,], ["features.home.impl.components_RoomSummaryRow_Day_9_en","features.home.impl.components_RoomSummaryRow_Night_9_en",0,], -["appnav.root_RootView_Day_0_en","appnav.root_RootView_Night_0_en",20420,], -["appnav.root_RootView_Day_1_en","appnav.root_RootView_Night_1_en",20420,], -["appnav.root_RootView_Day_2_en","appnav.root_RootView_Night_2_en",20420,], +["appnav.root_RootView_Day_0_en","appnav.root_RootView_Night_0_en",20466,], +["appnav.root_RootView_Day_1_en","appnav.root_RootView_Night_1_en",20466,], +["appnav.root_RootView_Day_2_en","appnav.root_RootView_Night_2_en",20466,], ["appicon.enterprise_RoundIcon_en","",0,], ["appicon.element_RoundIcon_en","",0,], ["libraries.designsystem.atomic.atoms_RoundedIconAtom_Day_0_en","libraries.designsystem.atomic.atoms_RoundedIconAtom_Night_0_en",0,], -["features.verifysession.impl.emoji_SasEmojis_Day_0_en","features.verifysession.impl.emoji_SasEmojis_Night_0_en",20420,], -["libraries.designsystem.components.dialogs_SaveChangesDialog_Day_0_en","libraries.designsystem.components.dialogs_SaveChangesDialog_Night_0_en",20420,], -["features.login.impl.screens.searchaccountprovider_SearchAccountProviderView_Day_0_en","features.login.impl.screens.searchaccountprovider_SearchAccountProviderView_Night_0_en",20420,], -["features.login.impl.screens.searchaccountprovider_SearchAccountProviderView_Day_1_en","features.login.impl.screens.searchaccountprovider_SearchAccountProviderView_Night_1_en",20420,], +["features.verifysession.impl.emoji_SasEmojis_Day_0_en","features.verifysession.impl.emoji_SasEmojis_Night_0_en",20466,], +["libraries.designsystem.components.dialogs_SaveChangesDialog_Day_0_en","libraries.designsystem.components.dialogs_SaveChangesDialog_Night_0_en",20466,], +["features.linknewdevice.impl.screens.scan_ScanQrCodeView_Day_0_en","features.linknewdevice.impl.screens.scan_ScanQrCodeView_Night_0_en",20466,], +["features.linknewdevice.impl.screens.scan_ScanQrCodeView_Day_1_en","features.linknewdevice.impl.screens.scan_ScanQrCodeView_Night_1_en",20466,], +["features.linknewdevice.impl.screens.scan_ScanQrCodeView_Day_2_en","features.linknewdevice.impl.screens.scan_ScanQrCodeView_Night_2_en",20466,], +["features.linknewdevice.impl.screens.scan_ScanQrCodeView_Day_3_en","features.linknewdevice.impl.screens.scan_ScanQrCodeView_Night_3_en",20466,], +["features.login.impl.screens.searchaccountprovider_SearchAccountProviderView_Day_0_en","features.login.impl.screens.searchaccountprovider_SearchAccountProviderView_Night_0_en",20466,], +["features.login.impl.screens.searchaccountprovider_SearchAccountProviderView_Day_1_en","features.login.impl.screens.searchaccountprovider_SearchAccountProviderView_Night_1_en",20466,], ["libraries.designsystem.theme.components_SearchBarActiveNoneQuery_Search_views_en","",0,], ["libraries.designsystem.theme.components_SearchBarActiveWithContent_Search_views_en","",0,], -["libraries.designsystem.theme.components_SearchBarActiveWithNoResults_Search_views_en","",20420,], +["libraries.designsystem.theme.components_SearchBarActiveWithNoResults_Search_views_en","",20466,], ["libraries.designsystem.theme.components_SearchBarActiveWithQueryNoBackButton_Search_views_en","",0,], ["libraries.designsystem.theme.components_SearchBarActiveWithQuery_Search_views_en","",0,], ["libraries.designsystem.theme.components_SearchBarInactive_Search_views_en","",0,], ["libraries.designsystem.theme.components_SearchFieldsDark_Search_views_en","",0,], ["libraries.designsystem.theme.components_SearchFieldsLight_Search_views_en","",0,], -["features.startchat.impl.components_SearchMultipleUsersResultItem_en","",20420,], -["features.startchat.impl.components_SearchSingleUserResultItem_en","",20420,], -["features.securebackup.impl.disable_SecureBackupDisableView_Day_0_en","features.securebackup.impl.disable_SecureBackupDisableView_Night_0_en",20420,], -["features.securebackup.impl.disable_SecureBackupDisableView_Day_1_en","features.securebackup.impl.disable_SecureBackupDisableView_Night_1_en",20420,], -["features.securebackup.impl.disable_SecureBackupDisableView_Day_2_en","features.securebackup.impl.disable_SecureBackupDisableView_Night_2_en",20420,], -["features.securebackup.impl.disable_SecureBackupDisableView_Day_3_en","features.securebackup.impl.disable_SecureBackupDisableView_Night_3_en",20420,], -["features.securebackup.impl.enter_SecureBackupEnterRecoveryKeyView_Day_0_en","features.securebackup.impl.enter_SecureBackupEnterRecoveryKeyView_Night_0_en",20420,], -["features.securebackup.impl.enter_SecureBackupEnterRecoveryKeyView_Day_1_en","features.securebackup.impl.enter_SecureBackupEnterRecoveryKeyView_Night_1_en",20420,], -["features.securebackup.impl.enter_SecureBackupEnterRecoveryKeyView_Day_2_en","features.securebackup.impl.enter_SecureBackupEnterRecoveryKeyView_Night_2_en",20420,], -["features.securebackup.impl.enter_SecureBackupEnterRecoveryKeyView_Day_3_en","features.securebackup.impl.enter_SecureBackupEnterRecoveryKeyView_Night_3_en",20420,], -["features.securebackup.impl.enter_SecureBackupEnterRecoveryKeyView_Day_4_en","features.securebackup.impl.enter_SecureBackupEnterRecoveryKeyView_Night_4_en",20420,], -["features.securebackup.impl.root_SecureBackupRootView_Day_0_en","features.securebackup.impl.root_SecureBackupRootView_Night_0_en",20420,], -["features.securebackup.impl.root_SecureBackupRootView_Day_10_en","features.securebackup.impl.root_SecureBackupRootView_Night_10_en",20420,], -["features.securebackup.impl.root_SecureBackupRootView_Day_11_en","features.securebackup.impl.root_SecureBackupRootView_Night_11_en",20420,], -["features.securebackup.impl.root_SecureBackupRootView_Day_12_en","features.securebackup.impl.root_SecureBackupRootView_Night_12_en",20420,], -["features.securebackup.impl.root_SecureBackupRootView_Day_13_en","features.securebackup.impl.root_SecureBackupRootView_Night_13_en",20420,], -["features.securebackup.impl.root_SecureBackupRootView_Day_14_en","features.securebackup.impl.root_SecureBackupRootView_Night_14_en",20420,], -["features.securebackup.impl.root_SecureBackupRootView_Day_15_en","features.securebackup.impl.root_SecureBackupRootView_Night_15_en",20420,], -["features.securebackup.impl.root_SecureBackupRootView_Day_16_en","features.securebackup.impl.root_SecureBackupRootView_Night_16_en",20420,], -["features.securebackup.impl.root_SecureBackupRootView_Day_17_en","features.securebackup.impl.root_SecureBackupRootView_Night_17_en",20420,], -["features.securebackup.impl.root_SecureBackupRootView_Day_1_en","features.securebackup.impl.root_SecureBackupRootView_Night_1_en",20420,], -["features.securebackup.impl.root_SecureBackupRootView_Day_2_en","features.securebackup.impl.root_SecureBackupRootView_Night_2_en",20420,], -["features.securebackup.impl.root_SecureBackupRootView_Day_3_en","features.securebackup.impl.root_SecureBackupRootView_Night_3_en",20420,], -["features.securebackup.impl.root_SecureBackupRootView_Day_4_en","features.securebackup.impl.root_SecureBackupRootView_Night_4_en",20420,], -["features.securebackup.impl.root_SecureBackupRootView_Day_5_en","features.securebackup.impl.root_SecureBackupRootView_Night_5_en",20420,], -["features.securebackup.impl.root_SecureBackupRootView_Day_6_en","features.securebackup.impl.root_SecureBackupRootView_Night_6_en",20420,], -["features.securebackup.impl.root_SecureBackupRootView_Day_7_en","features.securebackup.impl.root_SecureBackupRootView_Night_7_en",20420,], -["features.securebackup.impl.root_SecureBackupRootView_Day_8_en","features.securebackup.impl.root_SecureBackupRootView_Night_8_en",20420,], -["features.securebackup.impl.root_SecureBackupRootView_Day_9_en","features.securebackup.impl.root_SecureBackupRootView_Night_9_en",20420,], -["features.securebackup.impl.setup_SecureBackupSetupViewChange_Day_0_en","features.securebackup.impl.setup_SecureBackupSetupViewChange_Night_0_en",20420,], -["features.securebackup.impl.setup_SecureBackupSetupViewChange_Day_1_en","features.securebackup.impl.setup_SecureBackupSetupViewChange_Night_1_en",20420,], -["features.securebackup.impl.setup_SecureBackupSetupViewChange_Day_2_en","features.securebackup.impl.setup_SecureBackupSetupViewChange_Night_2_en",20420,], -["features.securebackup.impl.setup_SecureBackupSetupViewChange_Day_3_en","features.securebackup.impl.setup_SecureBackupSetupViewChange_Night_3_en",20420,], -["features.securebackup.impl.setup_SecureBackupSetupViewChange_Day_4_en","features.securebackup.impl.setup_SecureBackupSetupViewChange_Night_4_en",20420,], -["features.securebackup.impl.setup_SecureBackupSetupViewChange_Day_5_en","features.securebackup.impl.setup_SecureBackupSetupViewChange_Night_5_en",20420,], -["features.securebackup.impl.setup_SecureBackupSetupView_Day_0_en","features.securebackup.impl.setup_SecureBackupSetupView_Night_0_en",20420,], -["features.securebackup.impl.setup_SecureBackupSetupView_Day_1_en","features.securebackup.impl.setup_SecureBackupSetupView_Night_1_en",20420,], -["features.securebackup.impl.setup_SecureBackupSetupView_Day_2_en","features.securebackup.impl.setup_SecureBackupSetupView_Night_2_en",20420,], -["features.securebackup.impl.setup_SecureBackupSetupView_Day_3_en","features.securebackup.impl.setup_SecureBackupSetupView_Night_3_en",20420,], -["features.securebackup.impl.setup_SecureBackupSetupView_Day_4_en","features.securebackup.impl.setup_SecureBackupSetupView_Night_4_en",20420,], -["features.securebackup.impl.setup_SecureBackupSetupView_Day_5_en","features.securebackup.impl.setup_SecureBackupSetupView_Night_5_en",20420,], -["features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_0_en","",0,], -["features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_10_en","",0,], -["features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_11_en","",0,], -["features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_12_en","",0,], -["features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_13_en","",0,], -["features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_14_en","",0,], -["features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_15_en","",0,], -["features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_16_en","",0,], -["features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_17_en","",0,], -["features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_18_en","",0,], -["features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_19_en","",0,], -["features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_1_en","",0,], -["features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_2_en","",0,], -["features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_3_en","",0,], -["features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_4_en","",0,], -["features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_5_en","",0,], -["features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_6_en","",0,], -["features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_7_en","",0,], -["features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_8_en","",0,], -["features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_9_en","",0,], -["features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_0_en","",0,], -["features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_10_en","",0,], -["features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_11_en","",0,], -["features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_12_en","",0,], -["features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_13_en","",0,], -["features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_14_en","",0,], -["features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_15_en","",0,], -["features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_16_en","",0,], -["features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_17_en","",0,], -["features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_18_en","",0,], -["features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_19_en","",0,], -["features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_1_en","",0,], -["features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_2_en","",0,], -["features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_3_en","",0,], -["features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_4_en","",0,], -["features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_5_en","",0,], -["features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_6_en","",0,], -["features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_7_en","",0,], -["features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_8_en","",0,], -["features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_9_en","",0,], +["features.startchat.impl.components_SearchMultipleUsersResultItem_en","",20466,], +["features.startchat.impl.components_SearchSingleUserResultItem_en","",20466,], +["features.securebackup.impl.disable_SecureBackupDisableView_Day_0_en","features.securebackup.impl.disable_SecureBackupDisableView_Night_0_en",20466,], +["features.securebackup.impl.disable_SecureBackupDisableView_Day_1_en","features.securebackup.impl.disable_SecureBackupDisableView_Night_1_en",20466,], +["features.securebackup.impl.disable_SecureBackupDisableView_Day_2_en","features.securebackup.impl.disable_SecureBackupDisableView_Night_2_en",20466,], +["features.securebackup.impl.disable_SecureBackupDisableView_Day_3_en","features.securebackup.impl.disable_SecureBackupDisableView_Night_3_en",20466,], +["features.securebackup.impl.enter_SecureBackupEnterRecoveryKeyView_Day_0_en","features.securebackup.impl.enter_SecureBackupEnterRecoveryKeyView_Night_0_en",20466,], +["features.securebackup.impl.enter_SecureBackupEnterRecoveryKeyView_Day_1_en","features.securebackup.impl.enter_SecureBackupEnterRecoveryKeyView_Night_1_en",20466,], +["features.securebackup.impl.enter_SecureBackupEnterRecoveryKeyView_Day_2_en","features.securebackup.impl.enter_SecureBackupEnterRecoveryKeyView_Night_2_en",20466,], +["features.securebackup.impl.enter_SecureBackupEnterRecoveryKeyView_Day_3_en","features.securebackup.impl.enter_SecureBackupEnterRecoveryKeyView_Night_3_en",20466,], +["features.securebackup.impl.enter_SecureBackupEnterRecoveryKeyView_Day_4_en","features.securebackup.impl.enter_SecureBackupEnterRecoveryKeyView_Night_4_en",20466,], +["features.securebackup.impl.root_SecureBackupRootView_Day_0_en","features.securebackup.impl.root_SecureBackupRootView_Night_0_en",20466,], +["features.securebackup.impl.root_SecureBackupRootView_Day_10_en","features.securebackup.impl.root_SecureBackupRootView_Night_10_en",20466,], +["features.securebackup.impl.root_SecureBackupRootView_Day_11_en","features.securebackup.impl.root_SecureBackupRootView_Night_11_en",20466,], +["features.securebackup.impl.root_SecureBackupRootView_Day_12_en","features.securebackup.impl.root_SecureBackupRootView_Night_12_en",20466,], +["features.securebackup.impl.root_SecureBackupRootView_Day_13_en","features.securebackup.impl.root_SecureBackupRootView_Night_13_en",20466,], +["features.securebackup.impl.root_SecureBackupRootView_Day_14_en","features.securebackup.impl.root_SecureBackupRootView_Night_14_en",20466,], +["features.securebackup.impl.root_SecureBackupRootView_Day_15_en","features.securebackup.impl.root_SecureBackupRootView_Night_15_en",20466,], +["features.securebackup.impl.root_SecureBackupRootView_Day_16_en","features.securebackup.impl.root_SecureBackupRootView_Night_16_en",20466,], +["features.securebackup.impl.root_SecureBackupRootView_Day_17_en","features.securebackup.impl.root_SecureBackupRootView_Night_17_en",20466,], +["features.securebackup.impl.root_SecureBackupRootView_Day_1_en","features.securebackup.impl.root_SecureBackupRootView_Night_1_en",20466,], +["features.securebackup.impl.root_SecureBackupRootView_Day_2_en","features.securebackup.impl.root_SecureBackupRootView_Night_2_en",20466,], +["features.securebackup.impl.root_SecureBackupRootView_Day_3_en","features.securebackup.impl.root_SecureBackupRootView_Night_3_en",20466,], +["features.securebackup.impl.root_SecureBackupRootView_Day_4_en","features.securebackup.impl.root_SecureBackupRootView_Night_4_en",20466,], +["features.securebackup.impl.root_SecureBackupRootView_Day_5_en","features.securebackup.impl.root_SecureBackupRootView_Night_5_en",20466,], +["features.securebackup.impl.root_SecureBackupRootView_Day_6_en","features.securebackup.impl.root_SecureBackupRootView_Night_6_en",20466,], +["features.securebackup.impl.root_SecureBackupRootView_Day_7_en","features.securebackup.impl.root_SecureBackupRootView_Night_7_en",20466,], +["features.securebackup.impl.root_SecureBackupRootView_Day_8_en","features.securebackup.impl.root_SecureBackupRootView_Night_8_en",20466,], +["features.securebackup.impl.root_SecureBackupRootView_Day_9_en","features.securebackup.impl.root_SecureBackupRootView_Night_9_en",20466,], +["features.securebackup.impl.setup_SecureBackupSetupViewChange_Day_0_en","features.securebackup.impl.setup_SecureBackupSetupViewChange_Night_0_en",20466,], +["features.securebackup.impl.setup_SecureBackupSetupViewChange_Day_1_en","features.securebackup.impl.setup_SecureBackupSetupViewChange_Night_1_en",20466,], +["features.securebackup.impl.setup_SecureBackupSetupViewChange_Day_2_en","features.securebackup.impl.setup_SecureBackupSetupViewChange_Night_2_en",20466,], +["features.securebackup.impl.setup_SecureBackupSetupViewChange_Day_3_en","features.securebackup.impl.setup_SecureBackupSetupViewChange_Night_3_en",20466,], +["features.securebackup.impl.setup_SecureBackupSetupViewChange_Day_4_en","features.securebackup.impl.setup_SecureBackupSetupViewChange_Night_4_en",20466,], +["features.securebackup.impl.setup_SecureBackupSetupViewChange_Day_5_en","features.securebackup.impl.setup_SecureBackupSetupViewChange_Night_5_en",20466,], +["features.securebackup.impl.setup_SecureBackupSetupView_Day_0_en","features.securebackup.impl.setup_SecureBackupSetupView_Night_0_en",20466,], +["features.securebackup.impl.setup_SecureBackupSetupView_Day_1_en","features.securebackup.impl.setup_SecureBackupSetupView_Night_1_en",20466,], +["features.securebackup.impl.setup_SecureBackupSetupView_Day_2_en","features.securebackup.impl.setup_SecureBackupSetupView_Night_2_en",20466,], +["features.securebackup.impl.setup_SecureBackupSetupView_Day_3_en","features.securebackup.impl.setup_SecureBackupSetupView_Night_3_en",20466,], +["features.securebackup.impl.setup_SecureBackupSetupView_Day_4_en","features.securebackup.impl.setup_SecureBackupSetupView_Night_4_en",20466,], +["features.securebackup.impl.setup_SecureBackupSetupView_Day_5_en","features.securebackup.impl.setup_SecureBackupSetupView_Night_5_en",20466,], +["features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_0_en","",20466,], +["features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_10_en","",20466,], +["features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_11_en","",20466,], +["features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_12_en","",20466,], +["features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_13_en","",20466,], +["features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_14_en","",20466,], +["features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_15_en","",20466,], +["features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_16_en","",20466,], +["features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_17_en","",20466,], +["features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_18_en","",20466,], +["features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_19_en","",20466,], +["features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_1_en","",20466,], +["features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_20_en","",20467,], +["features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_21_en","",20467,], +["features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_22_en","",20467,], +["features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_23_en","",20467,], +["features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_2_en","",20466,], +["features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_3_en","",20466,], +["features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_4_en","",20466,], +["features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_5_en","",20466,], +["features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_6_en","",20466,], +["features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_7_en","",20466,], +["features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_8_en","",20466,], +["features.securityandprivacy.impl.root_SecurityAndPrivacyViewDark_9_en","",20466,], +["features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_0_en","",20466,], +["features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_10_en","",20466,], +["features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_11_en","",20466,], +["features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_12_en","",20466,], +["features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_13_en","",20466,], +["features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_14_en","",20466,], +["features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_15_en","",20466,], +["features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_16_en","",20466,], +["features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_17_en","",20466,], +["features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_18_en","",20466,], +["features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_19_en","",20466,], +["features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_1_en","",20466,], +["features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_20_en","",20467,], +["features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_21_en","",20467,], +["features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_22_en","",20467,], +["features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_23_en","",20467,], +["features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_2_en","",20466,], +["features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_3_en","",20466,], +["features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_4_en","",20466,], +["features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_5_en","",20466,], +["features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_6_en","",20466,], +["features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_7_en","",20466,], +["features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_8_en","",20466,], +["features.securityandprivacy.impl.root_SecurityAndPrivacyViewLight_9_en","",20466,], ["libraries.designsystem.atomic.atoms_SelectedIndicatorAtom_Day_0_en","libraries.designsystem.atomic.atoms_SelectedIndicatorAtom_Night_0_en",0,], ["libraries.matrix.ui.components_SelectedRoomRtl_Day_0_en","libraries.matrix.ui.components_SelectedRoomRtl_Night_0_en",0,], ["libraries.matrix.ui.components_SelectedRoomRtl_Day_1_en","libraries.matrix.ui.components_SelectedRoomRtl_Night_1_en",0,], @@ -1149,12 +1205,12 @@ export const screenshots = [ ["libraries.matrix.ui.components_SelectedUser_Day_0_en","libraries.matrix.ui.components_SelectedUser_Night_0_en",0,], ["libraries.matrix.ui.components_SelectedUser_Day_1_en","libraries.matrix.ui.components_SelectedUser_Night_1_en",0,], ["libraries.matrix.ui.components_SelectedUsersRowList_Day_0_en","libraries.matrix.ui.components_SelectedUsersRowList_Night_0_en",0,], -["libraries.textcomposer.components_SendButton_Day_0_en","libraries.textcomposer.components_SendButton_Night_0_en",0,], -["features.location.impl.send_SendLocationView_Day_0_en","features.location.impl.send_SendLocationView_Night_0_en",20420,], -["features.location.impl.send_SendLocationView_Day_1_en","features.location.impl.send_SendLocationView_Night_1_en",20420,], -["features.location.impl.send_SendLocationView_Day_2_en","features.location.impl.send_SendLocationView_Night_2_en",20420,], -["features.location.impl.send_SendLocationView_Day_3_en","features.location.impl.send_SendLocationView_Night_3_en",20420,], -["features.location.impl.send_SendLocationView_Day_4_en","features.location.impl.send_SendLocationView_Night_4_en",20420,], +["libraries.textcomposer.components_SendButtonIcon_Day_0_en","libraries.textcomposer.components_SendButtonIcon_Night_0_en",0,], +["features.location.impl.send_SendLocationView_Day_0_en","features.location.impl.send_SendLocationView_Night_0_en",20466,], +["features.location.impl.send_SendLocationView_Day_1_en","features.location.impl.send_SendLocationView_Night_1_en",20466,], +["features.location.impl.send_SendLocationView_Day_2_en","features.location.impl.send_SendLocationView_Night_2_en",20466,], +["features.location.impl.send_SendLocationView_Day_3_en","features.location.impl.send_SendLocationView_Night_3_en",20466,], +["features.location.impl.send_SendLocationView_Day_4_en","features.location.impl.send_SendLocationView_Night_4_en",20466,], ["libraries.matrix.ui.messages.sender_SenderName_Day_0_en","libraries.matrix.ui.messages.sender_SenderName_Night_0_en",0,], ["libraries.matrix.ui.messages.sender_SenderName_Day_1_en","libraries.matrix.ui.messages.sender_SenderName_Night_1_en",0,], ["libraries.matrix.ui.messages.sender_SenderName_Day_2_en","libraries.matrix.ui.messages.sender_SenderName_Night_2_en",0,], @@ -1164,27 +1220,28 @@ export const screenshots = [ ["libraries.matrix.ui.messages.sender_SenderName_Day_6_en","libraries.matrix.ui.messages.sender_SenderName_Night_6_en",0,], ["libraries.matrix.ui.messages.sender_SenderName_Day_7_en","libraries.matrix.ui.messages.sender_SenderName_Night_7_en",0,], ["libraries.matrix.ui.messages.sender_SenderName_Day_8_en","libraries.matrix.ui.messages.sender_SenderName_Night_8_en",0,], -["features.verifysession.impl.incoming.ui_SessionDetailsView_Day_0_en","features.verifysession.impl.incoming.ui_SessionDetailsView_Night_0_en",20420,], -["features.home.impl.components_SetUpRecoveryKeyBanner_Day_0_en","features.home.impl.components_SetUpRecoveryKeyBanner_Night_0_en",20420,], -["features.lockscreen.impl.setup.biometric_SetupBiometricView_Day_0_en","features.lockscreen.impl.setup.biometric_SetupBiometricView_Night_0_en",20420,], -["features.lockscreen.impl.setup.pin_SetupPinView_Day_0_en","features.lockscreen.impl.setup.pin_SetupPinView_Night_0_en",20420,], -["features.lockscreen.impl.setup.pin_SetupPinView_Day_1_en","features.lockscreen.impl.setup.pin_SetupPinView_Night_1_en",20420,], -["features.lockscreen.impl.setup.pin_SetupPinView_Day_2_en","features.lockscreen.impl.setup.pin_SetupPinView_Night_2_en",20420,], -["features.lockscreen.impl.setup.pin_SetupPinView_Day_3_en","features.lockscreen.impl.setup.pin_SetupPinView_Night_3_en",20420,], -["features.lockscreen.impl.setup.pin_SetupPinView_Day_4_en","features.lockscreen.impl.setup.pin_SetupPinView_Night_4_en",20420,], +["features.verifysession.impl.incoming.ui_SessionDetailsView_Day_0_en","features.verifysession.impl.incoming.ui_SessionDetailsView_Night_0_en",20466,], +["features.home.impl.components_SetUpRecoveryKeyBanner_Day_0_en","features.home.impl.components_SetUpRecoveryKeyBanner_Night_0_en",20466,], +["features.lockscreen.impl.setup.biometric_SetupBiometricView_Day_0_en","features.lockscreen.impl.setup.biometric_SetupBiometricView_Night_0_en",20466,], +["features.lockscreen.impl.setup.pin_SetupPinView_Day_0_en","features.lockscreen.impl.setup.pin_SetupPinView_Night_0_en",20466,], +["features.lockscreen.impl.setup.pin_SetupPinView_Day_1_en","features.lockscreen.impl.setup.pin_SetupPinView_Night_1_en",20466,], +["features.lockscreen.impl.setup.pin_SetupPinView_Day_2_en","features.lockscreen.impl.setup.pin_SetupPinView_Night_2_en",20466,], +["features.lockscreen.impl.setup.pin_SetupPinView_Day_3_en","features.lockscreen.impl.setup.pin_SetupPinView_Night_3_en",20466,], +["features.lockscreen.impl.setup.pin_SetupPinView_Day_4_en","features.lockscreen.impl.setup.pin_SetupPinView_Night_4_en",20466,], ["features.share.impl_ShareView_Day_0_en","features.share.impl_ShareView_Night_0_en",0,], ["features.share.impl_ShareView_Day_1_en","features.share.impl_ShareView_Night_1_en",0,], ["features.share.impl_ShareView_Day_2_en","features.share.impl_ShareView_Night_2_en",0,], -["features.share.impl_ShareView_Day_3_en","features.share.impl_ShareView_Night_3_en",20420,], -["features.location.impl.show_ShowLocationView_Day_0_en","features.location.impl.show_ShowLocationView_Night_0_en",20420,], -["features.location.impl.show_ShowLocationView_Day_1_en","features.location.impl.show_ShowLocationView_Night_1_en",20420,], -["features.location.impl.show_ShowLocationView_Day_2_en","features.location.impl.show_ShowLocationView_Night_2_en",20420,], -["features.location.impl.show_ShowLocationView_Day_3_en","features.location.impl.show_ShowLocationView_Night_3_en",20420,], -["features.location.impl.show_ShowLocationView_Day_4_en","features.location.impl.show_ShowLocationView_Night_4_en",20420,], -["features.location.impl.show_ShowLocationView_Day_5_en","features.location.impl.show_ShowLocationView_Night_5_en",20420,], -["features.location.impl.show_ShowLocationView_Day_6_en","features.location.impl.show_ShowLocationView_Night_6_en",20420,], -["features.location.impl.show_ShowLocationView_Day_7_en","features.location.impl.show_ShowLocationView_Night_7_en",20420,], -["features.signedout.impl_SignedOutView_Day_0_en","features.signedout.impl_SignedOutView_Night_0_en",20420,], +["features.share.impl_ShareView_Day_3_en","features.share.impl_ShareView_Night_3_en",20466,], +["features.location.impl.show_ShowLocationView_Day_0_en","features.location.impl.show_ShowLocationView_Night_0_en",20466,], +["features.location.impl.show_ShowLocationView_Day_1_en","features.location.impl.show_ShowLocationView_Night_1_en",20466,], +["features.location.impl.show_ShowLocationView_Day_2_en","features.location.impl.show_ShowLocationView_Night_2_en",20466,], +["features.location.impl.show_ShowLocationView_Day_3_en","features.location.impl.show_ShowLocationView_Night_3_en",20466,], +["features.location.impl.show_ShowLocationView_Day_4_en","features.location.impl.show_ShowLocationView_Night_4_en",20466,], +["features.location.impl.show_ShowLocationView_Day_5_en","features.location.impl.show_ShowLocationView_Night_5_en",20466,], +["features.location.impl.show_ShowLocationView_Day_6_en","features.location.impl.show_ShowLocationView_Night_6_en",20466,], +["features.location.impl.show_ShowLocationView_Day_7_en","features.location.impl.show_ShowLocationView_Night_7_en",20466,], +["features.linknewdevice.impl.screens.qrcode_ShowQrCodeView_Day_0_en","features.linknewdevice.impl.screens.qrcode_ShowQrCodeView_Night_0_en",20466,], +["features.signedout.impl_SignedOutView_Day_0_en","features.signedout.impl_SignedOutView_Night_0_en",20466,], ["libraries.designsystem.components_SimpleModalBottomSheet_Day_0_en","libraries.designsystem.components_SimpleModalBottomSheet_Night_0_en",0,], ["libraries.designsystem.components.dialogs_SingleSelectionDialogContent_Dialogs_en","",0,], ["libraries.designsystem.components.dialogs_SingleSelectionDialog_Day_0_en","libraries.designsystem.components.dialogs_SingleSelectionDialog_Night_0_en",0,], @@ -1194,102 +1251,102 @@ export const screenshots = [ ["libraries.designsystem.components.list_SingleSelectionListItemUnselectedWithSupportingText_Single_selection_List_item_-_no_selection,_supporting_text_List_items_en","",0,], ["libraries.designsystem.components.list_SingleSelectionListItem_Single_selection_List_item_-_no_selection_List_items_en","",0,], ["libraries.designsystem.theme.components_Sliders_Sliders_en","",0,], -["features.login.impl.dialogs_SlidingSyncNotSupportedDialog_Day_0_en","features.login.impl.dialogs_SlidingSyncNotSupportedDialog_Night_0_en",20420,], +["features.login.impl.dialogs_SlidingSyncNotSupportedDialog_Day_0_en","features.login.impl.dialogs_SlidingSyncNotSupportedDialog_Night_0_en",20466,], ["libraries.designsystem.theme.components_SnackbarWithActionAndCloseButton_Snackbar_with_action_and_close_button_Snackbars_en","",0,], ["libraries.designsystem.theme.components_SnackbarWithActionOnNewLineAndCloseButton_Snackbar_with_action_and_close_button_on_new_line_Snackbars_en","",0,], ["libraries.designsystem.theme.components_SnackbarWithActionOnNewLine_Snackbar_with_action_on_new_line_Snackbars_en","",0,], ["libraries.designsystem.theme.components_SnackbarWithAction_Snackbar_with_action_Snackbars_en","",0,], ["libraries.designsystem.theme.components_Snackbar_Snackbar_Snackbars_en","",0,], -["features.announcement.impl.spaces_SpaceAnnouncementView_Day_0_en","features.announcement.impl.spaces_SpaceAnnouncementView_Night_0_en",20420,], +["features.announcement.impl.spaces_SpaceAnnouncementView_Day_0_en","features.announcement.impl.spaces_SpaceAnnouncementView_Night_0_en",20466,], ["libraries.designsystem.components.avatar.internal_SpaceAvatar_Avatars_en","",0,], -["libraries.matrix.ui.components_SpaceHeaderRootView_Day_0_en","libraries.matrix.ui.components_SpaceHeaderRootView_Night_0_en",20420,], -["libraries.matrix.ui.components_SpaceHeaderView_Day_0_en","libraries.matrix.ui.components_SpaceHeaderView_Night_0_en",20420,], -["libraries.matrix.ui.components_SpaceInfoRow_Day_0_en","libraries.matrix.ui.components_SpaceInfoRow_Night_0_en",20420,], +["libraries.matrix.ui.components_SpaceHeaderRootView_Day_0_en","libraries.matrix.ui.components_SpaceHeaderRootView_Night_0_en",20466,], +["libraries.matrix.ui.components_SpaceHeaderView_Day_0_en","libraries.matrix.ui.components_SpaceHeaderView_Night_0_en",20466,], +["libraries.matrix.ui.components_SpaceInfoRow_Day_0_en","libraries.matrix.ui.components_SpaceInfoRow_Night_0_en",20466,], ["libraries.matrix.ui.components_SpaceMembersViewNoHeroes_Day_0_en","libraries.matrix.ui.components_SpaceMembersViewNoHeroes_Night_0_en",0,], ["libraries.matrix.ui.components_SpaceMembersView_Day_0_en","libraries.matrix.ui.components_SpaceMembersView_Night_0_en",0,], -["libraries.matrix.ui.components_SpaceRoomItemView_Day_0_en","libraries.matrix.ui.components_SpaceRoomItemView_Night_0_en",20420,], -["libraries.matrix.ui.components_SpaceRoomItemView_Day_1_en","libraries.matrix.ui.components_SpaceRoomItemView_Night_1_en",20420,], -["libraries.matrix.ui.components_SpaceRoomItemView_Day_2_en","libraries.matrix.ui.components_SpaceRoomItemView_Night_2_en",20420,], -["libraries.matrix.ui.components_SpaceRoomItemView_Day_3_en","libraries.matrix.ui.components_SpaceRoomItemView_Night_3_en",20420,], -["libraries.matrix.ui.components_SpaceRoomItemView_Day_4_en","libraries.matrix.ui.components_SpaceRoomItemView_Night_4_en",20420,], -["libraries.matrix.ui.components_SpaceRoomItemView_Day_5_en","libraries.matrix.ui.components_SpaceRoomItemView_Night_5_en",20420,], -["libraries.matrix.ui.components_SpaceRoomItemView_Day_6_en","libraries.matrix.ui.components_SpaceRoomItemView_Night_6_en",20420,], -["libraries.matrix.ui.components_SpaceRoomItemView_Day_7_en","libraries.matrix.ui.components_SpaceRoomItemView_Night_7_en",20420,], -["libraries.matrix.ui.components_SpaceRoomItemView_Day_8_en","libraries.matrix.ui.components_SpaceRoomItemView_Night_8_en",20420,], -["features.space.impl.settings_SpaceSettingsView_Day_0_en","features.space.impl.settings_SpaceSettingsView_Night_0_en",20420,], -["features.space.impl.settings_SpaceSettingsView_Day_1_en","features.space.impl.settings_SpaceSettingsView_Night_1_en",20420,], -["features.space.impl.settings_SpaceSettingsView_Day_2_en","features.space.impl.settings_SpaceSettingsView_Night_2_en",20420,], -["features.space.impl.settings_SpaceSettingsView_Day_3_en","features.space.impl.settings_SpaceSettingsView_Night_3_en",20420,], -["features.space.impl.root_SpaceView_Day_0_en","features.space.impl.root_SpaceView_Night_0_en",20420,], -["features.space.impl.root_SpaceView_Day_1_en","features.space.impl.root_SpaceView_Night_1_en",20420,], -["features.space.impl.root_SpaceView_Day_2_en","features.space.impl.root_SpaceView_Night_2_en",20420,], -["features.space.impl.root_SpaceView_Day_3_en","features.space.impl.root_SpaceView_Night_3_en",20420,], -["features.space.impl.root_SpaceView_Day_4_en","features.space.impl.root_SpaceView_Night_4_en",20420,], -["features.space.impl.root_SpaceView_Day_5_en","features.space.impl.root_SpaceView_Night_5_en",20420,], +["libraries.matrix.ui.components_SpaceRoomItemView_Day_0_en","libraries.matrix.ui.components_SpaceRoomItemView_Night_0_en",20466,], +["libraries.matrix.ui.components_SpaceRoomItemView_Day_1_en","libraries.matrix.ui.components_SpaceRoomItemView_Night_1_en",20466,], +["libraries.matrix.ui.components_SpaceRoomItemView_Day_2_en","libraries.matrix.ui.components_SpaceRoomItemView_Night_2_en",20466,], +["libraries.matrix.ui.components_SpaceRoomItemView_Day_3_en","libraries.matrix.ui.components_SpaceRoomItemView_Night_3_en",20466,], +["libraries.matrix.ui.components_SpaceRoomItemView_Day_4_en","libraries.matrix.ui.components_SpaceRoomItemView_Night_4_en",20466,], +["libraries.matrix.ui.components_SpaceRoomItemView_Day_5_en","libraries.matrix.ui.components_SpaceRoomItemView_Night_5_en",20466,], +["libraries.matrix.ui.components_SpaceRoomItemView_Day_6_en","libraries.matrix.ui.components_SpaceRoomItemView_Night_6_en",20466,], +["libraries.matrix.ui.components_SpaceRoomItemView_Day_7_en","libraries.matrix.ui.components_SpaceRoomItemView_Night_7_en",20466,], +["libraries.matrix.ui.components_SpaceRoomItemView_Day_8_en","libraries.matrix.ui.components_SpaceRoomItemView_Night_8_en",20466,], +["features.space.impl.settings_SpaceSettingsView_Day_0_en","features.space.impl.settings_SpaceSettingsView_Night_0_en",20466,], +["features.space.impl.settings_SpaceSettingsView_Day_1_en","features.space.impl.settings_SpaceSettingsView_Night_1_en",20466,], +["features.space.impl.settings_SpaceSettingsView_Day_2_en","features.space.impl.settings_SpaceSettingsView_Night_2_en",20466,], +["features.space.impl.settings_SpaceSettingsView_Day_3_en","features.space.impl.settings_SpaceSettingsView_Night_3_en",20466,], +["features.space.impl.root_SpaceView_Day_0_en","features.space.impl.root_SpaceView_Night_0_en",20466,], +["features.space.impl.root_SpaceView_Day_1_en","features.space.impl.root_SpaceView_Night_1_en",20466,], +["features.space.impl.root_SpaceView_Day_2_en","features.space.impl.root_SpaceView_Night_2_en",20466,], +["features.space.impl.root_SpaceView_Day_3_en","features.space.impl.root_SpaceView_Night_3_en",20466,], +["features.space.impl.root_SpaceView_Day_4_en","features.space.impl.root_SpaceView_Night_4_en",20466,], +["features.space.impl.root_SpaceView_Day_5_en","features.space.impl.root_SpaceView_Night_5_en",20466,], ["libraries.designsystem.modifiers_SquareSizeModifierInsideSquare_en","",0,], ["libraries.designsystem.modifiers_SquareSizeModifierLargeHeight_en","",0,], ["libraries.designsystem.modifiers_SquareSizeModifierLargeWidth_en","",0,], -["features.startchat.impl.root_StartChatView_Day_0_en","features.startchat.impl.root_StartChatView_Night_0_en",20420,], -["features.startchat.impl.root_StartChatView_Day_1_en","features.startchat.impl.root_StartChatView_Night_1_en",20420,], -["features.startchat.impl.root_StartChatView_Day_2_en","features.startchat.impl.root_StartChatView_Night_2_en",20420,], -["features.startchat.impl.root_StartChatView_Day_3_en","features.startchat.impl.root_StartChatView_Night_3_en",20420,], -["features.startchat.impl.root_StartChatView_Day_4_en","features.startchat.impl.root_StartChatView_Night_4_en",20420,], -["features.startchat.impl.root_StartChatView_Day_5_en","features.startchat.impl.root_StartChatView_Night_5_en",20420,], -["features.location.api.internal_StaticMapPlaceholder_Day_0_en","features.location.api.internal_StaticMapPlaceholder_Night_0_en",20420,], +["features.startchat.impl.root_StartChatView_Day_0_en","features.startchat.impl.root_StartChatView_Night_0_en",20466,], +["features.startchat.impl.root_StartChatView_Day_1_en","features.startchat.impl.root_StartChatView_Night_1_en",20466,], +["features.startchat.impl.root_StartChatView_Day_2_en","features.startchat.impl.root_StartChatView_Night_2_en",20466,], +["features.startchat.impl.root_StartChatView_Day_3_en","features.startchat.impl.root_StartChatView_Night_3_en",20466,], +["features.startchat.impl.root_StartChatView_Day_4_en","features.startchat.impl.root_StartChatView_Night_4_en",20466,], +["features.startchat.impl.root_StartChatView_Day_5_en","features.startchat.impl.root_StartChatView_Night_5_en",20466,], +["features.location.api.internal_StaticMapPlaceholder_Day_0_en","features.location.api.internal_StaticMapPlaceholder_Night_0_en",20466,], ["features.location.api_StaticMapView_Day_0_en","features.location.api_StaticMapView_Night_0_en",0,], -["features.messages.impl.messagecomposer.suggestions_SuggestionsPickerView_Day_0_en","features.messages.impl.messagecomposer.suggestions_SuggestionsPickerView_Night_0_en",20420,], +["features.messages.impl.messagecomposer.suggestions_SuggestionsPickerView_Day_0_en","features.messages.impl.messagecomposer.suggestions_SuggestionsPickerView_Night_0_en",20466,], ["libraries.designsystem.atomic.pages_SunsetPage_Day_0_en","libraries.designsystem.atomic.pages_SunsetPage_Night_0_en",0,], ["libraries.designsystem.components.button_SuperButton_Day_0_en","libraries.designsystem.components.button_SuperButton_Night_0_en",0,], ["libraries.designsystem.theme.components_Surface_en","",0,], ["libraries.designsystem.theme.components_Switch_Toggles_en","",0,], -["appnav.loggedin_SyncStateView_Day_0_en","appnav.loggedin_SyncStateView_Night_0_en",20420,], +["appnav.loggedin_SyncStateView_Day_0_en","appnav.loggedin_SyncStateView_Night_0_en",20466,], ["libraries.designsystem.components.avatar.internal_TextAvatar_Avatars_en","",0,], ["libraries.designsystem.theme.components_TextButtonLargeLowPadding_Buttons_en","",0,], ["libraries.designsystem.theme.components_TextButtonLarge_Buttons_en","",0,], ["libraries.designsystem.theme.components_TextButtonMediumLowPadding_Buttons_en","",0,], ["libraries.designsystem.theme.components_TextButtonMedium_Buttons_en","",0,], ["libraries.designsystem.theme.components_TextButtonSmall_Buttons_en","",0,], -["libraries.textcomposer_TextComposerAddCaption_Day_0_en","libraries.textcomposer_TextComposerAddCaption_Night_0_en",20420,], -["libraries.textcomposer_TextComposerCaption_Day_0_en","libraries.textcomposer_TextComposerCaption_Night_0_en",20420,], -["libraries.textcomposer_TextComposerEditCaption_Day_0_en","libraries.textcomposer_TextComposerEditCaption_Night_0_en",20420,], -["libraries.textcomposer_TextComposerEditNotEncrypted_Day_0_en","libraries.textcomposer_TextComposerEditNotEncrypted_Night_0_en",20420,], -["libraries.textcomposer_TextComposerEdit_Day_0_en","libraries.textcomposer_TextComposerEdit_Night_0_en",20420,], -["libraries.textcomposer_TextComposerFormattingNotEncrypted_Day_0_en","libraries.textcomposer_TextComposerFormattingNotEncrypted_Night_0_en",20420,], -["libraries.textcomposer_TextComposerFormatting_Day_0_en","libraries.textcomposer_TextComposerFormatting_Night_0_en",20420,], -["libraries.textcomposer_TextComposerLinkDialogCreateLinkWithoutText_Day_0_en","libraries.textcomposer_TextComposerLinkDialogCreateLinkWithoutText_Night_0_en",20420,], -["libraries.textcomposer_TextComposerLinkDialogCreateLink_Day_0_en","libraries.textcomposer_TextComposerLinkDialogCreateLink_Night_0_en",20420,], -["libraries.textcomposer_TextComposerLinkDialogEditLink_Day_0_en","libraries.textcomposer_TextComposerLinkDialogEditLink_Night_0_en",20420,], -["libraries.textcomposer_TextComposerReplyNotEncrypted_Day_0_en","libraries.textcomposer_TextComposerReplyNotEncrypted_Night_0_en",20420,], -["libraries.textcomposer_TextComposerReplyNotEncrypted_Day_10_en","libraries.textcomposer_TextComposerReplyNotEncrypted_Night_10_en",20420,], -["libraries.textcomposer_TextComposerReplyNotEncrypted_Day_11_en","libraries.textcomposer_TextComposerReplyNotEncrypted_Night_11_en",20420,], -["libraries.textcomposer_TextComposerReplyNotEncrypted_Day_1_en","libraries.textcomposer_TextComposerReplyNotEncrypted_Night_1_en",20420,], -["libraries.textcomposer_TextComposerReplyNotEncrypted_Day_2_en","libraries.textcomposer_TextComposerReplyNotEncrypted_Night_2_en",20420,], -["libraries.textcomposer_TextComposerReplyNotEncrypted_Day_3_en","libraries.textcomposer_TextComposerReplyNotEncrypted_Night_3_en",20420,], -["libraries.textcomposer_TextComposerReplyNotEncrypted_Day_4_en","libraries.textcomposer_TextComposerReplyNotEncrypted_Night_4_en",20420,], -["libraries.textcomposer_TextComposerReplyNotEncrypted_Day_5_en","libraries.textcomposer_TextComposerReplyNotEncrypted_Night_5_en",20420,], -["libraries.textcomposer_TextComposerReplyNotEncrypted_Day_6_en","libraries.textcomposer_TextComposerReplyNotEncrypted_Night_6_en",20420,], -["libraries.textcomposer_TextComposerReplyNotEncrypted_Day_7_en","libraries.textcomposer_TextComposerReplyNotEncrypted_Night_7_en",20420,], -["libraries.textcomposer_TextComposerReplyNotEncrypted_Day_8_en","libraries.textcomposer_TextComposerReplyNotEncrypted_Night_8_en",20420,], -["libraries.textcomposer_TextComposerReplyNotEncrypted_Day_9_en","libraries.textcomposer_TextComposerReplyNotEncrypted_Night_9_en",20420,], -["libraries.textcomposer_TextComposerReply_Day_0_en","libraries.textcomposer_TextComposerReply_Night_0_en",20420,], -["libraries.textcomposer_TextComposerReply_Day_10_en","libraries.textcomposer_TextComposerReply_Night_10_en",20420,], -["libraries.textcomposer_TextComposerReply_Day_11_en","libraries.textcomposer_TextComposerReply_Night_11_en",20420,], -["libraries.textcomposer_TextComposerReply_Day_1_en","libraries.textcomposer_TextComposerReply_Night_1_en",20420,], -["libraries.textcomposer_TextComposerReply_Day_2_en","libraries.textcomposer_TextComposerReply_Night_2_en",20420,], -["libraries.textcomposer_TextComposerReply_Day_3_en","libraries.textcomposer_TextComposerReply_Night_3_en",20420,], -["libraries.textcomposer_TextComposerReply_Day_4_en","libraries.textcomposer_TextComposerReply_Night_4_en",20420,], -["libraries.textcomposer_TextComposerReply_Day_5_en","libraries.textcomposer_TextComposerReply_Night_5_en",20420,], -["libraries.textcomposer_TextComposerReply_Day_6_en","libraries.textcomposer_TextComposerReply_Night_6_en",20420,], -["libraries.textcomposer_TextComposerReply_Day_7_en","libraries.textcomposer_TextComposerReply_Night_7_en",20420,], -["libraries.textcomposer_TextComposerReply_Day_8_en","libraries.textcomposer_TextComposerReply_Night_8_en",20420,], -["libraries.textcomposer_TextComposerReply_Day_9_en","libraries.textcomposer_TextComposerReply_Night_9_en",20420,], -["libraries.textcomposer_TextComposerSimpleNotEncrypted_Day_0_en","libraries.textcomposer_TextComposerSimpleNotEncrypted_Night_0_en",20420,], -["libraries.textcomposer_TextComposerSimple_Day_0_en","libraries.textcomposer_TextComposerSimple_Night_0_en",20420,], -["libraries.textcomposer_TextComposerVoiceNotEncrypted_Day_0_en","libraries.textcomposer_TextComposerVoiceNotEncrypted_Night_0_en",20420,], +["libraries.textcomposer_TextComposerAddCaption_Day_0_en","libraries.textcomposer_TextComposerAddCaption_Night_0_en",20466,], +["libraries.textcomposer_TextComposerCaption_Day_0_en","libraries.textcomposer_TextComposerCaption_Night_0_en",20466,], +["libraries.textcomposer_TextComposerEditCaption_Day_0_en","libraries.textcomposer_TextComposerEditCaption_Night_0_en",20466,], +["libraries.textcomposer_TextComposerEditNotEncrypted_Day_0_en","libraries.textcomposer_TextComposerEditNotEncrypted_Night_0_en",20466,], +["libraries.textcomposer_TextComposerEdit_Day_0_en","libraries.textcomposer_TextComposerEdit_Night_0_en",20466,], +["libraries.textcomposer_TextComposerFormattingNotEncrypted_Day_0_en","libraries.textcomposer_TextComposerFormattingNotEncrypted_Night_0_en",20466,], +["libraries.textcomposer_TextComposerFormatting_Day_0_en","libraries.textcomposer_TextComposerFormatting_Night_0_en",20466,], +["libraries.textcomposer_TextComposerLinkDialogCreateLinkWithoutText_Day_0_en","libraries.textcomposer_TextComposerLinkDialogCreateLinkWithoutText_Night_0_en",20466,], +["libraries.textcomposer_TextComposerLinkDialogCreateLink_Day_0_en","libraries.textcomposer_TextComposerLinkDialogCreateLink_Night_0_en",20466,], +["libraries.textcomposer_TextComposerLinkDialogEditLink_Day_0_en","libraries.textcomposer_TextComposerLinkDialogEditLink_Night_0_en",20466,], +["libraries.textcomposer_TextComposerReplyNotEncrypted_Day_0_en","libraries.textcomposer_TextComposerReplyNotEncrypted_Night_0_en",20466,], +["libraries.textcomposer_TextComposerReplyNotEncrypted_Day_10_en","libraries.textcomposer_TextComposerReplyNotEncrypted_Night_10_en",20466,], +["libraries.textcomposer_TextComposerReplyNotEncrypted_Day_11_en","libraries.textcomposer_TextComposerReplyNotEncrypted_Night_11_en",20466,], +["libraries.textcomposer_TextComposerReplyNotEncrypted_Day_1_en","libraries.textcomposer_TextComposerReplyNotEncrypted_Night_1_en",20466,], +["libraries.textcomposer_TextComposerReplyNotEncrypted_Day_2_en","libraries.textcomposer_TextComposerReplyNotEncrypted_Night_2_en",20466,], +["libraries.textcomposer_TextComposerReplyNotEncrypted_Day_3_en","libraries.textcomposer_TextComposerReplyNotEncrypted_Night_3_en",20466,], +["libraries.textcomposer_TextComposerReplyNotEncrypted_Day_4_en","libraries.textcomposer_TextComposerReplyNotEncrypted_Night_4_en",20466,], +["libraries.textcomposer_TextComposerReplyNotEncrypted_Day_5_en","libraries.textcomposer_TextComposerReplyNotEncrypted_Night_5_en",20466,], +["libraries.textcomposer_TextComposerReplyNotEncrypted_Day_6_en","libraries.textcomposer_TextComposerReplyNotEncrypted_Night_6_en",20466,], +["libraries.textcomposer_TextComposerReplyNotEncrypted_Day_7_en","libraries.textcomposer_TextComposerReplyNotEncrypted_Night_7_en",20466,], +["libraries.textcomposer_TextComposerReplyNotEncrypted_Day_8_en","libraries.textcomposer_TextComposerReplyNotEncrypted_Night_8_en",20466,], +["libraries.textcomposer_TextComposerReplyNotEncrypted_Day_9_en","libraries.textcomposer_TextComposerReplyNotEncrypted_Night_9_en",20466,], +["libraries.textcomposer_TextComposerReply_Day_0_en","libraries.textcomposer_TextComposerReply_Night_0_en",20466,], +["libraries.textcomposer_TextComposerReply_Day_10_en","libraries.textcomposer_TextComposerReply_Night_10_en",20466,], +["libraries.textcomposer_TextComposerReply_Day_11_en","libraries.textcomposer_TextComposerReply_Night_11_en",20466,], +["libraries.textcomposer_TextComposerReply_Day_1_en","libraries.textcomposer_TextComposerReply_Night_1_en",20466,], +["libraries.textcomposer_TextComposerReply_Day_2_en","libraries.textcomposer_TextComposerReply_Night_2_en",20466,], +["libraries.textcomposer_TextComposerReply_Day_3_en","libraries.textcomposer_TextComposerReply_Night_3_en",20466,], +["libraries.textcomposer_TextComposerReply_Day_4_en","libraries.textcomposer_TextComposerReply_Night_4_en",20466,], +["libraries.textcomposer_TextComposerReply_Day_5_en","libraries.textcomposer_TextComposerReply_Night_5_en",20466,], +["libraries.textcomposer_TextComposerReply_Day_6_en","libraries.textcomposer_TextComposerReply_Night_6_en",20466,], +["libraries.textcomposer_TextComposerReply_Day_7_en","libraries.textcomposer_TextComposerReply_Night_7_en",20466,], +["libraries.textcomposer_TextComposerReply_Day_8_en","libraries.textcomposer_TextComposerReply_Night_8_en",20466,], +["libraries.textcomposer_TextComposerReply_Day_9_en","libraries.textcomposer_TextComposerReply_Night_9_en",20466,], +["libraries.textcomposer_TextComposerSimpleNotEncrypted_Day_0_en","libraries.textcomposer_TextComposerSimpleNotEncrypted_Night_0_en",20466,], +["libraries.textcomposer_TextComposerSimple_Day_0_en","libraries.textcomposer_TextComposerSimple_Night_0_en",20466,], +["libraries.textcomposer_TextComposerVoiceNotEncrypted_Day_0_en","libraries.textcomposer_TextComposerVoiceNotEncrypted_Night_0_en",20466,], ["libraries.textcomposer_TextComposerVoice_Day_0_en","libraries.textcomposer_TextComposerVoice_Night_0_en",0,], ["libraries.designsystem.theme.components_TextDark_Text_en","",0,], -["libraries.designsystem.components.dialogs_TextFieldDialogWithError_Day_0_en","libraries.designsystem.components.dialogs_TextFieldDialogWithError_Night_0_en",20420,], -["libraries.designsystem.components.dialogs_TextFieldDialog_Day_0_en","libraries.designsystem.components.dialogs_TextFieldDialog_Night_0_en",20420,], +["libraries.designsystem.components.dialogs_TextFieldDialogWithError_Day_0_en","libraries.designsystem.components.dialogs_TextFieldDialogWithError_Night_0_en",20466,], +["libraries.designsystem.components.dialogs_TextFieldDialog_Day_0_en","libraries.designsystem.components.dialogs_TextFieldDialog_Night_0_en",20466,], ["libraries.designsystem.components.list_TextFieldListItemEmpty_Text_field_List_item_-_empty_List_items_en","",0,], ["libraries.designsystem.components.list_TextFieldListItemTextFieldValue_Text_field_List_item_-_textfieldvalue_List_items_en","",0,], ["libraries.designsystem.components.list_TextFieldListItem_Text_field_List_item_-_text_List_items_en","",0,], @@ -1301,16 +1358,16 @@ export const screenshots = [ ["libraries.mediaviewer.impl.local.txt_TextFileContentView_Day_3_en","libraries.mediaviewer.impl.local.txt_TextFileContentView_Night_3_en",0,], ["libraries.textcomposer.components_TextFormatting_Day_0_en","libraries.textcomposer.components_TextFormatting_Night_0_en",0,], ["libraries.designsystem.theme.components_TextLight_Text_en","",0,], -["features.messages.impl.timeline.components_ThreadSummaryView_Day_0_en","features.messages.impl.timeline.components_ThreadSummaryView_Night_0_en",20420,], -["features.messages.impl.topbars_ThreadTopBar_Day_0_en","features.messages.impl.topbars_ThreadTopBar_Night_0_en",20420,], -["libraries.designsystem.theme.components.previews_TimePickerHorizontal_DateTime_pickers_en","",20420,], -["libraries.designsystem.theme.components.previews_TimePickerVerticalDark_DateTime_pickers_en","",20420,], -["libraries.designsystem.theme.components.previews_TimePickerVerticalLight_DateTime_pickers_en","",20420,], +["features.messages.impl.timeline.components_ThreadSummaryView_Day_0_en","features.messages.impl.timeline.components_ThreadSummaryView_Night_0_en",20466,], +["features.messages.impl.topbars_ThreadTopBar_Day_0_en","features.messages.impl.topbars_ThreadTopBar_Night_0_en",20466,], +["libraries.designsystem.theme.components.previews_TimePickerHorizontal_DateTime_pickers_en","",20466,], +["libraries.designsystem.theme.components.previews_TimePickerVerticalDark_DateTime_pickers_en","",20466,], +["libraries.designsystem.theme.components.previews_TimePickerVerticalLight_DateTime_pickers_en","",20466,], ["features.messages.impl.timeline.components_TimelineEventTimestampView_Day_0_en","features.messages.impl.timeline.components_TimelineEventTimestampView_Night_0_en",0,], ["features.messages.impl.timeline.components_TimelineEventTimestampView_Day_1_en","features.messages.impl.timeline.components_TimelineEventTimestampView_Night_1_en",0,], ["features.messages.impl.timeline.components_TimelineEventTimestampView_Day_2_en","features.messages.impl.timeline.components_TimelineEventTimestampView_Night_2_en",0,], -["features.messages.impl.timeline.components_TimelineEventTimestampView_Day_3_en","features.messages.impl.timeline.components_TimelineEventTimestampView_Night_3_en",20420,], -["features.messages.impl.timeline.components_TimelineEventTimestampView_Day_4_en","features.messages.impl.timeline.components_TimelineEventTimestampView_Night_4_en",20420,], +["features.messages.impl.timeline.components_TimelineEventTimestampView_Day_3_en","features.messages.impl.timeline.components_TimelineEventTimestampView_Night_3_en",20466,], +["features.messages.impl.timeline.components_TimelineEventTimestampView_Day_4_en","features.messages.impl.timeline.components_TimelineEventTimestampView_Night_4_en",20466,], ["features.messages.impl.timeline.components_TimelineEventTimestampView_Day_5_en","features.messages.impl.timeline.components_TimelineEventTimestampView_Night_5_en",0,], ["features.messages.impl.timeline.components_TimelineEventTimestampView_Day_6_en","features.messages.impl.timeline.components_TimelineEventTimestampView_Night_6_en",0,], ["features.messages.impl.timeline.components_TimelineEventTimestampView_Day_7_en","features.messages.impl.timeline.components_TimelineEventTimestampView_Night_7_en",0,], @@ -1320,18 +1377,18 @@ export const screenshots = [ ["features.messages.impl.timeline.components.event_TimelineItemAudioView_Day_2_en","features.messages.impl.timeline.components.event_TimelineItemAudioView_Night_2_en",0,], ["features.messages.impl.timeline.components.event_TimelineItemAudioView_Day_3_en","features.messages.impl.timeline.components.event_TimelineItemAudioView_Night_3_en",0,], ["features.messages.impl.timeline.components.event_TimelineItemAudioView_Day_4_en","features.messages.impl.timeline.components.event_TimelineItemAudioView_Night_4_en",0,], -["features.messages.impl.timeline.components_TimelineItemCallNotifyView_Day_0_en","features.messages.impl.timeline.components_TimelineItemCallNotifyView_Night_0_en",20420,], +["features.messages.impl.timeline.components_TimelineItemCallNotifyView_Day_0_en","features.messages.impl.timeline.components_TimelineItemCallNotifyView_Night_0_en",20466,], ["features.messages.impl.timeline.components.virtual_TimelineItemDaySeparatorView_Day_0_en","features.messages.impl.timeline.components.virtual_TimelineItemDaySeparatorView_Night_0_en",0,], ["features.messages.impl.timeline.components.virtual_TimelineItemDaySeparatorView_Day_1_en","features.messages.impl.timeline.components.virtual_TimelineItemDaySeparatorView_Night_1_en",0,], -["features.messages.impl.timeline.components.event_TimelineItemEncryptedView_Day_0_en","features.messages.impl.timeline.components.event_TimelineItemEncryptedView_Night_0_en",20420,], -["features.messages.impl.timeline.components.event_TimelineItemEncryptedView_Day_1_en","features.messages.impl.timeline.components.event_TimelineItemEncryptedView_Night_1_en",20420,], -["features.messages.impl.timeline.components.event_TimelineItemEncryptedView_Day_2_en","features.messages.impl.timeline.components.event_TimelineItemEncryptedView_Night_2_en",20420,], -["features.messages.impl.timeline.components.event_TimelineItemEncryptedView_Day_3_en","features.messages.impl.timeline.components.event_TimelineItemEncryptedView_Night_3_en",20420,], -["features.messages.impl.timeline.components.event_TimelineItemEncryptedView_Day_4_en","features.messages.impl.timeline.components.event_TimelineItemEncryptedView_Night_4_en",20420,], -["features.messages.impl.timeline.components.event_TimelineItemEncryptedView_Day_5_en","features.messages.impl.timeline.components.event_TimelineItemEncryptedView_Night_5_en",20420,], -["features.messages.impl.timeline.components.event_TimelineItemEncryptedView_Day_6_en","features.messages.impl.timeline.components.event_TimelineItemEncryptedView_Night_6_en",20420,], -["features.messages.impl.timeline.components.event_TimelineItemEncryptedView_Day_7_en","features.messages.impl.timeline.components.event_TimelineItemEncryptedView_Night_7_en",20420,], -["features.messages.impl.timeline.components.event_TimelineItemEncryptedView_Day_8_en","features.messages.impl.timeline.components.event_TimelineItemEncryptedView_Night_8_en",20420,], +["features.messages.impl.timeline.components.event_TimelineItemEncryptedView_Day_0_en","features.messages.impl.timeline.components.event_TimelineItemEncryptedView_Night_0_en",20466,], +["features.messages.impl.timeline.components.event_TimelineItemEncryptedView_Day_1_en","features.messages.impl.timeline.components.event_TimelineItemEncryptedView_Night_1_en",20466,], +["features.messages.impl.timeline.components.event_TimelineItemEncryptedView_Day_2_en","features.messages.impl.timeline.components.event_TimelineItemEncryptedView_Night_2_en",20466,], +["features.messages.impl.timeline.components.event_TimelineItemEncryptedView_Day_3_en","features.messages.impl.timeline.components.event_TimelineItemEncryptedView_Night_3_en",20466,], +["features.messages.impl.timeline.components.event_TimelineItemEncryptedView_Day_4_en","features.messages.impl.timeline.components.event_TimelineItemEncryptedView_Night_4_en",20466,], +["features.messages.impl.timeline.components.event_TimelineItemEncryptedView_Day_5_en","features.messages.impl.timeline.components.event_TimelineItemEncryptedView_Night_5_en",20466,], +["features.messages.impl.timeline.components.event_TimelineItemEncryptedView_Day_6_en","features.messages.impl.timeline.components.event_TimelineItemEncryptedView_Night_6_en",20466,], +["features.messages.impl.timeline.components.event_TimelineItemEncryptedView_Day_7_en","features.messages.impl.timeline.components.event_TimelineItemEncryptedView_Night_7_en",20466,], +["features.messages.impl.timeline.components.event_TimelineItemEncryptedView_Day_8_en","features.messages.impl.timeline.components.event_TimelineItemEncryptedView_Night_8_en",20466,], ["features.messages.impl.timeline.components_TimelineItemEventRowDisambiguated_Day_0_en","features.messages.impl.timeline.components_TimelineItemEventRowDisambiguated_Night_0_en",0,], ["features.messages.impl.timeline.components_TimelineItemEventRowForDirectRoom_Day_0_en","features.messages.impl.timeline.components_TimelineItemEventRowForDirectRoom_Night_0_en",0,], ["features.messages.impl.timeline.components_TimelineItemEventRowLongSenderName_en","",0,], @@ -1339,18 +1396,18 @@ export const screenshots = [ ["features.messages.impl.timeline.components_TimelineItemEventRowTimestamp_Day_0_en","features.messages.impl.timeline.components_TimelineItemEventRowTimestamp_Night_0_en",0,], ["features.messages.impl.timeline.components_TimelineItemEventRowTimestamp_Day_1_en","features.messages.impl.timeline.components_TimelineItemEventRowTimestamp_Night_1_en",0,], ["features.messages.impl.timeline.components_TimelineItemEventRowTimestamp_Day_2_en","features.messages.impl.timeline.components_TimelineItemEventRowTimestamp_Night_2_en",0,], -["features.messages.impl.timeline.components_TimelineItemEventRowTimestamp_Day_3_en","features.messages.impl.timeline.components_TimelineItemEventRowTimestamp_Night_3_en",20420,], -["features.messages.impl.timeline.components_TimelineItemEventRowTimestamp_Day_4_en","features.messages.impl.timeline.components_TimelineItemEventRowTimestamp_Night_4_en",20420,], +["features.messages.impl.timeline.components_TimelineItemEventRowTimestamp_Day_3_en","features.messages.impl.timeline.components_TimelineItemEventRowTimestamp_Night_3_en",20466,], +["features.messages.impl.timeline.components_TimelineItemEventRowTimestamp_Day_4_en","features.messages.impl.timeline.components_TimelineItemEventRowTimestamp_Night_4_en",20466,], ["features.messages.impl.timeline.components_TimelineItemEventRowTimestamp_Day_5_en","features.messages.impl.timeline.components_TimelineItemEventRowTimestamp_Night_5_en",0,], ["features.messages.impl.timeline.components_TimelineItemEventRowTimestamp_Day_6_en","features.messages.impl.timeline.components_TimelineItemEventRowTimestamp_Night_6_en",0,], -["features.messages.impl.timeline.components_TimelineItemEventRowTimestamp_Day_7_en","features.messages.impl.timeline.components_TimelineItemEventRowTimestamp_Night_7_en",20420,], -["features.messages.impl.timeline.components_TimelineItemEventRowUtd_Day_0_en","features.messages.impl.timeline.components_TimelineItemEventRowUtd_Night_0_en",20420,], -["features.messages.impl.timeline.components_TimelineItemEventRowWithManyReactions_Day_0_en","features.messages.impl.timeline.components_TimelineItemEventRowWithManyReactions_Night_0_en",20420,], +["features.messages.impl.timeline.components_TimelineItemEventRowTimestamp_Day_7_en","features.messages.impl.timeline.components_TimelineItemEventRowTimestamp_Night_7_en",20466,], +["features.messages.impl.timeline.components_TimelineItemEventRowUtd_Day_0_en","features.messages.impl.timeline.components_TimelineItemEventRowUtd_Night_0_en",20466,], +["features.messages.impl.timeline.components_TimelineItemEventRowWithManyReactions_Day_0_en","features.messages.impl.timeline.components_TimelineItemEventRowWithManyReactions_Night_0_en",20466,], ["features.messages.impl.timeline.components_TimelineItemEventRowWithRR_Day_0_en","features.messages.impl.timeline.components_TimelineItemEventRowWithRR_Night_0_en",0,], ["features.messages.impl.timeline.components_TimelineItemEventRowWithRR_Day_1_en","features.messages.impl.timeline.components_TimelineItemEventRowWithRR_Night_1_en",0,], ["features.messages.impl.timeline.components_TimelineItemEventRowWithRR_Day_2_en","features.messages.impl.timeline.components_TimelineItemEventRowWithRR_Night_2_en",0,], -["features.messages.impl.timeline.components_TimelineItemEventRowWithReplyInformative_Day_0_en","features.messages.impl.timeline.components_TimelineItemEventRowWithReplyInformative_Night_0_en",20420,], -["features.messages.impl.timeline.components_TimelineItemEventRowWithReplyInformative_Day_1_en","features.messages.impl.timeline.components_TimelineItemEventRowWithReplyInformative_Night_1_en",20420,], +["features.messages.impl.timeline.components_TimelineItemEventRowWithReplyInformative_Day_0_en","features.messages.impl.timeline.components_TimelineItemEventRowWithReplyInformative_Night_0_en",20466,], +["features.messages.impl.timeline.components_TimelineItemEventRowWithReplyInformative_Day_1_en","features.messages.impl.timeline.components_TimelineItemEventRowWithReplyInformative_Night_1_en",20466,], ["features.messages.impl.timeline.components_TimelineItemEventRowWithReplyOther_Day_0_en","features.messages.impl.timeline.components_TimelineItemEventRowWithReplyOther_Night_0_en",0,], ["features.messages.impl.timeline.components_TimelineItemEventRowWithReplyOther_Day_1_en","features.messages.impl.timeline.components_TimelineItemEventRowWithReplyOther_Night_1_en",0,], ["features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_0_en","features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Night_0_en",0,], @@ -1359,41 +1416,41 @@ export const screenshots = [ ["features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_1_en","features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Night_1_en",0,], ["features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_2_en","features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Night_2_en",0,], ["features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_3_en","features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Night_3_en",0,], -["features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_4_en","features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Night_4_en",20420,], +["features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_4_en","features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Night_4_en",20466,], ["features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_5_en","features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Night_5_en",0,], ["features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_6_en","features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Night_6_en",0,], ["features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_7_en","features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Night_7_en",0,], -["features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_8_en","features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Night_8_en",20420,], +["features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_8_en","features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Night_8_en",20466,], ["features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Day_9_en","features.messages.impl.timeline.components_TimelineItemEventRowWithReply_Night_9_en",0,], -["features.messages.impl.timeline.components_TimelineItemEventRowWithThreadSummary_Day_0_en","features.messages.impl.timeline.components_TimelineItemEventRowWithThreadSummary_Night_0_en",20420,], +["features.messages.impl.timeline.components_TimelineItemEventRowWithThreadSummary_Day_0_en","features.messages.impl.timeline.components_TimelineItemEventRowWithThreadSummary_Night_0_en",20466,], ["features.messages.impl.timeline.components_TimelineItemEventRow_Day_0_en","features.messages.impl.timeline.components_TimelineItemEventRow_Night_0_en",0,], -["features.messages.impl.timeline.components_TimelineItemEventTimestampBelow_en","",20420,], +["features.messages.impl.timeline.components_TimelineItemEventTimestampBelow_en","",20466,], ["features.messages.impl.timeline.components.event_TimelineItemFileView_Day_0_en","features.messages.impl.timeline.components.event_TimelineItemFileView_Night_0_en",0,], ["features.messages.impl.timeline.components.event_TimelineItemFileView_Day_1_en","features.messages.impl.timeline.components.event_TimelineItemFileView_Night_1_en",0,], ["features.messages.impl.timeline.components.event_TimelineItemFileView_Day_2_en","features.messages.impl.timeline.components.event_TimelineItemFileView_Night_2_en",0,], ["features.messages.impl.timeline.components.event_TimelineItemFileView_Day_3_en","features.messages.impl.timeline.components.event_TimelineItemFileView_Night_3_en",0,], ["features.messages.impl.timeline.components.event_TimelineItemFileView_Day_4_en","features.messages.impl.timeline.components.event_TimelineItemFileView_Night_4_en",0,], -["features.messages.impl.timeline.components_TimelineItemGroupedEventsRowContentCollapse_Day_0_en","features.messages.impl.timeline.components_TimelineItemGroupedEventsRowContentCollapse_Night_0_en",20420,], -["features.messages.impl.timeline.components_TimelineItemGroupedEventsRowContentExpanded_Day_0_en","features.messages.impl.timeline.components_TimelineItemGroupedEventsRowContentExpanded_Night_0_en",20420,], -["features.messages.impl.timeline.components.event_TimelineItemImageViewHideMediaContent_Day_0_en","features.messages.impl.timeline.components.event_TimelineItemImageViewHideMediaContent_Night_0_en",20420,], +["features.messages.impl.timeline.components_TimelineItemGroupedEventsRowContentCollapse_Day_0_en","features.messages.impl.timeline.components_TimelineItemGroupedEventsRowContentCollapse_Night_0_en",20466,], +["features.messages.impl.timeline.components_TimelineItemGroupedEventsRowContentExpanded_Day_0_en","features.messages.impl.timeline.components_TimelineItemGroupedEventsRowContentExpanded_Night_0_en",20466,], +["features.messages.impl.timeline.components.event_TimelineItemImageViewHideMediaContent_Day_0_en","features.messages.impl.timeline.components.event_TimelineItemImageViewHideMediaContent_Night_0_en",20466,], ["features.messages.impl.timeline.components.event_TimelineItemImageView_Day_0_en","features.messages.impl.timeline.components.event_TimelineItemImageView_Night_0_en",0,], ["features.messages.impl.timeline.components.event_TimelineItemImageView_Day_1_en","features.messages.impl.timeline.components.event_TimelineItemImageView_Night_1_en",0,], ["features.messages.impl.timeline.components.event_TimelineItemImageView_Day_2_en","features.messages.impl.timeline.components.event_TimelineItemImageView_Night_2_en",0,], ["features.messages.impl.timeline.components.event_TimelineItemImageView_Day_3_en","features.messages.impl.timeline.components.event_TimelineItemImageView_Night_3_en",0,], ["features.messages.impl.timeline.components.event_TimelineItemInformativeView_Day_0_en","features.messages.impl.timeline.components.event_TimelineItemInformativeView_Night_0_en",0,], -["features.messages.impl.timeline.components.event_TimelineItemLegacyCallInviteView_Day_0_en","features.messages.impl.timeline.components.event_TimelineItemLegacyCallInviteView_Night_0_en",20420,], +["features.messages.impl.timeline.components.event_TimelineItemLegacyCallInviteView_Day_0_en","features.messages.impl.timeline.components.event_TimelineItemLegacyCallInviteView_Night_0_en",20466,], ["features.messages.impl.timeline.components.event_TimelineItemLocationView_Day_0_en","features.messages.impl.timeline.components.event_TimelineItemLocationView_Night_0_en",0,], ["features.messages.impl.timeline.components.event_TimelineItemLocationView_Day_1_en","features.messages.impl.timeline.components.event_TimelineItemLocationView_Night_1_en",0,], -["features.messages.impl.timeline.components.event_TimelineItemPollView_Day_0_en","features.messages.impl.timeline.components.event_TimelineItemPollView_Night_0_en",20420,], -["features.messages.impl.timeline.components.event_TimelineItemPollView_Day_1_en","features.messages.impl.timeline.components.event_TimelineItemPollView_Night_1_en",20420,], -["features.messages.impl.timeline.components.event_TimelineItemPollView_Day_2_en","features.messages.impl.timeline.components.event_TimelineItemPollView_Night_2_en",20420,], -["features.messages.impl.timeline.components.event_TimelineItemPollView_Day_3_en","features.messages.impl.timeline.components.event_TimelineItemPollView_Night_3_en",20420,], -["features.messages.impl.timeline.components_TimelineItemReactionsLayout_Day_0_en","features.messages.impl.timeline.components_TimelineItemReactionsLayout_Night_0_en",20420,], +["features.messages.impl.timeline.components.event_TimelineItemPollView_Day_0_en","features.messages.impl.timeline.components.event_TimelineItemPollView_Night_0_en",20466,], +["features.messages.impl.timeline.components.event_TimelineItemPollView_Day_1_en","features.messages.impl.timeline.components.event_TimelineItemPollView_Night_1_en",20466,], +["features.messages.impl.timeline.components.event_TimelineItemPollView_Day_2_en","features.messages.impl.timeline.components.event_TimelineItemPollView_Night_2_en",20466,], +["features.messages.impl.timeline.components.event_TimelineItemPollView_Day_3_en","features.messages.impl.timeline.components.event_TimelineItemPollView_Night_3_en",20466,], +["features.messages.impl.timeline.components_TimelineItemReactionsLayout_Day_0_en","features.messages.impl.timeline.components_TimelineItemReactionsLayout_Night_0_en",20466,], ["features.messages.impl.timeline.components_TimelineItemReactionsViewFew_Day_0_en","features.messages.impl.timeline.components_TimelineItemReactionsViewFew_Night_0_en",0,], -["features.messages.impl.timeline.components_TimelineItemReactionsViewIncoming_Day_0_en","features.messages.impl.timeline.components_TimelineItemReactionsViewIncoming_Night_0_en",20420,], -["features.messages.impl.timeline.components_TimelineItemReactionsViewOutgoing_Day_0_en","features.messages.impl.timeline.components_TimelineItemReactionsViewOutgoing_Night_0_en",20420,], +["features.messages.impl.timeline.components_TimelineItemReactionsViewIncoming_Day_0_en","features.messages.impl.timeline.components_TimelineItemReactionsViewIncoming_Night_0_en",20466,], +["features.messages.impl.timeline.components_TimelineItemReactionsViewOutgoing_Day_0_en","features.messages.impl.timeline.components_TimelineItemReactionsViewOutgoing_Night_0_en",20466,], ["features.messages.impl.timeline.components_TimelineItemReactionsView_Day_0_en","features.messages.impl.timeline.components_TimelineItemReactionsView_Night_0_en",0,], -["features.messages.impl.timeline.components.virtual_TimelineItemReadMarkerView_Day_0_en","features.messages.impl.timeline.components.virtual_TimelineItemReadMarkerView_Night_0_en",20420,], +["features.messages.impl.timeline.components.virtual_TimelineItemReadMarkerView_Day_0_en","features.messages.impl.timeline.components.virtual_TimelineItemReadMarkerView_Night_0_en",20466,], ["features.messages.impl.timeline.components.receipt_TimelineItemReadReceiptView_Day_0_en","features.messages.impl.timeline.components.receipt_TimelineItemReadReceiptView_Night_0_en",0,], ["features.messages.impl.timeline.components.receipt_TimelineItemReadReceiptView_Day_1_en","features.messages.impl.timeline.components.receipt_TimelineItemReadReceiptView_Night_1_en",0,], ["features.messages.impl.timeline.components.receipt_TimelineItemReadReceiptView_Day_2_en","features.messages.impl.timeline.components.receipt_TimelineItemReadReceiptView_Night_2_en",0,], @@ -1402,8 +1459,8 @@ export const screenshots = [ ["features.messages.impl.timeline.components.receipt_TimelineItemReadReceiptView_Day_5_en","features.messages.impl.timeline.components.receipt_TimelineItemReadReceiptView_Night_5_en",0,], ["features.messages.impl.timeline.components.receipt_TimelineItemReadReceiptView_Day_6_en","features.messages.impl.timeline.components.receipt_TimelineItemReadReceiptView_Night_6_en",0,], ["features.messages.impl.timeline.components.receipt_TimelineItemReadReceiptView_Day_7_en","features.messages.impl.timeline.components.receipt_TimelineItemReadReceiptView_Night_7_en",0,], -["features.messages.impl.timeline.components.event_TimelineItemRedactedView_Day_0_en","features.messages.impl.timeline.components.event_TimelineItemRedactedView_Night_0_en",20420,], -["features.messages.impl.timeline.components.virtual_TimelineItemRoomBeginningView_Day_0_en","features.messages.impl.timeline.components.virtual_TimelineItemRoomBeginningView_Night_0_en",20420,], +["features.messages.impl.timeline.components.event_TimelineItemRedactedView_Day_0_en","features.messages.impl.timeline.components.event_TimelineItemRedactedView_Night_0_en",20466,], +["features.messages.impl.timeline.components.virtual_TimelineItemRoomBeginningView_Day_0_en","features.messages.impl.timeline.components.virtual_TimelineItemRoomBeginningView_Night_0_en",20466,], ["features.messages.impl.timeline.components_TimelineItemStateEventRow_Day_0_en","features.messages.impl.timeline.components_TimelineItemStateEventRow_Night_0_en",0,], ["features.messages.impl.timeline.components.event_TimelineItemStateView_Day_0_en","features.messages.impl.timeline.components.event_TimelineItemStateView_Night_0_en",0,], ["features.messages.impl.timeline.components.event_TimelineItemStickerView_Day_0_en","features.messages.impl.timeline.components.event_TimelineItemStickerView_Night_0_en",0,], @@ -1418,8 +1475,8 @@ export const screenshots = [ ["features.messages.impl.timeline.components.event_TimelineItemTextView_Day_3_en","features.messages.impl.timeline.components.event_TimelineItemTextView_Night_3_en",0,], ["features.messages.impl.timeline.components.event_TimelineItemTextView_Day_4_en","features.messages.impl.timeline.components.event_TimelineItemTextView_Night_4_en",0,], ["features.messages.impl.timeline.components.event_TimelineItemTextView_Day_5_en","features.messages.impl.timeline.components.event_TimelineItemTextView_Night_5_en",0,], -["features.messages.impl.timeline.components.event_TimelineItemUnknownView_Day_0_en","features.messages.impl.timeline.components.event_TimelineItemUnknownView_Night_0_en",20420,], -["features.messages.impl.timeline.components.event_TimelineItemVideoViewHideMediaContent_Day_0_en","features.messages.impl.timeline.components.event_TimelineItemVideoViewHideMediaContent_Night_0_en",20420,], +["features.messages.impl.timeline.components.event_TimelineItemUnknownView_Day_0_en","features.messages.impl.timeline.components.event_TimelineItemUnknownView_Night_0_en",20466,], +["features.messages.impl.timeline.components.event_TimelineItemVideoViewHideMediaContent_Day_0_en","features.messages.impl.timeline.components.event_TimelineItemVideoViewHideMediaContent_Night_0_en",20466,], ["features.messages.impl.timeline.components.event_TimelineItemVideoView_Day_0_en","features.messages.impl.timeline.components.event_TimelineItemVideoView_Night_0_en",0,], ["features.messages.impl.timeline.components.event_TimelineItemVideoView_Day_1_en","features.messages.impl.timeline.components.event_TimelineItemVideoView_Night_1_en",0,], ["features.messages.impl.timeline.components.event_TimelineItemVideoView_Day_2_en","features.messages.impl.timeline.components.event_TimelineItemVideoView_Night_2_en",0,], @@ -1442,85 +1499,84 @@ export const screenshots = [ ["features.messages.impl.timeline.components.event_TimelineItemVoiceView_Day_9_en","features.messages.impl.timeline.components.event_TimelineItemVoiceView_Night_9_en",0,], ["features.messages.impl.timeline.components.virtual_TimelineLoadingMoreIndicator_Day_0_en","features.messages.impl.timeline.components.virtual_TimelineLoadingMoreIndicator_Night_0_en",0,], ["features.messages.impl.timeline.components.event_TimelineVideoWithCaptionRow_Day_0_en","features.messages.impl.timeline.components.event_TimelineVideoWithCaptionRow_Night_0_en",0,], -["features.messages.impl.timeline_TimelineViewMessageShield_Day_0_en","features.messages.impl.timeline_TimelineViewMessageShield_Night_0_en",20420,], -["features.messages.impl.timeline_TimelineView_Day_0_en","features.messages.impl.timeline_TimelineView_Night_0_en",20420,], +["features.messages.impl.timeline_TimelineViewMessageShield_Day_0_en","features.messages.impl.timeline_TimelineViewMessageShield_Night_0_en",20466,], +["features.messages.impl.timeline_TimelineView_Day_0_en","features.messages.impl.timeline_TimelineView_Night_0_en",20466,], ["features.messages.impl.timeline_TimelineView_Day_10_en","features.messages.impl.timeline_TimelineView_Night_10_en",0,], -["features.messages.impl.timeline_TimelineView_Day_11_en","features.messages.impl.timeline_TimelineView_Night_11_en",20420,], -["features.messages.impl.timeline_TimelineView_Day_12_en","features.messages.impl.timeline_TimelineView_Night_12_en",20420,], -["features.messages.impl.timeline_TimelineView_Day_13_en","features.messages.impl.timeline_TimelineView_Night_13_en",20420,], -["features.messages.impl.timeline_TimelineView_Day_14_en","features.messages.impl.timeline_TimelineView_Night_14_en",20420,], -["features.messages.impl.timeline_TimelineView_Day_15_en","features.messages.impl.timeline_TimelineView_Night_15_en",20420,], -["features.messages.impl.timeline_TimelineView_Day_16_en","features.messages.impl.timeline_TimelineView_Night_16_en",20420,], -["features.messages.impl.timeline_TimelineView_Day_17_en","features.messages.impl.timeline_TimelineView_Night_17_en",20420,], -["features.messages.impl.timeline_TimelineView_Day_1_en","features.messages.impl.timeline_TimelineView_Night_1_en",20420,], +["features.messages.impl.timeline_TimelineView_Day_11_en","features.messages.impl.timeline_TimelineView_Night_11_en",20466,], +["features.messages.impl.timeline_TimelineView_Day_12_en","features.messages.impl.timeline_TimelineView_Night_12_en",20466,], +["features.messages.impl.timeline_TimelineView_Day_13_en","features.messages.impl.timeline_TimelineView_Night_13_en",20466,], +["features.messages.impl.timeline_TimelineView_Day_14_en","features.messages.impl.timeline_TimelineView_Night_14_en",20466,], +["features.messages.impl.timeline_TimelineView_Day_15_en","features.messages.impl.timeline_TimelineView_Night_15_en",20466,], +["features.messages.impl.timeline_TimelineView_Day_16_en","features.messages.impl.timeline_TimelineView_Night_16_en",20466,], +["features.messages.impl.timeline_TimelineView_Day_17_en","features.messages.impl.timeline_TimelineView_Night_17_en",20466,], +["features.messages.impl.timeline_TimelineView_Day_1_en","features.messages.impl.timeline_TimelineView_Night_1_en",20466,], ["features.messages.impl.timeline_TimelineView_Day_2_en","features.messages.impl.timeline_TimelineView_Night_2_en",0,], ["features.messages.impl.timeline_TimelineView_Day_3_en","features.messages.impl.timeline_TimelineView_Night_3_en",0,], -["features.messages.impl.timeline_TimelineView_Day_4_en","features.messages.impl.timeline_TimelineView_Night_4_en",20420,], +["features.messages.impl.timeline_TimelineView_Day_4_en","features.messages.impl.timeline_TimelineView_Night_4_en",20466,], ["features.messages.impl.timeline_TimelineView_Day_5_en","features.messages.impl.timeline_TimelineView_Night_5_en",0,], -["features.messages.impl.timeline_TimelineView_Day_6_en","features.messages.impl.timeline_TimelineView_Night_6_en",20420,], +["features.messages.impl.timeline_TimelineView_Day_6_en","features.messages.impl.timeline_TimelineView_Night_6_en",20466,], ["features.messages.impl.timeline_TimelineView_Day_7_en","features.messages.impl.timeline_TimelineView_Night_7_en",0,], -["features.messages.impl.timeline_TimelineView_Day_8_en","features.messages.impl.timeline_TimelineView_Night_8_en",20420,], +["features.messages.impl.timeline_TimelineView_Day_8_en","features.messages.impl.timeline_TimelineView_Night_8_en",0,], ["features.messages.impl.timeline_TimelineView_Day_9_en","features.messages.impl.timeline_TimelineView_Night_9_en",0,], ["libraries.designsystem.components.avatar.internal_TombstonedRoomAvatar_Avatars_en","",0,], ["libraries.designsystem.theme.components_TopAppBarStr_App_Bars_en","",0,], ["libraries.designsystem.theme.components_TopAppBar_App_Bars_en","",0,], -["libraries.troubleshoot.impl_TroubleshootNotificationsView_Day_0_en","libraries.troubleshoot.impl_TroubleshootNotificationsView_Night_0_en",20420,], -["libraries.troubleshoot.impl_TroubleshootNotificationsView_Day_1_en","libraries.troubleshoot.impl_TroubleshootNotificationsView_Night_1_en",20420,], -["libraries.troubleshoot.impl_TroubleshootNotificationsView_Day_2_en","libraries.troubleshoot.impl_TroubleshootNotificationsView_Night_2_en",20420,], -["libraries.troubleshoot.impl_TroubleshootNotificationsView_Day_3_en","libraries.troubleshoot.impl_TroubleshootNotificationsView_Night_3_en",20420,], -["libraries.troubleshoot.impl_TroubleshootNotificationsView_Day_4_en","libraries.troubleshoot.impl_TroubleshootNotificationsView_Night_4_en",20420,], -["libraries.troubleshoot.impl_TroubleshootNotificationsView_Day_5_en","libraries.troubleshoot.impl_TroubleshootNotificationsView_Night_5_en",20420,], -["libraries.troubleshoot.impl_TroubleshootNotificationsView_Day_6_en","libraries.troubleshoot.impl_TroubleshootNotificationsView_Night_6_en",20420,], -["libraries.troubleshoot.impl_TroubleshootNotificationsView_Day_7_en","libraries.troubleshoot.impl_TroubleshootNotificationsView_Night_7_en",20420,], +["libraries.troubleshoot.impl_TroubleshootNotificationsView_Day_0_en","libraries.troubleshoot.impl_TroubleshootNotificationsView_Night_0_en",20466,], +["libraries.troubleshoot.impl_TroubleshootNotificationsView_Day_1_en","libraries.troubleshoot.impl_TroubleshootNotificationsView_Night_1_en",20466,], +["libraries.troubleshoot.impl_TroubleshootNotificationsView_Day_2_en","libraries.troubleshoot.impl_TroubleshootNotificationsView_Night_2_en",20466,], +["libraries.troubleshoot.impl_TroubleshootNotificationsView_Day_3_en","libraries.troubleshoot.impl_TroubleshootNotificationsView_Night_3_en",20466,], +["libraries.troubleshoot.impl_TroubleshootNotificationsView_Day_4_en","libraries.troubleshoot.impl_TroubleshootNotificationsView_Night_4_en",20466,], +["libraries.troubleshoot.impl_TroubleshootNotificationsView_Day_5_en","libraries.troubleshoot.impl_TroubleshootNotificationsView_Night_5_en",20466,], +["libraries.troubleshoot.impl_TroubleshootNotificationsView_Day_6_en","libraries.troubleshoot.impl_TroubleshootNotificationsView_Night_6_en",20466,], +["libraries.troubleshoot.impl_TroubleshootNotificationsView_Day_7_en","libraries.troubleshoot.impl_TroubleshootNotificationsView_Night_7_en",20466,], ["features.messages.impl.typing_TypingNotificationView_Day_0_en","features.messages.impl.typing_TypingNotificationView_Night_0_en",0,], -["features.messages.impl.typing_TypingNotificationView_Day_1_en","features.messages.impl.typing_TypingNotificationView_Night_1_en",20420,], -["features.messages.impl.typing_TypingNotificationView_Day_2_en","features.messages.impl.typing_TypingNotificationView_Night_2_en",20420,], -["features.messages.impl.typing_TypingNotificationView_Day_3_en","features.messages.impl.typing_TypingNotificationView_Night_3_en",20420,], -["features.messages.impl.typing_TypingNotificationView_Day_4_en","features.messages.impl.typing_TypingNotificationView_Night_4_en",20420,], -["features.messages.impl.typing_TypingNotificationView_Day_5_en","features.messages.impl.typing_TypingNotificationView_Night_5_en",20420,], -["features.messages.impl.typing_TypingNotificationView_Day_6_en","features.messages.impl.typing_TypingNotificationView_Night_6_en",20420,], +["features.messages.impl.typing_TypingNotificationView_Day_1_en","features.messages.impl.typing_TypingNotificationView_Night_1_en",20466,], +["features.messages.impl.typing_TypingNotificationView_Day_2_en","features.messages.impl.typing_TypingNotificationView_Night_2_en",20466,], +["features.messages.impl.typing_TypingNotificationView_Day_3_en","features.messages.impl.typing_TypingNotificationView_Night_3_en",20466,], +["features.messages.impl.typing_TypingNotificationView_Day_4_en","features.messages.impl.typing_TypingNotificationView_Night_4_en",20466,], +["features.messages.impl.typing_TypingNotificationView_Day_5_en","features.messages.impl.typing_TypingNotificationView_Night_5_en",20466,], +["features.messages.impl.typing_TypingNotificationView_Day_6_en","features.messages.impl.typing_TypingNotificationView_Night_6_en",20466,], ["features.messages.impl.typing_TypingNotificationView_Day_7_en","features.messages.impl.typing_TypingNotificationView_Night_7_en",0,], ["features.messages.impl.typing_TypingNotificationView_Day_8_en","features.messages.impl.typing_TypingNotificationView_Night_8_en",0,], ["libraries.designsystem.atomic.atoms_UnreadIndicatorAtom_Day_0_en","libraries.designsystem.atomic.atoms_UnreadIndicatorAtom_Night_0_en",0,], -["libraries.matrix.ui.components_UnresolvedUserRow_en","",20420,], -["libraries.matrix.ui.components_UnsavedAvatar_Day_0_en","libraries.matrix.ui.components_UnsavedAvatar_Night_0_en",0,], +["libraries.matrix.ui.components_UnresolvedUserRow_en","",20466,], ["libraries.designsystem.components.avatar.internal_UserAvatarColors_Day_0_en","libraries.designsystem.components.avatar.internal_UserAvatarColors_Night_0_en",0,], -["features.roomdetails.impl.notificationsettings_UserDefinedRoomNotificationSettingsView_Day_0_en","features.roomdetails.impl.notificationsettings_UserDefinedRoomNotificationSettingsView_Night_0_en",20420,], -["features.startchat.impl.components_UserListView_Day_0_en","features.startchat.impl.components_UserListView_Night_0_en",20420,], -["features.startchat.impl.components_UserListView_Day_1_en","features.startchat.impl.components_UserListView_Night_1_en",20420,], -["features.startchat.impl.components_UserListView_Day_2_en","features.startchat.impl.components_UserListView_Night_2_en",20420,], +["features.roomdetails.impl.notificationsettings_UserDefinedRoomNotificationSettingsView_Day_0_en","features.roomdetails.impl.notificationsettings_UserDefinedRoomNotificationSettingsView_Night_0_en",20466,], +["features.startchat.impl.components_UserListView_Day_0_en","features.startchat.impl.components_UserListView_Night_0_en",20466,], +["features.startchat.impl.components_UserListView_Day_1_en","features.startchat.impl.components_UserListView_Night_1_en",20466,], +["features.startchat.impl.components_UserListView_Day_2_en","features.startchat.impl.components_UserListView_Night_2_en",20466,], ["features.startchat.impl.components_UserListView_Day_3_en","features.startchat.impl.components_UserListView_Night_3_en",0,], ["features.startchat.impl.components_UserListView_Day_4_en","features.startchat.impl.components_UserListView_Night_4_en",0,], ["features.startchat.impl.components_UserListView_Day_5_en","features.startchat.impl.components_UserListView_Night_5_en",0,], ["features.startchat.impl.components_UserListView_Day_6_en","features.startchat.impl.components_UserListView_Night_6_en",0,], -["features.startchat.impl.components_UserListView_Day_7_en","features.startchat.impl.components_UserListView_Night_7_en",20420,], +["features.startchat.impl.components_UserListView_Day_7_en","features.startchat.impl.components_UserListView_Night_7_en",20466,], ["features.startchat.impl.components_UserListView_Day_8_en","features.startchat.impl.components_UserListView_Night_8_en",0,], -["features.startchat.impl.components_UserListView_Day_9_en","features.startchat.impl.components_UserListView_Night_9_en",20420,], +["features.startchat.impl.components_UserListView_Day_9_en","features.startchat.impl.components_UserListView_Night_9_en",20466,], ["features.preferences.impl.user_UserPreferences_Day_0_en","features.preferences.impl.user_UserPreferences_Night_0_en",0,], ["features.preferences.impl.user_UserPreferences_Day_1_en","features.preferences.impl.user_UserPreferences_Night_1_en",0,], ["features.preferences.impl.user_UserPreferences_Day_2_en","features.preferences.impl.user_UserPreferences_Night_2_en",0,], -["features.userprofile.shared_UserProfileHeaderSectionWithVerificationViolation_Day_0_en","features.userprofile.shared_UserProfileHeaderSectionWithVerificationViolation_Night_0_en",20420,], -["features.userprofile.shared_UserProfileHeaderSection_Day_0_en","features.userprofile.shared_UserProfileHeaderSection_Night_0_en",20420,], -["features.userprofile.shared_UserProfileView_Day_0_en","features.userprofile.shared_UserProfileView_Night_0_en",20420,], -["features.userprofile.shared_UserProfileView_Day_1_en","features.userprofile.shared_UserProfileView_Night_1_en",20420,], -["features.userprofile.shared_UserProfileView_Day_2_en","features.userprofile.shared_UserProfileView_Night_2_en",20420,], -["features.userprofile.shared_UserProfileView_Day_3_en","features.userprofile.shared_UserProfileView_Night_3_en",20420,], -["features.userprofile.shared_UserProfileView_Day_4_en","features.userprofile.shared_UserProfileView_Night_4_en",20420,], -["features.userprofile.shared_UserProfileView_Day_5_en","features.userprofile.shared_UserProfileView_Night_5_en",20420,], -["features.userprofile.shared_UserProfileView_Day_6_en","features.userprofile.shared_UserProfileView_Night_6_en",20420,], -["features.userprofile.shared_UserProfileView_Day_7_en","features.userprofile.shared_UserProfileView_Night_7_en",20420,], -["features.userprofile.shared_UserProfileView_Day_8_en","features.userprofile.shared_UserProfileView_Night_8_en",20420,], -["features.userprofile.shared_UserProfileView_Day_9_en","features.userprofile.shared_UserProfileView_Night_9_en",20420,], +["features.userprofile.shared_UserProfileHeaderSectionWithVerificationViolation_Day_0_en","features.userprofile.shared_UserProfileHeaderSectionWithVerificationViolation_Night_0_en",20466,], +["features.userprofile.shared_UserProfileHeaderSection_Day_0_en","features.userprofile.shared_UserProfileHeaderSection_Night_0_en",20466,], +["features.userprofile.shared_UserProfileView_Day_0_en","features.userprofile.shared_UserProfileView_Night_0_en",20466,], +["features.userprofile.shared_UserProfileView_Day_1_en","features.userprofile.shared_UserProfileView_Night_1_en",20466,], +["features.userprofile.shared_UserProfileView_Day_2_en","features.userprofile.shared_UserProfileView_Night_2_en",20466,], +["features.userprofile.shared_UserProfileView_Day_3_en","features.userprofile.shared_UserProfileView_Night_3_en",20466,], +["features.userprofile.shared_UserProfileView_Day_4_en","features.userprofile.shared_UserProfileView_Night_4_en",20466,], +["features.userprofile.shared_UserProfileView_Day_5_en","features.userprofile.shared_UserProfileView_Night_5_en",20466,], +["features.userprofile.shared_UserProfileView_Day_6_en","features.userprofile.shared_UserProfileView_Night_6_en",20466,], +["features.userprofile.shared_UserProfileView_Day_7_en","features.userprofile.shared_UserProfileView_Night_7_en",20466,], +["features.userprofile.shared_UserProfileView_Day_8_en","features.userprofile.shared_UserProfileView_Night_8_en",20466,], +["features.userprofile.shared_UserProfileView_Day_9_en","features.userprofile.shared_UserProfileView_Night_9_en",20466,], ["features.verifysession.impl.ui_VerificationUserProfileContent_Day_0_en","features.verifysession.impl.ui_VerificationUserProfileContent_Night_0_en",0,], ["libraries.designsystem.ruler_VerticalRuler_Day_0_en","libraries.designsystem.ruler_VerticalRuler_Night_0_en",0,], ["libraries.mediaviewer.impl.gallery.ui_VideoItemView_Day_0_en","libraries.mediaviewer.impl.gallery.ui_VideoItemView_Night_0_en",0,], ["libraries.mediaviewer.impl.gallery.ui_VideoItemView_Day_1_en","libraries.mediaviewer.impl.gallery.ui_VideoItemView_Night_1_en",0,], -["features.preferences.impl.advanced_VideoQualitySelectorDialog_Day_0_en","features.preferences.impl.advanced_VideoQualitySelectorDialog_Night_0_en",20420,], -["features.messages.impl.attachments.preview_VideoQualitySelectorDialog_Day_0_en","features.messages.impl.attachments.preview_VideoQualitySelectorDialog_Night_0_en",20420,], +["features.preferences.impl.advanced_VideoQualitySelectorDialog_Day_0_en","features.preferences.impl.advanced_VideoQualitySelectorDialog_Night_0_en",20466,], +["features.messages.impl.attachments.preview_VideoQualitySelectorDialog_Day_0_en","features.messages.impl.attachments.preview_VideoQualitySelectorDialog_Night_0_en",20466,], ["features.viewfolder.impl.file_ViewFileView_Day_0_en","features.viewfolder.impl.file_ViewFileView_Night_0_en",0,], ["features.viewfolder.impl.file_ViewFileView_Day_1_en","features.viewfolder.impl.file_ViewFileView_Night_1_en",0,], ["features.viewfolder.impl.file_ViewFileView_Day_2_en","features.viewfolder.impl.file_ViewFileView_Night_2_en",0,], -["features.viewfolder.impl.file_ViewFileView_Day_3_en","features.viewfolder.impl.file_ViewFileView_Night_3_en",20420,], +["features.viewfolder.impl.file_ViewFileView_Day_3_en","features.viewfolder.impl.file_ViewFileView_Night_3_en",20466,], ["features.viewfolder.impl.file_ViewFileView_Day_4_en","features.viewfolder.impl.file_ViewFileView_Night_4_en",0,], ["features.viewfolder.impl.file_ViewFileView_Day_5_en","features.viewfolder.impl.file_ViewFileView_Night_5_en",0,], ["features.viewfolder.impl.folder_ViewFolderView_Day_0_en","features.viewfolder.impl.folder_ViewFolderView_Night_0_en",0,], @@ -1534,10 +1590,10 @@ export const screenshots = [ ["libraries.mediaviewer.impl.gallery.ui_VoiceItemView_Day_1_en","libraries.mediaviewer.impl.gallery.ui_VoiceItemView_Night_1_en",0,], ["libraries.mediaviewer.impl.gallery.ui_VoiceItemView_Day_2_en","libraries.mediaviewer.impl.gallery.ui_VoiceItemView_Night_2_en",0,], ["libraries.mediaviewer.impl.gallery.ui_VoiceItemView_Day_3_en","libraries.mediaviewer.impl.gallery.ui_VoiceItemView_Night_3_en",0,], -["libraries.textcomposer.components_VoiceMessageDeleteButton_Day_0_en","libraries.textcomposer.components_VoiceMessageDeleteButton_Night_0_en",0,], -["libraries.textcomposer.components_VoiceMessageRecorderButton_Day_0_en","libraries.textcomposer.components_VoiceMessageRecorderButton_Night_0_en",0,], +["libraries.textcomposer.components_VoiceMessageDeleteButtonIcon_Day_0_en","libraries.textcomposer.components_VoiceMessageDeleteButtonIcon_Night_0_en",0,], +["libraries.textcomposer.components_VoiceMessagePreview_Day_0_en","libraries.textcomposer.components_VoiceMessagePreview_Night_0_en",0,], +["libraries.textcomposer.components_VoiceMessageRecorderButtonIcon_Day_0_en","libraries.textcomposer.components_VoiceMessageRecorderButtonIcon_Night_0_en",0,], ["libraries.textcomposer.components_VoiceMessageRecording_Day_0_en","libraries.textcomposer.components_VoiceMessageRecording_Night_0_en",0,], -["libraries.textcomposer.components_VoiceMessage_Day_0_en","libraries.textcomposer.components_VoiceMessage_Night_0_en",0,], ["libraries.designsystem.components.media_WaveformPlaybackView_Day_0_en","libraries.designsystem.components.media_WaveformPlaybackView_Night_0_en",0,], ["libraries.designsystem.ruler_WithRulers_Day_0_en","libraries.designsystem.ruler_WithRulers_Night_0_en",0,], ]; diff --git a/services/analytics/api/src/main/kotlin/io/element/android/services/analytics/api/AnalyticsLongRunningTransaction.kt b/services/analytics/api/src/main/kotlin/io/element/android/services/analytics/api/AnalyticsLongRunningTransaction.kt index b318807a45a..9c991cfce20 100644 --- a/services/analytics/api/src/main/kotlin/io/element/android/services/analytics/api/AnalyticsLongRunningTransaction.kt +++ b/services/analytics/api/src/main/kotlin/io/element/android/services/analytics/api/AnalyticsLongRunningTransaction.kt @@ -7,15 +7,24 @@ package io.element.android.services.analytics.api +import io.element.android.services.analyticsproviders.api.AnalyticsTransactions +import io.element.android.services.analyticsproviders.api.TransactionDefinition + sealed class AnalyticsLongRunningTransaction( val name: String, - val operation: String?, + val operation: String? = null, + val description: String? = null, ) { - data object ColdStartUntilCachedRoomList : AnalyticsLongRunningTransaction("Cold start until cached room list is displayed", null) - data object FirstRoomsDisplayed : AnalyticsLongRunningTransaction("First rooms displayed after login or restoration", null) - data object ResumeAppUntilNewRoomsReceived : AnalyticsLongRunningTransaction("App was resumed and new room list items arrived", null) - data object NotificationTapOpensTimeline : AnalyticsLongRunningTransaction("A notification was tapped and it opened a timeline", null) - data object OpenRoom : AnalyticsLongRunningTransaction("Open a room and see loaded items in the timeline", null) + constructor(definition: TransactionDefinition) : this(definition.name, definition.operation, definition.description) + + // UX flows + data object ColdStart : AnalyticsLongRunningTransaction(AnalyticsTransactions.coldStart) + data object CatchUp : AnalyticsLongRunningTransaction(AnalyticsTransactions.catchUp) + data object NotificationToMessage : AnalyticsLongRunningTransaction(AnalyticsTransactions.notificationToMessage) + data object OpenRoom : AnalyticsLongRunningTransaction(AnalyticsTransactions.openRoom) + + // Technical flows + data object FirstRoomsDisplayed : AnalyticsLongRunningTransaction("First rooms displayed after login or restoration", null, null) data object LoadJoinedRoomFlow : AnalyticsLongRunningTransaction("Load joined room UI", "ui.load") data object LoadMessagesUi : AnalyticsLongRunningTransaction("Load messages UI", "ui.load") data object DisplayFirstTimelineItems : AnalyticsLongRunningTransaction("Get and display first timeline items", null) diff --git a/services/analytics/api/src/main/kotlin/io/element/android/services/analytics/api/AnalyticsSdkManager.kt b/services/analytics/api/src/main/kotlin/io/element/android/services/analytics/api/AnalyticsSdkManager.kt new file mode 100644 index 00000000000..bf5e04509cc --- /dev/null +++ b/services/analytics/api/src/main/kotlin/io/element/android/services/analytics/api/AnalyticsSdkManager.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2025 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.services.analytics.api + +/** + * Manager to handle SDK analytics (e.g., Sentry). + */ +interface AnalyticsSdkManager { + /** + * Enable or disable SDK analytics. + */ + fun enableSdkAnalytics(enabled: Boolean) + + /** + * Start a new span with the given [name], using [parentTraceId] to optionally attach it to a parent transaction. + */ + fun startSpan(name: String, parentTraceId: String? = null): AnalyticsSdkSpan + + /** + * Create a 'bridge' span optionally linking it to a parent trace via [parentTraceId]. + */ + fun bridge(parentTraceId: String? = null): AnalyticsSdkSpan +} diff --git a/services/analytics/api/src/main/kotlin/io/element/android/services/analytics/api/AnalyticsSdkSpan.kt b/services/analytics/api/src/main/kotlin/io/element/android/services/analytics/api/AnalyticsSdkSpan.kt new file mode 100644 index 00000000000..92f79da7f97 --- /dev/null +++ b/services/analytics/api/src/main/kotlin/io/element/android/services/analytics/api/AnalyticsSdkSpan.kt @@ -0,0 +1,19 @@ +/* + * Copyright (c) 2025 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.services.analytics.api + +/** + * Represents an analytics span in the Rust SDK. + */ +interface AnalyticsSdkSpan { + /** Enters the span and starts collecting metrics. */ + fun enter() + + /** Exit the span and stop collecting the metrics. A request should be sent shortly after. */ + fun exit() +} diff --git a/services/analytics/api/src/main/kotlin/io/element/android/services/analytics/api/AnalyticsService.kt b/services/analytics/api/src/main/kotlin/io/element/android/services/analytics/api/AnalyticsService.kt index 08562b01ce1..1449977d917 100644 --- a/services/analytics/api/src/main/kotlin/io/element/android/services/analytics/api/AnalyticsService.kt +++ b/services/analytics/api/src/main/kotlin/io/element/android/services/analytics/api/AnalyticsService.kt @@ -53,7 +53,7 @@ interface AnalyticsService : AnalyticsTracker, ErrorTracker { /** * Starts a transaction to measure the performance of an operation. */ - fun startTransaction(name: String, operation: String? = null): AnalyticsTransaction + fun startTransaction(name: String, operation: String? = null, description: String? = null): AnalyticsTransaction /** * Starts an [AnalyticsLongRunningTransaction], that can be shared with other components. @@ -72,16 +72,20 @@ interface AnalyticsService : AnalyticsTracker, ErrorTracker { * Removes an ongoing [AnalyticsLongRunningTransaction] so it's no longer shared. */ fun removeLongRunningTransaction(longRunningTransaction: AnalyticsLongRunningTransaction): AnalyticsTransaction? + + /** Enter a span inside the Rust SDK tracing system. If a [parentTraceId] is provided, the SDK trace will be added as a child of that trace. */ + fun enterSdkSpan(name: String?, parentTraceId: String?): AnalyticsSdkSpan } inline fun AnalyticsService.recordTransaction( name: String, operation: String, + description: String? = null, parentTransaction: AnalyticsTransaction? = null, block: (AnalyticsTransaction) -> T ): T { - val transaction = parentTransaction?.startChild(name, operation) - ?: startTransaction(name, operation) + val transaction = parentTransaction?.startChild(operation, description) + ?: startTransaction(name, operation, description) try { val result = block(transaction) return result @@ -110,3 +114,12 @@ fun AnalyticsService.finishLongRunningTransaction( it.finish() } } + +inline fun AnalyticsService.inBridgeSdkSpan(parentTraceId: String?, block: (AnalyticsSdkSpan) -> T): T { + val span = enterSdkSpan(name = null, parentTraceId = parentTraceId) + return try { + block(span) + } finally { + span.exit() + } +} diff --git a/services/analytics/api/src/main/kotlin/io/element/android/services/analytics/api/NoopAnalyticsSdkSpan.kt b/services/analytics/api/src/main/kotlin/io/element/android/services/analytics/api/NoopAnalyticsSdkSpan.kt new file mode 100644 index 00000000000..d4db9359e25 --- /dev/null +++ b/services/analytics/api/src/main/kotlin/io/element/android/services/analytics/api/NoopAnalyticsSdkSpan.kt @@ -0,0 +1,13 @@ +/* + * Copyright (c) 2025 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.services.analytics.api + +object NoopAnalyticsSdkSpan : AnalyticsSdkSpan { + override fun enter() {} + override fun exit() {} +} diff --git a/services/analytics/api/src/main/kotlin/io/element/android/services/analytics/api/NoopAnalyticsTransaction.kt b/services/analytics/api/src/main/kotlin/io/element/android/services/analytics/api/NoopAnalyticsTransaction.kt index 2b18f8408ca..914fac1b127 100644 --- a/services/analytics/api/src/main/kotlin/io/element/android/services/analytics/api/NoopAnalyticsTransaction.kt +++ b/services/analytics/api/src/main/kotlin/io/element/android/services/analytics/api/NoopAnalyticsTransaction.kt @@ -11,7 +11,10 @@ import io.element.android.services.analyticsproviders.api.AnalyticsTransaction object NoopAnalyticsTransaction : AnalyticsTransaction { override fun startChild(operation: String, description: String?): AnalyticsTransaction = NoopAnalyticsTransaction - override fun setData(key: String, value: Any) {} + override fun putExtraData(key: String, value: String) {} + override fun putIndexableData(key: String, value: String) {} override fun isFinished(): Boolean = true + override fun traceId(): String? = null + override fun attachError(throwable: Throwable) {} override fun finish() {} } diff --git a/services/analytics/api/src/main/kotlin/io/element/android/services/analytics/api/watchers/AnalyticsSendMessageWatcher.kt b/services/analytics/api/src/main/kotlin/io/element/android/services/analytics/api/watchers/AnalyticsSendMessageWatcher.kt new file mode 100644 index 00000000000..4c3010f22e2 --- /dev/null +++ b/services/analytics/api/src/main/kotlin/io/element/android/services/analytics/api/watchers/AnalyticsSendMessageWatcher.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2025 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.services.analytics.api.watchers + +/** + * An analytics watcher tracking the time it took the client to send a message. + */ +interface AnalyticsSendMessageWatcher { + /** + * Start listening to send queue updates and tracking the sending states of the events. + */ + fun start() + + /** + * Stop observing the sending states of the events. + */ + fun stop() +} diff --git a/services/analytics/impl/src/main/kotlin/io/element/android/services/analytics/impl/DefaultAnalyticsService.kt b/services/analytics/impl/src/main/kotlin/io/element/android/services/analytics/impl/DefaultAnalyticsService.kt index b88862367dc..16c7d45f9ea 100644 --- a/services/analytics/impl/src/main/kotlin/io/element/android/services/analytics/impl/DefaultAnalyticsService.kt +++ b/services/analytics/impl/src/main/kotlin/io/element/android/services/analytics/impl/DefaultAnalyticsService.kt @@ -20,7 +20,10 @@ import io.element.android.libraries.di.annotations.AppCoroutineScope import io.element.android.libraries.sessionstorage.api.observer.SessionListener import io.element.android.libraries.sessionstorage.api.observer.SessionObserver import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction +import io.element.android.services.analytics.api.AnalyticsSdkManager +import io.element.android.services.analytics.api.AnalyticsSdkSpan import io.element.android.services.analytics.api.AnalyticsService +import io.element.android.services.analytics.api.NoopAnalyticsSdkSpan import io.element.android.services.analytics.api.NoopAnalyticsTransaction import io.element.android.services.analytics.impl.log.analyticsTag import io.element.android.services.analytics.impl.store.AnalyticsStore @@ -40,10 +43,9 @@ import java.util.concurrent.atomic.AtomicBoolean class DefaultAnalyticsService( private val analyticsProviders: Set<@JvmSuppressWildcards AnalyticsProvider>, private val analyticsStore: AnalyticsStore, -// private val lateInitUserPropertiesFactory: LateInitUserPropertiesFactory, - @AppCoroutineScope - private val coroutineScope: CoroutineScope, + @AppCoroutineScope private val coroutineScope: CoroutineScope, private val sessionObserver: SessionObserver, + private val analyticsSdkManager: AnalyticsSdkManager, ) : AnalyticsService, SessionListener { private val pendingLongRunningTransactions = ConcurrentHashMap() @@ -69,6 +71,7 @@ class DefaultAnalyticsService( override suspend fun setUserConsent(userConsent: Boolean) { Timber.tag(analyticsTag.value).d("setUserConsent($userConsent)") analyticsStore.setUserConsent(userConsent) + analyticsSdkManager.enableSdkAnalytics(enabled = userConsent) } override suspend fun setDidAskUserConsent() { @@ -85,6 +88,7 @@ class DefaultAnalyticsService( // Delete the store when the last session is deleted if (wasLastSession) { analyticsStore.reset() + analyticsSdkManager.enableSdkAnalytics(false) } } @@ -146,9 +150,21 @@ class DefaultAnalyticsService( } } - override fun startTransaction(name: String, operation: String?): AnalyticsTransaction { + override fun addExtraData(key: String, value: String) { + if (userConsent.get()) { + analyticsProviders.onEach { it.addExtraData(key, value) } + } + } + + override fun addIndexableData(key: String, value: String) { + if (userConsent.get()) { + analyticsProviders.onEach { it.addIndexableData(key, value) } + } + } + + override fun startTransaction(name: String, operation: String?, description: String?): AnalyticsTransaction { return if (userConsent.get()) { - analyticsProviders.firstNotNullOfOrNull { it.startTransaction(name, operation) } + analyticsProviders.firstNotNullOfOrNull { it.startTransaction(name, operation, description) } } else { null } ?: NoopAnalyticsTransaction @@ -158,8 +174,8 @@ class DefaultAnalyticsService( longRunningTransaction: AnalyticsLongRunningTransaction, parentTransaction: AnalyticsTransaction?, ): AnalyticsTransaction { - val transaction = parentTransaction?.startChild(longRunningTransaction.name, longRunningTransaction.operation) - ?: startTransaction(longRunningTransaction.name, longRunningTransaction.operation) + val transaction = parentTransaction?.startChild(longRunningTransaction.operation.orEmpty(), longRunningTransaction.name) + ?: startTransaction(longRunningTransaction.name, longRunningTransaction.operation, longRunningTransaction.description) pendingLongRunningTransactions[longRunningTransaction] = transaction return transaction @@ -172,4 +188,16 @@ class DefaultAnalyticsService( override fun removeLongRunningTransaction(longRunningTransaction: AnalyticsLongRunningTransaction): AnalyticsTransaction? { return pendingLongRunningTransactions.remove(longRunningTransaction) } + + override fun enterSdkSpan(name: String?, parentTraceId: String?): AnalyticsSdkSpan { + return if (userConsent.get()) { + if (name != null) { + analyticsSdkManager.startSpan(name, parentTraceId) + } else { + analyticsSdkManager.bridge(parentTraceId) + }.apply { enter() } + } else { + NoopAnalyticsSdkSpan + } + } } diff --git a/services/analytics/impl/src/main/kotlin/io/element/android/services/analytics/impl/watchers/DefaultAnalyticsColdStartWatcher.kt b/services/analytics/impl/src/main/kotlin/io/element/android/services/analytics/impl/watchers/DefaultAnalyticsColdStartWatcher.kt index c53e28918bf..e596bc35a5e 100644 --- a/services/analytics/impl/src/main/kotlin/io/element/android/services/analytics/impl/watchers/DefaultAnalyticsColdStartWatcher.kt +++ b/services/analytics/impl/src/main/kotlin/io/element/android/services/analytics/impl/watchers/DefaultAnalyticsColdStartWatcher.kt @@ -37,7 +37,7 @@ class DefaultAnalyticsColdStartWatcher( if (hasConsent) { if (isColdStart.get()) { Timber.d("Starting cold start check") - analyticsService.startLongRunningTransaction(AnalyticsLongRunningTransaction.ColdStartUntilCachedRoomList) + analyticsService.startLongRunningTransaction(AnalyticsLongRunningTransaction.ColdStart) } else { error("The app is no longer in a cold start state") } @@ -49,7 +49,7 @@ class DefaultAnalyticsColdStartWatcher( override fun whenLoggingIn() { if (isColdStart.getAndSet(false)) { - analyticsService.cancelLongRunningTransaction(AnalyticsLongRunningTransaction.ColdStartUntilCachedRoomList) + analyticsService.cancelLongRunningTransaction(AnalyticsLongRunningTransaction.ColdStart) Timber.d("Canceled cold start check: user is logging in") } } @@ -57,7 +57,7 @@ class DefaultAnalyticsColdStartWatcher( override fun onRoomListVisible() { if (isColdStart.getAndSet(false)) { Timber.d("Room list is visible, finishing cold start check") - analyticsService.finishLongRunningTransaction(AnalyticsLongRunningTransaction.ColdStartUntilCachedRoomList) + analyticsService.finishLongRunningTransaction(AnalyticsLongRunningTransaction.ColdStart) } } } diff --git a/services/analytics/impl/src/main/kotlin/io/element/android/services/analytics/impl/watchers/DefaultAnalyticsRoomListStateWatcher.kt b/services/analytics/impl/src/main/kotlin/io/element/android/services/analytics/impl/watchers/DefaultAnalyticsRoomListStateWatcher.kt index 97e438fceef..7e11e703ec0 100644 --- a/services/analytics/impl/src/main/kotlin/io/element/android/services/analytics/impl/watchers/DefaultAnalyticsRoomListStateWatcher.kt +++ b/services/analytics/impl/src/main/kotlin/io/element/android/services/analytics/impl/watchers/DefaultAnalyticsRoomListStateWatcher.kt @@ -52,7 +52,7 @@ class DefaultAnalyticsRoomListStateWatcher( .withPreviousValue() .onEach { (wasInForeground, isInForeground) -> if (isInForeground && roomListService.state.value != RoomListService.State.Running) { - analyticsService.startLongRunningTransaction(AnalyticsLongRunningTransaction.ResumeAppUntilNewRoomsReceived) + analyticsService.startLongRunningTransaction(AnalyticsLongRunningTransaction.CatchUp) } if (wasInForeground == false && isInForeground) { @@ -64,7 +64,7 @@ class DefaultAnalyticsRoomListStateWatcher( roomListService.state .onEach { state -> if (state == RoomListService.State.Running && isWarmState.get()) { - analyticsService.finishLongRunningTransaction(AnalyticsLongRunningTransaction.ResumeAppUntilNewRoomsReceived) + analyticsService.finishLongRunningTransaction(AnalyticsLongRunningTransaction.CatchUp) } } .launchIn(coroutineScope) diff --git a/services/analytics/impl/src/main/kotlin/io/element/android/services/analytics/impl/watchers/DefaultAnalyticsSendMessageWatcher.kt b/services/analytics/impl/src/main/kotlin/io/element/android/services/analytics/impl/watchers/DefaultAnalyticsSendMessageWatcher.kt new file mode 100644 index 00000000000..642107cdecc --- /dev/null +++ b/services/analytics/impl/src/main/kotlin/io/element/android/services/analytics/impl/watchers/DefaultAnalyticsSendMessageWatcher.kt @@ -0,0 +1,82 @@ +/* + * Copyright (c) 2025 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.services.analytics.impl.watchers + +import dev.zacsweers.metro.ContributesBinding +import dev.zacsweers.metro.SingleIn +import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.di.annotations.RoomCoroutineScope +import io.element.android.libraries.matrix.api.core.TransactionId +import io.element.android.libraries.matrix.api.room.JoinedRoom +import io.element.android.libraries.matrix.api.room.SendQueueUpdate +import io.element.android.services.analytics.api.AnalyticsService +import io.element.android.services.analytics.api.watchers.AnalyticsSendMessageWatcher +import io.element.android.services.analyticsproviders.api.AnalyticsTransaction +import io.element.android.services.analyticsproviders.api.AnalyticsTransactions +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import timber.log.Timber +import java.util.concurrent.ConcurrentHashMap + +private const val TAG = "SendMessageWatcher" + +@SingleIn(RoomScope::class) +@ContributesBinding(RoomScope::class) +class DefaultAnalyticsSendMessageWatcher( + private val room: JoinedRoom, + private val analyticsService: AnalyticsService, + @RoomCoroutineScope private val coroutineScope: CoroutineScope, +) : AnalyticsSendMessageWatcher { + private val pendingEvents = ConcurrentHashMap() + private var sendQueueWatchJob: Job? = null + + @OptIn(ExperimentalCoroutinesApi::class) + override fun start() { + Timber.tag(TAG).d("Starting SendMessageWatcher") + sendQueueWatchJob?.cancel() + sendQueueWatchJob = room.subscribeToSendQueueUpdates() + .onEach { update -> + // We received a new local event + when (update) { + is SendQueueUpdate.NewLocalEvent -> { + Timber.tag(TAG).d("Event with transaction id ${update.transactionId} sent") + watch(update.transactionId) + } + is SendQueueUpdate.SentEvent -> { + val pendingTransaction = pendingEvents.remove(update.transactionId) + if (pendingTransaction != null) { + Timber.tag(TAG).d("Sent event with transaction id ${update.transactionId} received in sync") + pendingTransaction.finish() + } + } + else -> Unit + } + } + .launchIn(coroutineScope) + } + + override fun stop() { + Timber.tag(TAG).d("Stopping SendMessageWatcher") + sendQueueWatchJob?.cancel() + sendQueueWatchJob = null + pendingEvents.clear() + } + + private fun watch(transactionId: TransactionId) { + pendingEvents[transactionId] = with(AnalyticsTransactions.sendMessage) { + analyticsService.startTransaction( + name = name, + operation = operation, + description = description, + ) + } + } +} diff --git a/services/analytics/impl/src/test/kotlin/io/element/android/services/analytics/impl/DefaultAnalyticsServiceTest.kt b/services/analytics/impl/src/test/kotlin/io/element/android/services/analytics/impl/DefaultAnalyticsServiceTest.kt index 86a0c08d253..7de3d0ac533 100644 --- a/services/analytics/impl/src/test/kotlin/io/element/android/services/analytics/impl/DefaultAnalyticsServiceTest.kt +++ b/services/analytics/impl/src/test/kotlin/io/element/android/services/analytics/impl/DefaultAnalyticsServiceTest.kt @@ -17,6 +17,7 @@ import im.vector.app.features.analytics.plan.MobileScreen import im.vector.app.features.analytics.plan.PollEnd import im.vector.app.features.analytics.plan.SuperProperties import im.vector.app.features.analytics.plan.UserProperties +import io.element.android.libraries.matrix.test.analytics.FakeAnalyticsSdkManager import io.element.android.libraries.sessionstorage.api.observer.SessionObserver import io.element.android.libraries.sessionstorage.test.observer.NoOpSessionObserver import io.element.android.services.analytics.impl.store.AnalyticsStore @@ -32,6 +33,7 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest import org.junit.Test @@ -126,17 +128,20 @@ class DefaultAnalyticsServiceTest { } @Test - fun `setUserConsent is sent to the store`() = runTest { + fun `setUserConsent is sent to the store and the SDK`() = runTest { + val sdkAnalyticsEnabledLambda = lambdaRecorder {} val store = FakeAnalyticsStore() val sut = createDefaultAnalyticsService( coroutineScope = backgroundScope, analyticsStore = store, + sdkAnalyticsManager = FakeAnalyticsSdkManager(sdkAnalyticsEnabledLambda), ) assertThat(store.userConsentFlow.first()).isFalse() assertThat(sut.userConsentFlow.first()).isFalse() sut.setUserConsent(true) assertThat(store.userConsentFlow.first()).isTrue() assertThat(sut.userConsentFlow.first()).isTrue() + sdkAnalyticsEnabledLambda.assertions().isCalledOnce().with(value(true)) } @Test @@ -169,16 +174,19 @@ class DefaultAnalyticsServiceTest { @Test fun `when the last session is deleted, the store is reset`() = runTest { - val resetLambda = lambdaRecorder { } + val resetLambda = lambdaRecorder {} + val sdkAnalyticsEnabledLambda = lambdaRecorder {} val store = FakeAnalyticsStore( resetLambda = resetLambda, ) val sut = createDefaultAnalyticsService( coroutineScope = backgroundScope, analyticsStore = store, + sdkAnalyticsManager = FakeAnalyticsSdkManager(sdkAnalyticsEnabledLambda), ) sut.onSessionDeleted("userId", true) resetLambda.assertions().isCalledOnce() + sdkAnalyticsEnabledLambda.assertions().isCalledOnce().with(value(false)) } @Test @@ -234,7 +242,6 @@ class DefaultAnalyticsServiceTest { fun `when consent is provided, updateUserProperties is sent to the provider`() = runTest { val updateUserPropertiesLambda = lambdaRecorder { _ -> } val sut = createDefaultAnalyticsService( - coroutineScope = backgroundScope, analyticsProviders = setOf( FakeAnalyticsProvider( initLambda = { }, @@ -251,7 +258,6 @@ class DefaultAnalyticsServiceTest { fun `when super properties are updated, updateSuperProperties is sent to the provider`() = runTest { val updateSuperPropertiesLambda = lambdaRecorder { _ -> } val sut = createDefaultAnalyticsService( - coroutineScope = backgroundScope, analyticsProviders = setOf( FakeAnalyticsProvider( initLambda = { }, @@ -264,8 +270,15 @@ class DefaultAnalyticsServiceTest { updateSuperPropertiesLambda.assertions().isCalledOnce().with(value(aSuperProperty)) } - private suspend fun createDefaultAnalyticsService( - coroutineScope: CoroutineScope, + @Test + fun `startSdkSpan returns a span from the AnalyticsSdkManager`() = runTest { + val sut = createDefaultAnalyticsService() + val span = sut.enterSdkSpan("spanName", "parentTraceId") + assertThat(span).isNotNull() + } + + private suspend fun TestScope.createDefaultAnalyticsService( + coroutineScope: CoroutineScope = backgroundScope, analyticsProviders: Set<@JvmSuppressWildcards AnalyticsProvider> = setOf( FakeAnalyticsProvider( stopLambda = { }, @@ -273,11 +286,13 @@ class DefaultAnalyticsServiceTest { ), analyticsStore: AnalyticsStore = FakeAnalyticsStore(), sessionObserver: SessionObserver = NoOpSessionObserver(), + sdkAnalyticsManager: FakeAnalyticsSdkManager = FakeAnalyticsSdkManager(enableSdkAnalyticsLambda = {}), ) = DefaultAnalyticsService( analyticsProviders = analyticsProviders, analyticsStore = analyticsStore, coroutineScope = coroutineScope, sessionObserver = sessionObserver, + analyticsSdkManager = sdkAnalyticsManager, ).also { // Wait for the service to be ready delay(1) diff --git a/services/analytics/impl/src/test/kotlin/io/element/android/services/analytics/impl/watchers/DefaultAnalyticsColdStartWatcherTest.kt b/services/analytics/impl/src/test/kotlin/io/element/android/services/analytics/impl/watchers/DefaultAnalyticsColdStartWatcherTest.kt index 325316b45c0..88d6be010f5 100644 --- a/services/analytics/impl/src/test/kotlin/io/element/android/services/analytics/impl/watchers/DefaultAnalyticsColdStartWatcherTest.kt +++ b/services/analytics/impl/src/test/kotlin/io/element/android/services/analytics/impl/watchers/DefaultAnalyticsColdStartWatcherTest.kt @@ -8,7 +8,7 @@ package io.element.android.services.analytics.impl.watchers import com.google.common.truth.Truth.assertThat -import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.ColdStartUntilCachedRoomList +import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.ColdStart import io.element.android.services.analytics.test.FakeAnalyticsService import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.TestScope @@ -31,14 +31,14 @@ class DefaultAnalyticsColdStartWatcherTest { runCurrent() // The transaction is running - assertThat(analyticsService.getLongRunningTransaction(ColdStartUntilCachedRoomList)).isNotNull() + assertThat(analyticsService.getLongRunningTransaction(ColdStart)).isNotNull() // As soon as the room list is visible watcher.onRoomListVisible() runCurrent() // The transaction is now finished - assertThat(analyticsService.getLongRunningTransaction(ColdStartUntilCachedRoomList)).isNull() + assertThat(analyticsService.getLongRunningTransaction(ColdStart)).isNull() } @Test @@ -54,14 +54,14 @@ class DefaultAnalyticsColdStartWatcherTest { runCurrent() // The transaction is running - assertThat(analyticsService.getLongRunningTransaction(ColdStartUntilCachedRoomList)).isNotNull() + assertThat(analyticsService.getLongRunningTransaction(ColdStart)).isNotNull() // If the user starts a login flow watcher.whenLoggingIn() runCurrent() // The transaction is gone - assertThat(analyticsService.getLongRunningTransaction(ColdStartUntilCachedRoomList)).isNull() + assertThat(analyticsService.getLongRunningTransaction(ColdStart)).isNull() } @Test @@ -80,7 +80,7 @@ class DefaultAnalyticsColdStartWatcherTest { runCurrent() // The transaction never starts - assertThat(analyticsService.getLongRunningTransaction(ColdStartUntilCachedRoomList)).isNull() + assertThat(analyticsService.getLongRunningTransaction(ColdStart)).isNull() } @Test @@ -95,7 +95,7 @@ class DefaultAnalyticsColdStartWatcherTest { runCurrent() // The transaction is not running in that case - assertThat(analyticsService.getLongRunningTransaction(ColdStartUntilCachedRoomList)).isNull() + assertThat(analyticsService.getLongRunningTransaction(ColdStart)).isNull() } private fun TestScope.createAnalyticsColdStartWatcher( diff --git a/services/analytics/impl/src/test/kotlin/io/element/android/services/analytics/impl/watchers/DefaultAnalyticsRoomListStateWatcherTest.kt b/services/analytics/impl/src/test/kotlin/io/element/android/services/analytics/impl/watchers/DefaultAnalyticsRoomListStateWatcherTest.kt index 3b1f562c673..8796d6a2eec 100644 --- a/services/analytics/impl/src/test/kotlin/io/element/android/services/analytics/impl/watchers/DefaultAnalyticsRoomListStateWatcherTest.kt +++ b/services/analytics/impl/src/test/kotlin/io/element/android/services/analytics/impl/watchers/DefaultAnalyticsRoomListStateWatcherTest.kt @@ -10,7 +10,7 @@ package io.element.android.services.analytics.impl.watchers import com.google.common.truth.Truth.assertThat import io.element.android.libraries.matrix.api.roomlist.RoomListService import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService -import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.ResumeAppUntilNewRoomsReceived +import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction.CatchUp import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.services.appnavstate.api.AppNavigationState import io.element.android.services.appnavstate.api.NavigationState @@ -49,14 +49,14 @@ class DefaultAnalyticsRoomListStateWatcherTest { runCurrent() // The transaction should be present now - assertThat(analyticsService.getLongRunningTransaction(ResumeAppUntilNewRoomsReceived)).isNotNull() + assertThat(analyticsService.getLongRunningTransaction(CatchUp)).isNotNull() // And now the room list service running roomListService.postState(RoomListService.State.Running) runCurrent() // And the transaction should now be gone - assertThat(analyticsService.getLongRunningTransaction(ResumeAppUntilNewRoomsReceived)).isNull() + assertThat(analyticsService.getLongRunningTransaction(CatchUp)).isNull() watcher.stop() } @@ -86,7 +86,7 @@ class DefaultAnalyticsRoomListStateWatcherTest { runCurrent() // The transaction was never present - assertThat(analyticsService.getLongRunningTransaction(ResumeAppUntilNewRoomsReceived)).isNull() + assertThat(analyticsService.getLongRunningTransaction(CatchUp)).isNull() watcher.stop() } @@ -116,12 +116,12 @@ class DefaultAnalyticsRoomListStateWatcherTest { runCurrent() // The transaction should be present now - assertThat(analyticsService.getLongRunningTransaction(ResumeAppUntilNewRoomsReceived)).isNotNull() + assertThat(analyticsService.getLongRunningTransaction(CatchUp)).isNotNull() runCurrent() // But without the room list syncing, it never finishes - assertThat(analyticsService.getLongRunningTransaction(ResumeAppUntilNewRoomsReceived)).isNotNull() + assertThat(analyticsService.getLongRunningTransaction(CatchUp)).isNotNull() watcher.stop() } @@ -151,7 +151,7 @@ class DefaultAnalyticsRoomListStateWatcherTest { runCurrent() // The transaction was never added - assertThat(analyticsService.getLongRunningTransaction(ResumeAppUntilNewRoomsReceived)).isNull() + assertThat(analyticsService.getLongRunningTransaction(CatchUp)).isNull() watcher.stop() } diff --git a/services/analytics/impl/src/test/kotlin/io/element/android/services/analytics/impl/watchers/DefaultAnalyticsSendMessageWatcherTest.kt b/services/analytics/impl/src/test/kotlin/io/element/android/services/analytics/impl/watchers/DefaultAnalyticsSendMessageWatcherTest.kt new file mode 100644 index 00000000000..b0e996c4c05 --- /dev/null +++ b/services/analytics/impl/src/test/kotlin/io/element/android/services/analytics/impl/watchers/DefaultAnalyticsSendMessageWatcherTest.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2025 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.services.analytics.impl.watchers + +import io.element.android.libraries.matrix.api.room.SendQueueUpdate +import io.element.android.libraries.matrix.test.AN_EVENT_ID +import io.element.android.libraries.matrix.test.A_TRANSACTION_ID +import io.element.android.libraries.matrix.test.room.FakeJoinedRoom +import io.element.android.services.analytics.api.NoopAnalyticsTransaction +import io.element.android.services.analytics.test.FakeAnalyticsService +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.mockk.mockk +import io.mockk.verify +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 DefaultAnalyticsSendMessageWatcherTest { + @Test + fun `test start listens to send queue updates`() = runTest { + val mockedTransaction = mockk(relaxed = true) + val startTransactionRecorder = lambdaRecorder { _: String, _: String?, _: String? -> mockedTransaction } + val room = FakeJoinedRoom() + val analyticsService = FakeAnalyticsService(startTransactionLambda = startTransactionRecorder) + + val watcher = createDefaultAnalyticsSendMessageWatcher(room = room, analyticsService = analyticsService) + + // When we start listening, we don't trigger any analyticsService.startTransaction calls + watcher.start() + runCurrent() + + startTransactionRecorder.assertions().isNeverCalled() + + // When we receive a new local event, we start a new transaction for it + room.givenSendQueueUpdate(SendQueueUpdate.NewLocalEvent(A_TRANSACTION_ID)) + runCurrent() + + startTransactionRecorder.assertions().isCalledOnce() + + // And we receive an 'event sent' update with the event's id, we finish the transaction + room.givenSendQueueUpdate(SendQueueUpdate.SentEvent(A_TRANSACTION_ID, AN_EVENT_ID)) + runCurrent() + + verify { mockedTransaction.finish() } + + // We also stop the watcher for cleanup + watcher.stop() + } + + private fun TestScope.createDefaultAnalyticsSendMessageWatcher( + room: FakeJoinedRoom = FakeJoinedRoom(), + analyticsService: FakeAnalyticsService = FakeAnalyticsService(), + ) = DefaultAnalyticsSendMessageWatcher( + room = room, + analyticsService = analyticsService, + coroutineScope = backgroundScope, + ) +} diff --git a/services/analytics/noop/src/main/kotlin/io/element/android/services/analytics/noop/NoopAnalyticsService.kt b/services/analytics/noop/src/main/kotlin/io/element/android/services/analytics/noop/NoopAnalyticsService.kt index 591651ed6c4..b0c6d29b638 100644 --- a/services/analytics/noop/src/main/kotlin/io/element/android/services/analytics/noop/NoopAnalyticsService.kt +++ b/services/analytics/noop/src/main/kotlin/io/element/android/services/analytics/noop/NoopAnalyticsService.kt @@ -16,7 +16,9 @@ import im.vector.app.features.analytics.itf.VectorAnalyticsScreen import im.vector.app.features.analytics.plan.SuperProperties import im.vector.app.features.analytics.plan.UserProperties import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction +import io.element.android.services.analytics.api.AnalyticsSdkSpan import io.element.android.services.analytics.api.AnalyticsService +import io.element.android.services.analytics.api.NoopAnalyticsSdkSpan import io.element.android.services.analytics.api.NoopAnalyticsTransaction import io.element.android.services.analyticsproviders.api.AnalyticsProvider import io.element.android.services.analyticsproviders.api.AnalyticsTransaction @@ -38,11 +40,13 @@ class NoopAnalyticsService : AnalyticsService { override fun updateUserProperties(userProperties: UserProperties) = Unit override fun trackError(throwable: Throwable) = Unit override fun updateSuperProperties(updatedProperties: SuperProperties) = Unit - override fun startTransaction(name: String, operation: String?): AnalyticsTransaction = NoopAnalyticsTransaction + override fun startTransaction(name: String, operation: String?, description: String?): AnalyticsTransaction = NoopAnalyticsTransaction override fun startLongRunningTransaction( longRunningTransaction: AnalyticsLongRunningTransaction, parentTransaction: AnalyticsTransaction?, ): AnalyticsTransaction = NoopAnalyticsTransaction override fun getLongRunningTransaction(longRunningTransaction: AnalyticsLongRunningTransaction): AnalyticsTransaction? = null override fun removeLongRunningTransaction(longRunningTransaction: AnalyticsLongRunningTransaction) = NoopAnalyticsTransaction + + override fun enterSdkSpan(name: String?, parentTraceId: String?): AnalyticsSdkSpan = NoopAnalyticsSdkSpan } diff --git a/services/analytics/noop/src/main/kotlin/io/element/android/services/analytics/noop/di/NoopAnalyticsModule.kt b/services/analytics/noop/src/main/kotlin/io/element/android/services/analytics/noop/di/NoopAnalyticsModule.kt new file mode 100644 index 00000000000..3c4b00c7a01 --- /dev/null +++ b/services/analytics/noop/src/main/kotlin/io/element/android/services/analytics/noop/di/NoopAnalyticsModule.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2025 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.services.analytics.noop.di + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.BindingContainer +import dev.zacsweers.metro.ContributesTo +import dev.zacsweers.metro.Provides +import io.element.android.libraries.di.identifiers.SentrySdkDsn + +@BindingContainer +@ContributesTo(AppScope::class) +object NoopAnalyticsModule { + @Provides + fun provideSentrySdkDsn(): SentrySdkDsn? = null +} diff --git a/services/analytics/noop/src/main/kotlin/io/element/android/services/analytics/noop/watchers/NoopAnalyticsSendMessageWatcher.kt b/services/analytics/noop/src/main/kotlin/io/element/android/services/analytics/noop/watchers/NoopAnalyticsSendMessageWatcher.kt new file mode 100644 index 00000000000..6e9bf8058f5 --- /dev/null +++ b/services/analytics/noop/src/main/kotlin/io/element/android/services/analytics/noop/watchers/NoopAnalyticsSendMessageWatcher.kt @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2025 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.services.analytics.noop.watchers + +import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.di.RoomScope +import io.element.android.services.analytics.api.watchers.AnalyticsSendMessageWatcher + +@ContributesBinding(RoomScope::class) +class NoopAnalyticsSendMessageWatcher : AnalyticsSendMessageWatcher { + override fun start() = Unit + override fun stop() = Unit +} diff --git a/services/analytics/test/src/main/kotlin/io/element/android/services/analytics/test/FakeAnalyticsService.kt b/services/analytics/test/src/main/kotlin/io/element/android/services/analytics/test/FakeAnalyticsService.kt index 274d06f6a59..f0dd9e0d4a5 100644 --- a/services/analytics/test/src/main/kotlin/io/element/android/services/analytics/test/FakeAnalyticsService.kt +++ b/services/analytics/test/src/main/kotlin/io/element/android/services/analytics/test/FakeAnalyticsService.kt @@ -13,7 +13,9 @@ import im.vector.app.features.analytics.itf.VectorAnalyticsScreen import im.vector.app.features.analytics.plan.SuperProperties import im.vector.app.features.analytics.plan.UserProperties import io.element.android.services.analytics.api.AnalyticsLongRunningTransaction +import io.element.android.services.analytics.api.AnalyticsSdkSpan import io.element.android.services.analytics.api.AnalyticsService +import io.element.android.services.analytics.api.NoopAnalyticsSdkSpan import io.element.android.services.analytics.api.NoopAnalyticsTransaction import io.element.android.services.analyticsproviders.api.AnalyticsProvider import io.element.android.services.analyticsproviders.api.AnalyticsTransaction @@ -24,6 +26,7 @@ import kotlinx.coroutines.flow.asStateFlow class FakeAnalyticsService( isEnabled: Boolean = false, didAskUserConsent: Boolean = false, + private val startTransactionLambda: (String, String?, String?) -> AnalyticsTransaction = { _, _, _ -> NoopAnalyticsTransaction }, ) : AnalyticsService { private val isEnabledFlow = MutableStateFlow(isEnabled) override val didAskUserConsentFlow = MutableStateFlow(didAskUserConsent) @@ -70,7 +73,11 @@ class FakeAnalyticsService( // No op } - override fun startTransaction(name: String, operation: String?): AnalyticsTransaction = NoopAnalyticsTransaction + override fun startTransaction(name: String, operation: String?, description: String?): AnalyticsTransaction = startTransactionLambda( + name, + operation, + description + ) override fun startLongRunningTransaction( longRunningTransaction: AnalyticsLongRunningTransaction, parentTransaction: AnalyticsTransaction? @@ -86,4 +93,6 @@ class FakeAnalyticsService( override fun removeLongRunningTransaction(longRunningTransaction: AnalyticsLongRunningTransaction): AnalyticsTransaction? { return longRunningTransactions.remove(longRunningTransaction) } + + override fun enterSdkSpan(name: String?, parentTraceId: String?): AnalyticsSdkSpan = NoopAnalyticsSdkSpan } diff --git a/services/analytics/test/src/main/kotlin/io/element/android/services/analytics/test/watchers/FakeAnalyticsSendMessageWatcher.kt b/services/analytics/test/src/main/kotlin/io/element/android/services/analytics/test/watchers/FakeAnalyticsSendMessageWatcher.kt new file mode 100644 index 00000000000..6320e8522e5 --- /dev/null +++ b/services/analytics/test/src/main/kotlin/io/element/android/services/analytics/test/watchers/FakeAnalyticsSendMessageWatcher.kt @@ -0,0 +1,18 @@ +/* + * Copyright (c) 2025 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.services.analytics.test.watchers + +import io.element.android.services.analytics.api.watchers.AnalyticsSendMessageWatcher + +class FakeAnalyticsSendMessageWatcher( + private val startLambda: () -> Unit = {}, + private val stopLambda: () -> Unit = {}, +) : AnalyticsSendMessageWatcher { + override fun start() = startLambda() + override fun stop() = stopLambda() +} diff --git a/services/analyticsproviders/api/src/main/kotlin/io/element/android/services/analyticsproviders/api/AnalyticsProvider.kt b/services/analyticsproviders/api/src/main/kotlin/io/element/android/services/analyticsproviders/api/AnalyticsProvider.kt index f90d924c813..593d62f40f5 100644 --- a/services/analyticsproviders/api/src/main/kotlin/io/element/android/services/analyticsproviders/api/AnalyticsProvider.kt +++ b/services/analyticsproviders/api/src/main/kotlin/io/element/android/services/analyticsproviders/api/AnalyticsProvider.kt @@ -21,5 +21,5 @@ interface AnalyticsProvider : AnalyticsTracker, ErrorTracker { fun stop() - fun startTransaction(name: String, operation: String? = null): AnalyticsTransaction? + fun startTransaction(name: String, operation: String? = null, description: String? = null): AnalyticsTransaction? } diff --git a/services/analyticsproviders/api/src/main/kotlin/io/element/android/services/analyticsproviders/api/AnalyticsTransaction.kt b/services/analyticsproviders/api/src/main/kotlin/io/element/android/services/analyticsproviders/api/AnalyticsTransaction.kt index 8297055f96a..b5f81ac67e1 100644 --- a/services/analyticsproviders/api/src/main/kotlin/io/element/android/services/analyticsproviders/api/AnalyticsTransaction.kt +++ b/services/analyticsproviders/api/src/main/kotlin/io/element/android/services/analyticsproviders/api/AnalyticsTransaction.kt @@ -8,12 +8,47 @@ package io.element.android.services.analyticsproviders.api interface AnalyticsTransaction { + /** + * Start a child span from this transaction. + */ fun startChild(operation: String, description: String? = null): AnalyticsTransaction - fun setData(key: String, value: Any) + + /** + * Adds extra data to the transaction. This data is not indexed, it's just listed. + */ + fun putExtraData(key: String, value: String) + + /** + * Similar to [putExtraData], adds extra data that *will be indexed* and can be used for filtering in the analytics portal. + * + * **Do not add numerical values using this function, use [putExtraData] instead.** + */ + fun putIndexableData(key: String, value: String) + + /** + * Whether the transaction has finished. + */ fun isFinished(): Boolean + + /** + * The optional trace id which can be used for distributed tracing. + */ + fun traceId(): String? + + /** + * Attach a throwable to the transaction, so we can know it failed. + */ + fun attachError(throwable: Throwable) + + /** + * Finish the transaction. This will schedule an upload of the data. + */ fun finish() } +/** + * Records a child span from this transaction. + */ inline fun AnalyticsTransaction.recordChildTransaction(operation: String, description: String? = null, block: (AnalyticsTransaction) -> T): T { val child = startChild(operation, description) try { diff --git a/services/analyticsproviders/api/src/main/kotlin/io/element/android/services/analyticsproviders/api/AnalyticsTransactions.kt b/services/analyticsproviders/api/src/main/kotlin/io/element/android/services/analyticsproviders/api/AnalyticsTransactions.kt new file mode 100644 index 00000000000..5c44cc9e322 --- /dev/null +++ b/services/analyticsproviders/api/src/main/kotlin/io/element/android/services/analyticsproviders/api/AnalyticsTransactions.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2025 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.services.analyticsproviders.api + +object AnalyticsTransactions { + val coldStart = TransactionDefinition( + name = "Cached room list", + operation = "ux", + description = "Cold start until the cached room list is displayed", + ) + + val catchUp = TransactionDefinition( + name = "Up-to-date room list", + operation = "ux", + description = "The app syncs and the room list becomes up-to-date", + ) + + val notificationToMessage = TransactionDefinition( + name = "Notification to message", + operation = "ux", + description = "A notification was tapped and it opened a timeline", + ) + + val openRoom = TransactionDefinition( + name = "Open a room", + operation = "ux", + description = "Open a room and see loaded items in the timeline", + ) + + val sendMessage = TransactionDefinition( + name = "Send a message", + operation = "ux", + description = "Send to sent state in timeline", + ) +} + +data class TransactionDefinition( + val name: String, + val operation: String? = null, + val description: String?, +) diff --git a/services/analyticsproviders/api/src/main/kotlin/io/element/android/services/analyticsproviders/api/AnalyticsUserData.kt b/services/analyticsproviders/api/src/main/kotlin/io/element/android/services/analyticsproviders/api/AnalyticsUserData.kt new file mode 100644 index 00000000000..28ae4a56680 --- /dev/null +++ b/services/analyticsproviders/api/src/main/kotlin/io/element/android/services/analyticsproviders/api/AnalyticsUserData.kt @@ -0,0 +1,20 @@ +/* + * Copyright (c) 2025 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.services.analyticsproviders.api + +object AnalyticsUserData { + const val HOMESERVER = "homeserver" + + const val STATE_STORE_SIZE = "state_store_size" + const val EVENT_CACHE_SIZE = "event_cache_size" + const val CRYPTO_STORE_SIZE = "crypto_store_size" + const val MEDIA_STORE_SIZE = "media_store_size" + + const val FIRST_SYNC_STATE = "first_sync_state" + const val TIMELINE_ITEM_COUNT = "timeline_item_count" +} diff --git a/services/analyticsproviders/api/src/main/kotlin/io/element/android/services/analyticsproviders/api/trackers/AnalyticsTracker.kt b/services/analyticsproviders/api/src/main/kotlin/io/element/android/services/analyticsproviders/api/trackers/AnalyticsTracker.kt index 4ada0cbdaaa..5bb63338a8e 100644 --- a/services/analyticsproviders/api/src/main/kotlin/io/element/android/services/analyticsproviders/api/trackers/AnalyticsTracker.kt +++ b/services/analyticsproviders/api/src/main/kotlin/io/element/android/services/analyticsproviders/api/trackers/AnalyticsTracker.kt @@ -35,6 +35,18 @@ interface AnalyticsTracker { * Super properties are added to any tracked event automatically. */ fun updateSuperProperties(updatedProperties: SuperProperties) + + /** + * Adds extra data that will be sent with every event. + */ + fun addExtraData(key: String, value: String) {} + + /** + * Similar to [addExtraData], adds data that will be indexed in the analytics portal. + * + * **Do not add numerical values using this, use [addExtraData] instead.** + */ + fun addIndexableData(key: String, value: String) {} } fun AnalyticsTracker.captureInteraction(name: Interaction.Name, type: Interaction.InteractionType? = null) { diff --git a/services/analyticsproviders/posthog/src/main/kotlin/io/element/android/services/analyticsproviders/posthog/PosthogAnalyticsProvider.kt b/services/analyticsproviders/posthog/src/main/kotlin/io/element/android/services/analyticsproviders/posthog/PosthogAnalyticsProvider.kt index 9185b906c4e..49edc9b1a04 100644 --- a/services/analyticsproviders/posthog/src/main/kotlin/io/element/android/services/analyticsproviders/posthog/PosthogAnalyticsProvider.kt +++ b/services/analyticsproviders/posthog/src/main/kotlin/io/element/android/services/analyticsproviders/posthog/PosthogAnalyticsProvider.kt @@ -124,7 +124,7 @@ class PosthogAnalyticsProvider( return withSuperProperties.takeIf { it.isEmpty().not() } } - override fun startTransaction(name: String, operation: String?): AnalyticsTransaction? = null + override fun startTransaction(name: String, operation: String?, description: String?): AnalyticsTransaction? = null } private fun Map.keepOnlyNonNullValues(): Map { diff --git a/services/analyticsproviders/sentry/build.gradle.kts b/services/analyticsproviders/sentry/build.gradle.kts index ba46a6f4f95..02dde35ef40 100644 --- a/services/analyticsproviders/sentry/build.gradle.kts +++ b/services/analyticsproviders/sentry/build.gradle.kts @@ -2,6 +2,7 @@ import config.BuildTimeConfig import extension.buildConfigFieldStr import extension.readLocalProperty import extension.setupDependencyInjection +import extension.testCommonDependencies /* * Copyright (c) 2025 Element Creations Ltd. @@ -32,6 +33,16 @@ android { } ?: "" ) + buildConfigFieldStr( + name = "SDK_SENTRY_DSN", + value = if (isEnterpriseBuild) { + BuildTimeConfig.SERVICES_SENTRY_DSN_RUST + } else { + System.getenv("ELEMENT_SDK_SENTRY_DSN") + ?: readLocalProperty("services.analyticsproviders.sdk.sentry.dsn") + } + ?: "" + ) } } @@ -41,5 +52,11 @@ dependencies { implementation(libs.sentry) implementation(projects.libraries.core) implementation(projects.libraries.di) + implementation(projects.libraries.matrix.api) implementation(projects.services.analyticsproviders.api) + implementation(projects.services.appnavstate.api) + + testCommonDependencies(libs, false) + testImplementation(projects.libraries.matrix.test) + testImplementation(projects.services.appnavstate.test) } diff --git a/services/analyticsproviders/sentry/src/main/kotlin/io/element/android/services/analyticsproviders/sentry/SentryAnalyticsProvider.kt b/services/analyticsproviders/sentry/src/main/kotlin/io/element/android/services/analyticsproviders/sentry/SentryAnalyticsProvider.kt index 8c2566946ac..ef2d4e21d92 100644 --- a/services/analyticsproviders/sentry/src/main/kotlin/io/element/android/services/analyticsproviders/sentry/SentryAnalyticsProvider.kt +++ b/services/analyticsproviders/sentry/src/main/kotlin/io/element/android/services/analyticsproviders/sentry/SentryAnalyticsProvider.kt @@ -9,6 +9,7 @@ package io.element.android.services.analyticsproviders.sentry import android.content.Context +import androidx.annotation.VisibleForTesting import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesIntoSet import dev.zacsweers.metro.Inject @@ -16,23 +17,34 @@ import im.vector.app.features.analytics.itf.VectorAnalyticsEvent import im.vector.app.features.analytics.itf.VectorAnalyticsScreen import im.vector.app.features.analytics.plan.SuperProperties import im.vector.app.features.analytics.plan.UserProperties +import io.element.android.libraries.core.data.ByteUnit import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.core.meta.BuildType import io.element.android.libraries.di.annotations.ApplicationContext +import io.element.android.libraries.di.identifiers.SentryDsn +import io.element.android.libraries.matrix.api.analytics.GetDatabaseSizesUseCase import io.element.android.services.analyticsproviders.api.AnalyticsProvider import io.element.android.services.analyticsproviders.api.AnalyticsTransaction +import io.element.android.services.analyticsproviders.api.AnalyticsUserData import io.element.android.services.analyticsproviders.sentry.log.analyticsTag +import io.element.android.services.appnavstate.api.AppNavigationStateService +import io.element.android.services.appnavstate.api.currentSessionId import io.sentry.Breadcrumb import io.sentry.Sentry import io.sentry.SentryOptions import io.sentry.android.core.SentryAndroid +import io.sentry.protocol.SentryTransaction +import kotlinx.coroutines.runBlocking import timber.log.Timber @ContributesIntoSet(AppScope::class) @Inject class SentryAnalyticsProvider( @ApplicationContext private val context: Context, + private val sentryDsn: SentryDsn?, private val buildMeta: BuildMeta, + private val getDatabaseSizesUseCase: GetDatabaseSizesUseCase, + private val appNavigationStateService: AppNavigationStateService, ) : AnalyticsProvider { override val name = SentryConfig.NAME @@ -40,14 +52,16 @@ class SentryAnalyticsProvider( Timber.tag(analyticsTag.value).d("Initializing Sentry") if (Sentry.isEnabled()) return - val dsn = SentryConfig.DSN.ifBlank { + val dsn = sentryDsn?.value ?: run { Timber.w("No Sentry DSN provided, Sentry will not be initialized") return } SentryAndroid.init(context) { options -> options.dsn = dsn - options.beforeSend = SentryOptions.BeforeSendCallback { event, _ -> event } + options.beforeSendTransaction = SentryOptions.BeforeSendTransactionCallback { transaction, _ -> + prepareTransactionBeforeSend(transaction) + } options.tracesSampleRate = 1.0 options.isEnableUserInteractionTracing = true options.environment = buildMeta.buildType.toSentryEnv() @@ -86,12 +100,47 @@ class SentryAnalyticsProvider( override fun updateSuperProperties(updatedProperties: SuperProperties) { } + override fun addExtraData(key: String, value: String) { + Sentry.setExtra(key, value) + } + + override fun addIndexableData(key: String, value: String) { + Sentry.setTag(key, value) + } + override fun trackError(throwable: Throwable) { + Timber.e(throwable, "Sending error to Sentry") Sentry.captureException(throwable) } - override fun startTransaction(name: String, operation: String?): AnalyticsTransaction? { - return SentryAnalyticsTransaction(name, operation) + override fun startTransaction(name: String, operation: String?, description: String?): AnalyticsTransaction? { + return SentryAnalyticsTransaction(name, operation, description) + } + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal fun prepareTransactionBeforeSend(transaction: SentryTransaction): SentryTransaction { + // Ensure we'll never upload any session ids in extras or tags + val invalidExtras = transaction.extras?.filter { (it.value as? String)?.startsWith("@") == true }.orEmpty() + for (invalidExtra in invalidExtras) { + transaction.removeExtra(invalidExtra.key) + } + val invalidTags = transaction.tags?.filter { it.value.startsWith("@") }.orEmpty() + for (invalidTag in invalidTags) { + transaction.removeTag(invalidTag.key) + } + + val sessionId = appNavigationStateService.appNavigationState.value.navigationState.currentSessionId() + if (sessionId != null) { + // This runs in a separate thread, so although using `runBlocking` is not great, at least it shouldn't freeze the app + // Also, the method is fairly quick, so the blocking shouldn't take longer than a few ms + val databaseSizes = runBlocking { getDatabaseSizesUseCase(sessionId) }.getOrNull() + + databaseSizes?.stateStore?.let { transaction.setExtra(AnalyticsUserData.STATE_STORE_SIZE, it.into(ByteUnit.MB)) } + databaseSizes?.eventCacheStore?.let { transaction.setExtra(AnalyticsUserData.EVENT_CACHE_SIZE, it.into(ByteUnit.MB)) } + databaseSizes?.mediaStore?.let { transaction.setExtra(AnalyticsUserData.MEDIA_STORE_SIZE, it.into(ByteUnit.MB)) } + databaseSizes?.cryptoStore?.let { transaction.setExtra(AnalyticsUserData.CRYPTO_STORE_SIZE, it.into(ByteUnit.MB)) } + } + return transaction } } diff --git a/services/analyticsproviders/sentry/src/main/kotlin/io/element/android/services/analyticsproviders/sentry/SentryAnalyticsTransaction.kt b/services/analyticsproviders/sentry/src/main/kotlin/io/element/android/services/analyticsproviders/sentry/SentryAnalyticsTransaction.kt index 90bbd77e00c..6477b2cc5c9 100644 --- a/services/analyticsproviders/sentry/src/main/kotlin/io/element/android/services/analyticsproviders/sentry/SentryAnalyticsTransaction.kt +++ b/services/analyticsproviders/sentry/src/main/kotlin/io/element/android/services/analyticsproviders/sentry/SentryAnalyticsTransaction.kt @@ -14,17 +14,25 @@ import io.sentry.Sentry import timber.log.Timber class SentryAnalyticsTransaction private constructor(span: ISpan) : AnalyticsTransaction { - constructor(name: String, operation: String?) : this(Sentry.startTransaction(name, operation.orEmpty())) + constructor(name: String, operation: String?, description: String? = null) : this( + Sentry.startTransaction(name, operation.orEmpty()).also { it.description = description } + ) private val inner = span override fun startChild(operation: String, description: String?): AnalyticsTransaction = SentryAnalyticsTransaction( inner.startChild(operation, description) ) - override fun setData(key: String, value: Any) = inner.setData(key, value) + + override fun putIndexableData(key: String, value: String) = inner.setTag(key, value) + override fun putExtraData(key: String, value: String) = inner.setData(key, value) + override fun traceId(): String? = inner.toSentryTrace().value override fun isFinished(): Boolean = inner.isFinished + override fun attachError(throwable: Throwable) { + inner.throwable = throwable + } override fun finish() { val name = if (inner is ITransaction) inner.name else inner.operation - Timber.d("Finishing transaction: $name") + Timber.d("Finishing transaction: '$name'") inner.finish() } } diff --git a/services/analyticsproviders/sentry/src/main/kotlin/io/element/android/services/analyticsproviders/sentry/SentryConfig.kt b/services/analyticsproviders/sentry/src/main/kotlin/io/element/android/services/analyticsproviders/sentry/SentryConfig.kt index 3167d82f77e..993823a877f 100644 --- a/services/analyticsproviders/sentry/src/main/kotlin/io/element/android/services/analyticsproviders/sentry/SentryConfig.kt +++ b/services/analyticsproviders/sentry/src/main/kotlin/io/element/android/services/analyticsproviders/sentry/SentryConfig.kt @@ -11,6 +11,7 @@ package io.element.android.services.analyticsproviders.sentry object SentryConfig { const val NAME = "Sentry" const val DSN = BuildConfig.SENTRY_DSN + const val SDK_DSN = BuildConfig.SDK_SENTRY_DSN const val ENV_DEBUG = "DEBUG" const val ENV_NIGHTLY = "NIGHTLY" const val ENV_RELEASE = "RELEASE" diff --git a/services/analyticsproviders/sentry/src/main/kotlin/io/element/android/services/analyticsproviders/sentry/di/SentryModule.kt b/services/analyticsproviders/sentry/src/main/kotlin/io/element/android/services/analyticsproviders/sentry/di/SentryModule.kt new file mode 100644 index 00000000000..74b7d56683f --- /dev/null +++ b/services/analyticsproviders/sentry/src/main/kotlin/io/element/android/services/analyticsproviders/sentry/di/SentryModule.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2025 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.services.analyticsproviders.sentry.di + +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.BindingContainer +import dev.zacsweers.metro.ContributesTo +import dev.zacsweers.metro.Provides +import io.element.android.libraries.di.identifiers.SentryDsn +import io.element.android.libraries.di.identifiers.SentrySdkDsn +import io.element.android.services.analyticsproviders.sentry.SentryConfig + +@BindingContainer +@ContributesTo(AppScope::class) +object SentryModule { + @Provides + fun provideSentryDsn(): SentryDsn? = SentryConfig.DSN.takeIf { it.isNotBlank() }?.let(::SentryDsn) + + @Provides + fun provideSentrySdkDsn(): SentrySdkDsn? = SentryConfig.SDK_DSN.takeIf { it.isNotBlank() }?.let(::SentrySdkDsn) +} diff --git a/services/analyticsproviders/sentry/src/test/kotlin/io/element/android/services/analyticsproviders/sentry/SentryAnalyticsProviderTest.kt b/services/analyticsproviders/sentry/src/test/kotlin/io/element/android/services/analyticsproviders/sentry/SentryAnalyticsProviderTest.kt new file mode 100644 index 00000000000..24b938d9a53 --- /dev/null +++ b/services/analyticsproviders/sentry/src/test/kotlin/io/element/android/services/analyticsproviders/sentry/SentryAnalyticsProviderTest.kt @@ -0,0 +1,233 @@ +/* + * Copyright (c) 2025 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:Suppress("UnstableApiUsage") + +package io.element.android.services.analyticsproviders.sentry + +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.google.common.truth.Truth.assertThat +import im.vector.app.features.analytics.itf.VectorAnalyticsEvent +import im.vector.app.features.analytics.itf.VectorAnalyticsScreen +import im.vector.app.features.analytics.plan.SuperProperties +import im.vector.app.features.analytics.plan.UserProperties +import io.element.android.libraries.core.data.megaBytes +import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.di.identifiers.SentryDsn +import io.element.android.libraries.matrix.api.analytics.GetDatabaseSizesUseCase +import io.element.android.libraries.matrix.api.analytics.SdkStoreSizes +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.matrix.test.core.aBuildMeta +import io.element.android.services.analyticsproviders.api.AnalyticsUserData +import io.element.android.services.appnavstate.api.AppNavigationState +import io.element.android.services.appnavstate.api.NavigationState +import io.element.android.services.appnavstate.test.FakeAppNavigationStateService +import io.sentry.Sentry +import io.sentry.SentryTracer +import io.sentry.protocol.SentryId +import io.sentry.protocol.SentryTransaction +import kotlinx.coroutines.flow.MutableStateFlow +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class SentryAnalyticsProviderTest { + @Test + fun `init enables Sentry if DSN is present`() { + createSentryAnalyticsProvider().run { + init() + } + assertThat(Sentry.isEnabled()).isTrue() + } + + @Test + fun `init does nothing if DSN is not present`() { + createSentryAnalyticsProvider(sentryDsn = null).run { + init() + } + assertThat(Sentry.isEnabled()).isTrue() + } + + @Test + fun `stop disables Sentry`() { + createSentryAnalyticsProvider().run { + init() + stop() + } + assertThat(Sentry.isEnabled()).isFalse() + } + + @Test + fun `capture adds a breadcrumb`() { + createSentryAnalyticsProvider().run { + init() + capture(object : VectorAnalyticsEvent { + override fun getName(): String = "Test" + override fun getProperties(): Map? = mapOf("foo" to "bar") + }) + } + assertThat(Sentry.getCurrentScopes().scope.breadcrumbs.isNotEmpty()).isTrue() + } + + @Test + fun `screen adds a breadcrumb`() { + createSentryAnalyticsProvider().run { + init() + screen(object : VectorAnalyticsScreen { + override fun getName(): String = "Test" + override fun getProperties(): Map? = mapOf("foo" to "bar") + }) + } + assertThat(Sentry.getCurrentScopes().scope.breadcrumbs.isNotEmpty()).isTrue() + } + + @Test + fun `updateUserProperties and updateSuperProperties do nothing`() { + createSentryAnalyticsProvider().run { + init() + updateUserProperties(UserProperties()) + updateSuperProperties(SuperProperties()) + } + val scope = Sentry.getCurrentScopes().scope + assertThat(scope.extras.isEmpty()).isTrue() + assertThat(scope.tags.isEmpty()).isTrue() + assertThat(scope.contexts.isEmpty()).isTrue() + } + + @Test + fun `addExtraData adds a global extra`() { + createSentryAnalyticsProvider().run { + init() + addExtraData("foo", "bar") + } + val scope = Sentry.getCurrentScopes().scope + assertThat(scope.extras.get("foo")).isEqualTo("bar") + } + + @Test + fun `addIndexableData adds a global tag`() { + createSentryAnalyticsProvider().run { + init() + addIndexableData("foo", "bar") + } + val scope = Sentry.getCurrentScopes().scope + assertThat(scope.tags.get("foo")).isEqualTo("bar") + } + + @Test + fun `trackError adds a throwable to the global scope`() { + var initialLastId: SentryId? = null + createSentryAnalyticsProvider().run { + init() + initialLastId = Sentry.getLastEventId() + trackError(IllegalStateException("foo")) + } + assertThat(Sentry.getLastEventId()).isNotEqualTo(initialLastId) + } + + @Test + fun `startTransaction starts a SentryAnalyticsTransaction`() { + val transaction = createSentryAnalyticsProvider().run { + init() + startTransaction("foo") + } + assertThat(transaction).isNotNull() + assertThat(transaction).isInstanceOf(SentryAnalyticsTransaction::class.java) + } + + @Test + fun `prepareTransactionBeforeSend removes unwanted data and adds DB size extras`() { + createSentryAnalyticsProvider( + getDatabaseSizesUseCase = GetDatabaseSizesUseCase { + Result.success( + SdkStoreSizes(stateStore = 10.megaBytes, eventCacheStore = 11.megaBytes, mediaStore = 12.megaBytes, cryptoStore = 13.megaBytes) + ) + }, + appNavigationStateService = FakeAppNavigationStateService( + MutableStateFlow(AppNavigationState(navigationState = NavigationState.Session("owner", A_SESSION_ID), isInForeground = true)) + ) + ).run { + init() + + val transaction = SentryTransaction(Sentry.startTransaction("foo", "bar") as SentryTracer) + // Add a user id value + transaction.setExtra("user", "@some:user") + transaction.setTag("user", "@some:user") + + val result = prepareTransactionBeforeSend(transaction) + + // The user id value should have been removed + assertThat(result.getExtra("user")).isNull() + assertThat(result.getTag("user")).isNull() + + // The DB sizes should be included + assertThat(result.getExtra(AnalyticsUserData.STATE_STORE_SIZE)).isEqualTo(10) + assertThat(result.getExtra(AnalyticsUserData.EVENT_CACHE_SIZE)).isEqualTo(11) + assertThat(result.getExtra(AnalyticsUserData.MEDIA_STORE_SIZE)).isEqualTo(12) + assertThat(result.getExtra(AnalyticsUserData.CRYPTO_STORE_SIZE)).isEqualTo(13) + } + } + + @Test + fun `prepareTransactionBeforeSend doesn't add DB info if no session id is provided`() { + createSentryAnalyticsProvider( + getDatabaseSizesUseCase = GetDatabaseSizesUseCase { + Result.success( + SdkStoreSizes(stateStore = 10.megaBytes, eventCacheStore = 11.megaBytes, mediaStore = 12.megaBytes, cryptoStore = 13.megaBytes) + ) + }, + appNavigationStateService = FakeAppNavigationStateService( + MutableStateFlow(AppNavigationState(navigationState = NavigationState.Root, isInForeground = true)) + ) + ).run { + init() + + val transaction = SentryTransaction(Sentry.startTransaction("foo", "bar") as SentryTracer) + val result = prepareTransactionBeforeSend(transaction) + // The DB sizes are missing since there was no session id to query them + assertThat(result.extras).isNull() + } + } + + @Test + fun `prepareTransactionBeforeSend doesn't add DB info if no store sizes are available`() { + createSentryAnalyticsProvider( + getDatabaseSizesUseCase = GetDatabaseSizesUseCase { + Result.success( + SdkStoreSizes(stateStore = null, eventCacheStore = null, mediaStore = null, cryptoStore = null) + ) + }, + appNavigationStateService = FakeAppNavigationStateService( + MutableStateFlow(AppNavigationState(navigationState = NavigationState.Session("owner", A_SESSION_ID), isInForeground = true)) + ) + ).run { + init() + + val transaction = SentryTransaction(Sentry.startTransaction("foo", "bar") as SentryTracer) + val result = prepareTransactionBeforeSend(transaction) + + // The DB sizes are missing since there was no session id to query them + assertThat(result.extras).isNull() + } + } + + private fun createSentryAnalyticsProvider( + sentryDsn: SentryDsn? = SentryDsn("https://1234@sentry.com/a"), + buildMeta: BuildMeta = aBuildMeta(), + getDatabaseSizesUseCase: GetDatabaseSizesUseCase = GetDatabaseSizesUseCase { Result.success(SdkStoreSizes(null, null, null, null)) }, + appNavigationStateService: FakeAppNavigationStateService = FakeAppNavigationStateService( + MutableStateFlow(AppNavigationState(navigationState = NavigationState.Session("owner", A_SESSION_ID), isInForeground = true)) + ) + ) = SentryAnalyticsProvider( + context = InstrumentationRegistry.getInstrumentation().targetContext, + sentryDsn = sentryDsn, + buildMeta = buildMeta, + getDatabaseSizesUseCase = getDatabaseSizesUseCase, + appNavigationStateService = appNavigationStateService, + ) +} diff --git a/services/analyticsproviders/test/src/main/kotlin/io/element/android/services/analyticsproviders/test/FakeAnalyticsProvider.kt b/services/analyticsproviders/test/src/main/kotlin/io/element/android/services/analyticsproviders/test/FakeAnalyticsProvider.kt index 9b0374bd9b2..06a8f8e7aaf 100644 --- a/services/analyticsproviders/test/src/main/kotlin/io/element/android/services/analyticsproviders/test/FakeAnalyticsProvider.kt +++ b/services/analyticsproviders/test/src/main/kotlin/io/element/android/services/analyticsproviders/test/FakeAnalyticsProvider.kt @@ -33,5 +33,5 @@ class FakeAnalyticsProvider( override fun updateUserProperties(userProperties: UserProperties) = updateUserPropertiesLambda(userProperties) override fun trackError(throwable: Throwable) = trackErrorLambda(throwable) override fun updateSuperProperties(updatedProperties: SuperProperties) = updateSuperPropertiesLambda(updatedProperties) - override fun startTransaction(name: String, operation: String?): AnalyticsTransaction? = null + override fun startTransaction(name: String, operation: String?, description: String?): AnalyticsTransaction? = null } diff --git a/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistClassNameTest.kt b/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistClassNameTest.kt index bf47e773a8e..b38c1be0708 100644 --- a/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistClassNameTest.kt +++ b/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistClassNameTest.kt @@ -109,8 +109,10 @@ class KonsistClassNameTest { .classes() .withNameContaining("Fake") .withoutName( + "FakeAesKeyGenerator", "FakeFileSystem", "FakeImageLoader", + "FakeKeyStore", "FakeListenableFuture", ) .withoutPackage( diff --git a/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistPreviewTest.kt b/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistPreviewTest.kt index 433fef289f9..9516c16d306 100644 --- a/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistPreviewTest.kt +++ b/tests/konsist/src/test/kotlin/io/element/android/tests/konsist/KonsistPreviewTest.kt @@ -48,7 +48,7 @@ class KonsistPreviewTest { " and should be internal." ) { val testedView = it.name.removeSuffix("A11yPreview") - it.text.contains("$testedView(") && + (it.text.contains("$testedView(") || it.text.contains("ContentToPreview(")) && it.hasAllAnnotationsOf(PreviewsDayNight::class).not() && it.text.contains("ElementPreview") && it.hasInternalModifier @@ -80,6 +80,9 @@ class KonsistPreviewTest { private val previewNameExceptions = listOf( "AsyncIndicatorFailurePreview", "AsyncIndicatorLoadingPreview", + "AvatarPickerSizesPreview", + "AvatarPickerViewPreview", + "AvatarPickerViewRtlPreview", "BackgroundVerticalGradientDisabledPreview", "BackgroundVerticalGradientPreview", "ColorAliasesPreview", @@ -87,6 +90,7 @@ class KonsistPreviewTest { "GradientFloatingActionButtonCircleShapePreview", "HeaderFooterPageScrollablePreview", "HomeTopBarMultiAccountPreview", + "HomeTopBarSpacesPreview", "HomeTopBarWithIndicatorPreview", "IconsOtherPreview", "MarkdownTextComposerEditPreview", @@ -99,6 +103,7 @@ class KonsistPreviewTest { "MessageComposerViewVoicePreview", "MessagesReactionButtonAddPreview", "MessagesReactionButtonExtraPreview", + "MessagesViewWithHistoryVisiblePreview", "MessagesViewWithIdentityChangePreview", "PendingMemberRowWithLongNamePreview", "PinUnlockViewInAppPreview", diff --git a/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/LongTask.kt b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/LongTask.kt index 5e3367fcc43..bbaa1cf7418 100644 --- a/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/LongTask.kt +++ b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/LongTask.kt @@ -34,7 +34,7 @@ suspend fun awaitWithLatch(timeout: Duration = 300.milliseconds, block: (Complet withTimeout(timeout) { latch.also(block).await() } - } catch (exception: TimeoutCancellationException) { + } catch (_: TimeoutCancellationException) { latch.complete(Unit) } } diff --git a/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/SemanticsNodeInteractionsProviderExtensions.kt b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/SemanticsNodeInteractionsProviderExtensions.kt index f81b03b10b5..6502882d7d3 100644 --- a/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/SemanticsNodeInteractionsProviderExtensions.kt +++ b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/SemanticsNodeInteractionsProviderExtensions.kt @@ -10,32 +10,31 @@ package io.element.android.tests.testutils import androidx.activity.ComponentActivity import androidx.annotation.StringRes +import androidx.compose.ui.test.SemanticsMatcher import androidx.compose.ui.test.SemanticsNodeInteractionsProvider +import androidx.compose.ui.test.hasAnyAncestor import androidx.compose.ui.test.hasClickAction import androidx.compose.ui.test.hasContentDescription import androidx.compose.ui.test.hasTestTag import androidx.compose.ui.test.hasText +import androidx.compose.ui.test.isDialog import androidx.compose.ui.test.junit4.AndroidComposeTestRule -import androidx.compose.ui.test.onFirst import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import io.element.android.libraries.ui.strings.CommonStrings import org.junit.rules.TestRule -fun AndroidComposeTestRule.clickOn(@StringRes res: Int) { - val text = activity.getString(res) - onNode(hasText(text) and hasClickAction()) - .performClick() -} +val trueMatcher = SemanticsMatcher("true matcher") { true } -fun AndroidComposeTestRule.clickOnFirst(@StringRes res: Int) { +fun AndroidComposeTestRule.clickOn( + @StringRes res: Int, + inDialog: Boolean = false, +) { val text = activity.getString(res) - onAllNodes(hasText(text) and hasClickAction()).onFirst().performClick() -} - -fun AndroidComposeTestRule.clickOnLast(@StringRes res: Int) { - val text = activity.getString(res) - onAllNodes(hasText(text) and hasClickAction()).onFirst().performClick() + onNode( + hasText(text) and hasClickAction() and if (inDialog) hasAnyAncestor(isDialog()) else trueMatcher + ) + .performClick() } /** diff --git a/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/fake/FakeAndroidKeyStore.kt b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/fake/FakeAndroidKeyStore.kt new file mode 100644 index 00000000000..9edbec9795f --- /dev/null +++ b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/fake/FakeAndroidKeyStore.kt @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2025 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.tests.testutils.fake + +import java.io.InputStream +import java.io.OutputStream +import java.security.Key +import java.security.KeyStore +import java.security.KeyStoreSpi +import java.security.Provider +import java.security.SecureRandom +import java.security.Security +import java.security.cert.Certificate +import java.security.spec.AlgorithmParameterSpec +import java.util.Date +import java.util.Enumeration +import javax.crypto.KeyGenerator +import javax.crypto.KeyGeneratorSpi +import javax.crypto.SecretKey + +// Source - https://stackoverflow.com/questions/38213748/using-the-android-keystore-in-robolectric-tests/75763240#75763240 +// Posted by Victor Oliveira +// Retrieved 2025-12-19, License - CC BY-SA 4.0 + +object FakeAndroidKeyStore { + val setup by lazy { + Security.addProvider(object : Provider("AndroidKeyStore", 1.0, "") { + init { + put("KeyStore.AndroidKeyStore", FakeKeyStore::class.java.name) + put("KeyGenerator.AES", FakeAesKeyGenerator::class.java.name) + } + }) + } + + class FakeKeyStore : KeyStoreSpi() { + private val wrapped = KeyStore.getInstance(KeyStore.getDefaultType()) + + override fun engineIsKeyEntry(alias: String?): Boolean = wrapped.isKeyEntry(alias) + override fun engineIsCertificateEntry(alias: String?): Boolean = wrapped.isCertificateEntry(alias) + override fun engineGetCertificate(alias: String?): Certificate = wrapped.getCertificate(alias) + override fun engineGetCreationDate(alias: String?): Date = wrapped.getCreationDate(alias) + override fun engineDeleteEntry(alias: String?) = wrapped.deleteEntry(alias) + override fun engineSetKeyEntry(alias: String?, key: Key?, password: CharArray?, chain: Array?) = + wrapped.setKeyEntry(alias, key, password, chain) + + override fun engineSetKeyEntry(alias: String?, key: ByteArray?, chain: Array?) = wrapped.setKeyEntry(alias, key, chain) + override fun engineStore(stream: OutputStream?, password: CharArray?) = wrapped.store(stream, password) + override fun engineSize(): Int = wrapped.size() + override fun engineAliases(): Enumeration = wrapped.aliases() + override fun engineContainsAlias(alias: String?): Boolean = wrapped.containsAlias(alias) + override fun engineLoad(stream: InputStream?, password: CharArray?) = wrapped.load(stream, password) + override fun engineGetCertificateChain(alias: String?): Array = wrapped.getCertificateChain(alias) + override fun engineSetCertificateEntry(alias: String?, cert: Certificate?) = wrapped.setCertificateEntry(alias, cert) + override fun engineGetCertificateAlias(cert: Certificate?): String = wrapped.getCertificateAlias(cert) + override fun engineGetKey(alias: String?, password: CharArray?): Key = wrapped.getKey(alias, password) + } + + class FakeAesKeyGenerator : KeyGeneratorSpi() { + private val wrapped = KeyGenerator.getInstance("AES") + + override fun engineInit(random: SecureRandom?) = Unit + override fun engineInit(params: AlgorithmParameterSpec?, random: SecureRandom?) = Unit + override fun engineInit(keysize: Int, random: SecureRandom?) = Unit + override fun engineGenerateKey(): SecretKey = wrapped.generateKey() + } +} diff --git a/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/node/TestParentNode.kt b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/node/TestParentNode.kt index c9947333403..3daec9b3b56 100644 --- a/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/node/TestParentNode.kt +++ b/tests/testutils/src/main/kotlin/io/element/android/tests/testutils/node/TestParentNode.kt @@ -27,7 +27,7 @@ class TestParentNode( private val childNodeFactory: (buildContext: BuildContext, plugins: List) -> Child, ) : DependencyInjectionGraphOwner, Node( - buildContext = BuildContext.Companion.root(savedStateMap = null), + buildContext = BuildContext.root(savedStateMap = null), plugins = emptyList(), view = EmptyNodeView, ) { diff --git a/tests/uitests/build.gradle.kts b/tests/uitests/build.gradle.kts index 31b83388c10..d4e15e6dde8 100644 --- a/tests/uitests/build.gradle.kts +++ b/tests/uitests/build.gradle.kts @@ -32,7 +32,7 @@ android { } } -tasks.withType(Test::class.java) { +tasks.withType(Test::class) { // Don't fail the test run if there are no tests, this can happen if we run them with screenshot test disabled failOnNoDiscoveredTests = false } diff --git a/tests/uitests/src/test/kotlin/base/ScreenshotTest.kt b/tests/uitests/src/test/kotlin/base/ScreenshotTest.kt index 5296060b63e..0b088b5de05 100644 --- a/tests/uitests/src/test/kotlin/base/ScreenshotTest.kt +++ b/tests/uitests/src/test/kotlin/base/ScreenshotTest.kt @@ -83,7 +83,7 @@ private fun Paparazzi.fixScreenshotName(preview: ComposablePreview