diff --git a/.github/workflows/danger.yml b/.github/workflows/danger.yml index 295c203107..b89ab1e56e 100644 --- a/.github/workflows/danger.yml +++ b/.github/workflows/danger.yml @@ -11,7 +11,7 @@ jobs: - run: | npm install --save-dev @babel/plugin-transform-flow-strip-types - name: Danger - uses: danger/danger-js@12.2.0 + uses: danger/danger-js@12.3.0 with: args: "--dangerfile ./tools/danger/dangerfile.js" env: diff --git a/.github/workflows/nightlyReports.yml b/.github/workflows/nightlyReports.yml index 65a2717f36..b27183cc59 100644 --- a/.github/workflows/nightlyReports.yml +++ b/.github/workflows/nightlyReports.yml @@ -33,7 +33,7 @@ jobs: run: ./gradlew verifyPaparazziDebug $CI_GRADLE_ARG_PROPERTIES - name: 📈 Generate kover report and verify coverage - run: ./gradlew :app:koverXmlReportGplayDebug :app:koverHtmlReportGplayDebug :app:koverVerifyGplayDebug $CI_GRADLE_ARG_PROPERTIES + run: ./gradlew :app:koverXmlReportGplayDebug :app:koverHtmlReportGplayDebug :app:koverVerifyAll $CI_GRADLE_ARG_PROPERTIES - name: ✅ Upload kover report if: always() diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index b62dbb0127..dd0e2f2a41 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -10,7 +10,7 @@ on: # Enrich gradle.properties for CI/CD env: GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx6g -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -Dkotlin.incremental=false -XX:+UseParallelGC - CI_GRADLE_ARG_PROPERTIES: --stacktrace -PpreDexEnable=false --max-workers 8 --no-daemon --warn + CI_GRADLE_ARG_PROPERTIES: --stacktrace -PpreDexEnable=false --max-workers 8 --no-daemon jobs: checkScript: @@ -33,12 +33,13 @@ jobs: - name: Search for invalid screenshot files run: ./tools/test/checkInvalidScreenshots.py - check: - name: Project Check Suite + # Code checks + konsist: + name: Konsist tests runs-on: ubuntu-latest # Allow all jobs on main and develop. Just one per PR. concurrency: - group: ${{ github.ref == 'refs/heads/main' && format('check-main-{0}', github.sha) || github.ref == 'refs/heads/develop' && format('check-develop-{0}', github.sha) || format('check-{0}', github.ref) }} + group: ${{ github.ref == 'refs/heads/main' && format('check-konsist-main-{0}', github.sha) || github.ref == 'refs/heads/develop' && format('check-konsist-develop-{0}', github.sha) || format('check-konsist-{0}', github.ref) }} cancel-in-progress: true steps: - uses: actions/checkout@v4 @@ -55,8 +56,40 @@ jobs: uses: gradle/actions/setup-gradle@v3 with: cache-read-only: ${{ github.ref != 'refs/heads/develop' }} - - name: Run code quality check suite - run: ./gradlew runQualityChecks $CI_GRADLE_ARG_PROPERTIES + - name: Run Konsist tests + run: ./gradlew :tests:konsist:testDebugUnitTest $CI_GRADLE_ARG_PROPERTIES --no-daemon + - name: Upload reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: konsist-report + path: | + **/build/reports/**/*.* + + lint: + name: Android lint check + runs-on: ubuntu-latest + # Allow all jobs on main and develop. Just one per PR. + concurrency: + group: ${{ github.ref == 'refs/heads/main' && format('check-lint-main-{0}', github.sha) || github.ref == 'refs/heads/develop' && format('check-lint-develop-{0}', github.sha) || format('check-lint-{0}', github.ref) }} + cancel-in-progress: true + steps: + - uses: actions/checkout@v4 + with: + # Ensure we are building the branch and not the branch after being merged on develop + # https://github.com/actions/checkout/issues/881 + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }} + - name: Use JDK 17 + uses: actions/setup-java@v4 + with: + distribution: 'temurin' # See 'Supported distributions' for available options + java-version: '17' + - name: Configure gradle + uses: gradle/actions/setup-gradle@v3 + with: + cache-read-only: ${{ github.ref != 'refs/heads/develop' }} + - name: Run lint + run: ./gradlew :app:lintGplayDebug :app:lintFdroidDebug $CI_GRADLE_ARG_PROPERTIES - name: Upload reports if: always() uses: actions/upload-artifact@v4 @@ -64,6 +97,108 @@ jobs: name: linting-report path: | **/build/reports/**/*.* + + detekt: + name: Detekt checks + runs-on: ubuntu-latest + # Allow all jobs on main and develop. Just one per PR. + concurrency: + group: ${{ github.ref == 'refs/heads/main' && format('check-detekt-main-{0}', github.sha) || github.ref == 'refs/heads/develop' && format('check-detekt-develop-{0}', github.sha) || format('check-detekt-{0}', github.ref) }} + cancel-in-progress: true + steps: + - uses: actions/checkout@v4 + with: + # Ensure we are building the branch and not the branch after being merged on develop + # https://github.com/actions/checkout/issues/881 + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }} + - name: Use JDK 17 + uses: actions/setup-java@v4 + with: + distribution: 'temurin' # See 'Supported distributions' for available options + java-version: '17' + - name: Configure gradle + uses: gradle/actions/setup-gradle@v3 + with: + cache-read-only: ${{ github.ref != 'refs/heads/develop' }} + - name: Run Detekt + run: ./gradlew detekt $CI_GRADLE_ARG_PROPERTIES --no-daemon + - name: Upload reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: detekt-report + path: | + **/build/reports/**/*.* + + ktlint: + name: Ktlint checks + runs-on: ubuntu-latest + # Allow all jobs on main and develop. Just one per PR. + concurrency: + group: ${{ github.ref == 'refs/heads/main' && format('check-ktlint-main-{0}', github.sha) || github.ref == 'refs/heads/develop' && format('check-ktlint-develop-{0}', github.sha) || format('check-ktlint-{0}', github.ref) }} + cancel-in-progress: true + steps: + - uses: actions/checkout@v4 + with: + # Ensure we are building the branch and not the branch after being merged on develop + # https://github.com/actions/checkout/issues/881 + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }} + - name: Use JDK 17 + uses: actions/setup-java@v4 + with: + distribution: 'temurin' # See 'Supported distributions' for available options + java-version: '17' + - name: Configure gradle + uses: gradle/actions/setup-gradle@v3 + with: + cache-read-only: ${{ github.ref != 'refs/heads/develop' }} + - name: Run Ktlint check + run: ./gradlew ktlintCheck $CI_GRADLE_ARG_PROPERTIES + - name: Upload reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: ktlint-report + path: | + **/build/reports/**/*.* + + knit: + name: Knit checks + runs-on: ubuntu-latest + # Allow all jobs on main and develop. Just one per PR. + concurrency: + group: ${{ github.ref == 'refs/heads/main' && format('check-knit-main-{0}', github.sha) || github.ref == 'refs/heads/develop' && format('check-knit-develop-{0}', github.sha) || format('check-knit-{0}', github.ref) }} + cancel-in-progress: true + steps: + - uses: actions/checkout@v4 + with: + # Ensure we are building the branch and not the branch after being merged on develop + # https://github.com/actions/checkout/issues/881 + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }} + - name: Use JDK 17 + uses: actions/setup-java@v4 + with: + distribution: 'temurin' # See 'Supported distributions' for available options + java-version: '17' + - name: Configure gradle + uses: gradle/actions/setup-gradle@v3 + with: + cache-read-only: ${{ github.ref != 'refs/heads/develop' }} + - name: Run Knit + run: ./gradlew knitCheck $CI_GRADLE_ARG_PROPERTIES + + upload_reports: + name: Project Check Suite + runs-on: ubuntu-latest + needs: [konsist, lint, ktlint, detekt] + steps: + - uses: actions/checkout@v4 + with: + # Ensure we are building the branch and not the branch after being merged on develop + # 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@v4 - name: Prepare Danger if: always() run: | @@ -72,7 +207,7 @@ jobs: yarn add danger-plugin-lint-report --dev - name: Danger lint if: always() - uses: danger/danger-js@12.2.0 + uses: danger/danger-js@12.3.0 with: args: "--dangerfile ./tools/danger/dangerfile-lint.js" env: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4c84c4e2fe..bc16e4ad35 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,4 +1,4 @@ -name: Create release App Bundle +name: Create release App Bundle and APKs on: workflow_dispatch: @@ -11,11 +11,11 @@ env: CI_GRADLE_ARG_PROPERTIES: --stacktrace -PpreDexEnable=false --max-workers 8 --no-daemon jobs: - release: - name: Create App Bundle + gplay: + name: Create App Bundle (Gplay) runs-on: ubuntu-latest concurrency: - group: ${{ github.ref == 'refs/head/main' && format('build-release-main-{0}', github.sha) }} + group: ${{ github.ref == 'refs/head/main' && format('build-release-main-gplay-{0}', github.sha) }} cancel-in-progress: true steps: - uses: actions/checkout@v4 @@ -38,3 +38,31 @@ jobs: name: elementx-app-gplay-bundle-unsigned path: | app/build/outputs/bundle/gplayRelease/app-gplay-release.aab + + fdroid: + name: Create APKs (FDroid) + runs-on: ubuntu-latest + concurrency: + group: ${{ github.ref == 'refs/head/main' && format('build-release-main-fdroid-{0}', github.sha) }} + cancel-in-progress: true + steps: + - uses: actions/checkout@v4 + - name: Use JDK 17 + uses: actions/setup-java@v4 + with: + distribution: 'temurin' # See 'Supported distributions' for available options + java-version: '17' + - name: Configure gradle + uses: gradle/actions/setup-gradle@v3 + - name: Create APKs + env: + 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 }} + run: ./gradlew assembleFdroidRelease $CI_GRADLE_ARG_PROPERTIES + - name: Upload apks as artifact + uses: actions/upload-artifact@v4 + with: + name: elementx-app-fdroid-apks-unsigned + path: | + app/build/outputs/apk/fdroid/release/*.apk diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 4952ac435c..a678cb54cb 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -55,7 +55,7 @@ jobs: run: ./gradlew verifyPaparazziDebug $CI_GRADLE_ARG_PROPERTIES - name: 📈Generate kover report and verify coverage - run: ./gradlew :app:koverXmlReportGplayDebug :app:koverHtmlReportGplayDebug :app:koverVerifyGplayDebug $CI_GRADLE_ARG_PROPERTIES + run: ./gradlew :app:koverXmlReportGplayDebug :app:koverHtmlReportGplayDebug :app:koverVerifyAll $CI_GRADLE_ARG_PROPERTIES - name: 🚫 Upload kover failed coverage reports if: failure() diff --git a/.gitignore b/.gitignore index e7029fd3cd..342d97a933 100644 --- a/.gitignore +++ b/.gitignore @@ -45,6 +45,7 @@ captures/ .idea/assetWizardSettings.xml .idea/compiler.xml .idea/deploymentTargetDropDown.xml +.idea/deploymentTargetSelector.xml .idea/gradle.xml .idea/jarRepositories.xml .idea/misc.xml diff --git a/.idea/dictionaries/shared.xml b/.idea/dictionaries/shared.xml index c792687763..01db2cef83 100644 --- a/.idea/dictionaries/shared.xml +++ b/.idea/dictionaries/shared.xml @@ -3,7 +3,9 @@ backstack blurhash + fdroid ftue + gplay homeserver konsist kover diff --git a/.idea/kotlinc.xml b/.idea/kotlinc.xml index fe63bb677d..148fdd2469 100644 --- a/.idea/kotlinc.xml +++ b/.idea/kotlinc.xml @@ -1,6 +1,6 @@ - \ No newline at end of file diff --git a/.maestro/tests/roomList/timeline/messages/text.yaml b/.maestro/tests/roomList/timeline/messages/text.yaml index 6767886d8d..cae5e310c0 100644 --- a/.maestro/tests/roomList/timeline/messages/text.yaml +++ b/.maestro/tests/roomList/timeline/messages/text.yaml @@ -2,7 +2,7 @@ appId: ${MAESTRO_APP_ID} --- - takeScreenshot: build/maestro/510-Timeline - tapOn: - id: "rich_text_editor" + id: "text_editor" - inputText: "Hello world!" - tapOn: "Send" - hideKeyboard diff --git a/.maestro/tests/settings/settings.yaml b/.maestro/tests/settings/settings.yaml index c77d118a3b..15181a458b 100644 --- a/.maestro/tests/settings/settings.yaml +++ b/.maestro/tests/settings/settings.yaml @@ -34,7 +34,7 @@ appId: ${MAESTRO_APP_ID} - tapOn: text: "Advanced settings" -- assertVisible: "Rich text editor" +- assertVisible: "View source" - back - tapOn: diff --git a/CHANGES.md b/CHANGES.md index 409f5fdd66..d303339a27 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,64 @@ +Changes in Element X v0.4.13 (2024-05-22) +========================================= + +Features ✨ +---------- + - Add plain text editor based on Markdown input. ([#2840](https://github.com/element-hq/element-x-android/issues/2840)) + +Bugfixes 🐛 +---------- + - Use members display names for their membership state events. ([#2286](https://github.com/element-hq/element-x-android/issues/2286)) + - Make sure explicit links in messages take priority over links found by linkification (urls, emails, phone numbers, etc.) ([#2291](https://github.com/element-hq/element-x-android/issues/2291)) + - Fix modal contents overlapping screen lock pin. ([#2692](https://github.com/element-hq/element-x-android/issues/2692)) + - Fix a crash when trying to create an `EncryptedFile` in Android 6. ([#2846](https://github.com/element-hq/element-x-android/issues/2846)) + - Session falsely displayed as 'verified' with no internet connection. ([#2884](https://github.com/element-hq/element-x-android/issues/2884)) + +Other changes +------------- + - Allow configuring push notification provider ([#2340](https://github.com/element-hq/element-x-android/issues/2340)) + - UX cleanup: reorder text composer actions to prioritise camera ones. ([#2803](https://github.com/element-hq/element-x-android/issues/2803)) + - Translation added into Portuguese and Simplified Chinese ([#2834](https://github.com/element-hq/element-x-android/issues/2834)) + - Use via parameters when joining a room from permalink. ([#2843](https://github.com/element-hq/element-x-android/issues/2843)) + + +Changes in Element X v0.4.12 (2024-05-13) +========================================= + +Features ✨ +---------- +- Add support for expected decryption errors due to membership (UX and analytics). ([#2754](https://github.com/element-hq/element-x-android/issues/2754)) +- Handle permalink navigation to Events. ([#2759](https://github.com/element-hq/element-x-android/issues/2759)) +- Pretty-print event JSON in debug viewer ([#2771](https://github.com/element-hq/element-x-android/issues/2771)) +- Add support for external permalinks. ([#2776](https://github.com/element-hq/element-x-android/issues/2776)) +- Enable support for Android per-app language preferences ([#2795](https://github.com/element-hq/element-x-android/issues/2795)) + +Bugfixes 🐛 +---------- +- Fix session verification being asked again for already verified users. ([#2718](https://github.com/element-hq/element-x-android/issues/2718)) +- Instead of displaying 'create new recovery key' on the session verification screen when there is no other session active, display it always under the 'enter recovery key' screen. ([#2740](https://github.com/element-hq/element-x-android/issues/2740)) +- Adjust the typography used in the selected user component so a user's display name fits better. ([#2760](https://github.com/element-hq/element-x-android/issues/2760)) +- User display name overflows in timeline messages when it's way too long. ([#2761](https://github.com/element-hq/element-x-android/issues/2761)) +- Ensure the application open the room when a notification is clicked. ([#2778](https://github.com/element-hq/element-x-android/issues/2778)) +- Enforce mandatory session verification only for new logins. ([#2810](https://github.com/element-hq/element-x-android/issues/2810)) +- Make log less verbose, make sure we upload as many log files as possible before reaching the request size limit of the bug reporting service, discard older logs if they don't fit. ([#2825](https://github.com/element-hq/element-x-android/issues/2825)) +- Remove 'Join' button in room directory search results. ([#2827](https://github.com/element-hq/element-x-android/issues/2827)) +- Add missing `app_id` and `Version` properties to bug reports. ([#2829](https://github.com/element-hq/element-x-android/issues/2829)) + +Other changes +------------- +- RoomMember screen: fallback to userProfile data, if the member is not a user of the room. ([#2721](https://github.com/element-hq/element-x-android/issues/2721)) +- Migrate application data. ([#2749](https://github.com/element-hq/element-x-android/issues/2749)) +- Let the SDK manage the file log cleanup, and keep one week of log. ([#2758](https://github.com/element-hq/element-x-android/issues/2758)) +- UX cleanup: reorder options in the main settings screen. ([#2801](https://github.com/element-hq/element-x-android/issues/2801)) +- Analytics: Add support to report current session verification and recovery state ([#2806](https://github.com/element-hq/element-x-android/issues/2806)) +- UX cleanup: room details screen, add new CTA buttons for Invite and Call actions. ([#2814](https://github.com/element-hq/element-x-android/issues/2814)) +- UX cleanup: user profile. Move send DM to a call to action button, add 'Call' CTA too. ([#2818](https://github.com/element-hq/element-x-android/issues/2818)) +- Add room badges to room details screen. ([#2822](https://github.com/element-hq/element-x-android/issues/2822)) + +Security +------------- +- Bump the Rust SDK to `v0.2.18` to remediate [CVE-2024-34353 / GHSA-9ggc-845v-gcgv](https://github.com/matrix-org/matrix-rust-sdk/security/advisories/GHSA-9ggc-845v-gcgv). + Changes in Element X v0.4.10 (2024-04-17) ========================================= @@ -5,21 +66,21 @@ Matrix Rust SDK 0.2.14 Features ✨ ---------- - - Rework room navigation to handle unknown room and prepare work on permalink. ([#2695](https://github.com/element-hq/element-x-android/issues/2695)) +- Rework room navigation to handle unknown room and prepare work on permalink. ([#2695](https://github.com/element-hq/element-x-android/issues/2695)) Other changes ------------- - - Encrypt new session data with a passphrase ([#2703](https://github.com/element-hq/element-x-android/issues/2703)) - - Use sdk API to build permalinks ([#2708](https://github.com/element-hq/element-x-android/issues/2708)) - - Parse permalink using parseMatrixEntityFrom from the SDK ([#2709](https://github.com/element-hq/element-x-android/issues/2709)) - - Fix compile for forks that use the `noop` analytics module ([#2698](https://github.com/element-hq/element-x-android/issues/2698)) +- Encrypt new session data with a passphrase ([#2703](https://github.com/element-hq/element-x-android/issues/2703)) +- Use sdk API to build permalinks ([#2708](https://github.com/element-hq/element-x-android/issues/2708)) +- Parse permalink using parseMatrixEntityFrom from the SDK ([#2709](https://github.com/element-hq/element-x-android/issues/2709)) +- Fix compile for forks that use the `noop` analytics module ([#2698](https://github.com/element-hq/element-x-android/issues/2698)) Changes in Element X v0.4.9 (2024-04-12) ======================================== - Synchronize Localazy Strings. - + Security ---------- - Fix crash while processing a room message containing a malformed pill. @@ -29,26 +90,26 @@ Changes in Element X v0.4.8 (2024-04-10) Features ✨ ---------- - - Move session recovery to the login flow. ([#2579](https://github.com/element-hq/element-x-android/issues/2579)) - - Move session verification to the after login flow and make it mandatory. ([#2580](https://github.com/element-hq/element-x-android/issues/2580)) - - Add a notification troubleshoot screen ([#2601](https://github.com/element-hq/element-x-android/issues/2601)) - - Add action to copy permalink ([#2650](https://github.com/element-hq/element-x-android/issues/2650)) +- Move session recovery to the login flow. ([#2579](https://github.com/element-hq/element-x-android/issues/2579)) +- Move session verification to the after login flow and make it mandatory. ([#2580](https://github.com/element-hq/element-x-android/issues/2580)) +- Add a notification troubleshoot screen ([#2601](https://github.com/element-hq/element-x-android/issues/2601)) +- Add action to copy permalink ([#2650](https://github.com/element-hq/element-x-android/issues/2650)) Bugfixes 🐛 ---------- - - Fix analytics issue around room considered as space by mistake. ([#2612](https://github.com/element-hq/element-x-android/issues/2612)) - - Fix crash observed when going back to the room list. ([#2619](https://github.com/element-hq/element-x-android/issues/2619)) - - Hide Event org.matrix.msc3401.call.member on the timeline. ([#2625](https://github.com/element-hq/element-x-android/issues/2625)) - - Fall back to name-based generated avatars when image avatars don't load. ([#2667](https://github.com/element-hq/element-x-android/issues/2667)) +- Fix analytics issue around room considered as space by mistake. ([#2612](https://github.com/element-hq/element-x-android/issues/2612)) +- Fix crash observed when going back to the room list. ([#2619](https://github.com/element-hq/element-x-android/issues/2619)) +- Hide Event org.matrix.msc3401.call.member on the timeline. ([#2625](https://github.com/element-hq/element-x-android/issues/2625)) +- Fall back to name-based generated avatars when image avatars don't load. ([#2667](https://github.com/element-hq/element-x-android/issues/2667)) Other changes ------------- - - Improve UI for notification permission screen in onboarding. ([#2581](https://github.com/element-hq/element-x-android/issues/2581)) - - Categorise members by role in change roles screen. ([#2593](https://github.com/element-hq/element-x-android/issues/2593)) - - Make completed poll more clearly visible ([#2608](https://github.com/element-hq/element-x-android/issues/2608)) - - Show users from last visited DM as suggestion when starting a Chat or when creating a Room. ([#2634](https://github.com/element-hq/element-x-android/issues/2634)) - - Enable room moderation feature. ([#2678](https://github.com/element-hq/element-x-android/issues/2678)) - - Improve analytics opt-in screen UI. ([#2684](https://github.com/element-hq/element-x-android/issues/2684)) +- Improve UI for notification permission screen in onboarding. ([#2581](https://github.com/element-hq/element-x-android/issues/2581)) +- Categorise members by role in change roles screen. ([#2593](https://github.com/element-hq/element-x-android/issues/2593)) +- Make completed poll more clearly visible ([#2608](https://github.com/element-hq/element-x-android/issues/2608)) +- Show users from last visited DM as suggestion when starting a Chat or when creating a Room. ([#2634](https://github.com/element-hq/element-x-android/issues/2634)) +- Enable room moderation feature. ([#2678](https://github.com/element-hq/element-x-android/issues/2678)) +- Improve analytics opt-in screen UI. ([#2684](https://github.com/element-hq/element-x-android/issues/2684)) Changes in Element X v0.4.7 (2024-03-26) @@ -56,19 +117,19 @@ Changes in Element X v0.4.7 (2024-03-26) Features ✨ ---------- - - Enable the feature "RoomList filters". ([#2603](https://github.com/element-hq/element-x-android/issues/2603)) - - Enable the feature "Mark as unread" ([#2261](https://github.com/element-hq/element-x-android/issues/2261)) - - Implement MSC2530 (Body field as media caption) ([#2521](https://github.com/element-hq/element-x-android/issues/2521)) +- Enable the feature "RoomList filters". ([#2603](https://github.com/element-hq/element-x-android/issues/2603)) +- Enable the feature "Mark as unread" ([#2261](https://github.com/element-hq/element-x-android/issues/2261)) +- Implement MSC2530 (Body field as media caption) ([#2521](https://github.com/element-hq/element-x-android/issues/2521)) Bugfixes 🐛 ---------- - - Use user avatar from cache if available. ([#2488](https://github.com/element-hq/element-x-android/issues/2488)) - - Update member list after changing member roles and when the room member list is opened. ([#2590](https://github.com/element-hq/element-x-android/issues/2590)) +- Use user avatar from cache if available. ([#2488](https://github.com/element-hq/element-x-android/issues/2488)) +- Update member list after changing member roles and when the room member list is opened. ([#2590](https://github.com/element-hq/element-x-android/issues/2590)) Other changes ------------- - - Compound: add `BigIcon`, `BigCheckmark` and `PageTitle` components. ([#2574](https://github.com/element-hq/element-x-android/issues/2574)) - - Remove Welcome screen from the FTUE. ([#2584](https://github.com/element-hq/element-x-android/issues/2584)) +- Compound: add `BigIcon`, `BigCheckmark` and `PageTitle` components. ([#2574](https://github.com/element-hq/element-x-android/issues/2574)) +- Remove Welcome screen from the FTUE. ([#2584](https://github.com/element-hq/element-x-android/issues/2584)) Changes in Element X v0.4.6 (2024-03-15) @@ -76,26 +137,26 @@ Changes in Element X v0.4.6 (2024-03-15) Features ✨ ---------- - - Admins can now change user roles in rooms. ([#2257](https://github.com/element-hq/element-x-android/issues/2257)) - - Room member moderation: remove, ban and unban users from a room. ([#2258](https://github.com/element-hq/element-x-android/issues/2258)) - - Change a room's permissions power levels. ([#2259](https://github.com/element-hq/element-x-android/issues/2259)) - - Add state timeline events and notifications for legacy call invites. ([#2485](https://github.com/element-hq/element-x-android/issues/2485)) +- Admins can now change user roles in rooms. ([#2257](https://github.com/element-hq/element-x-android/issues/2257)) +- Room member moderation: remove, ban and unban users from a room. ([#2258](https://github.com/element-hq/element-x-android/issues/2258)) +- Change a room's permissions power levels. ([#2259](https://github.com/element-hq/element-x-android/issues/2259)) +- Add state timeline events and notifications for legacy call invites. ([#2485](https://github.com/element-hq/element-x-android/issues/2485)) Bugfixes 🐛 ---------- - - Added empty state to banned member list. ([#+add-empty-state-to-banned-members-list](https://github.com/element-hq/element-x-android/issues/+add-empty-state-to-banned-members-list)) - - Prevent sending empty messages. ([#995](https://github.com/element-hq/element-x-android/issues/995)) - - Use the display name only once in display name change events. The user should be referenced by `userId` instead. ([#2125](https://github.com/element-hq/element-x-android/issues/2125)) - - Hide blocked users list when there are no blocked users. ([#2198](https://github.com/element-hq/element-x-android/issues/2198)) - - Fix timeline not showing sender info when room is marked as direct but not a 1:1 room. ([#2530](https://github.com/element-hq/element-x-android/issues/2530)) +- Added empty state to banned member list. ([#+add-empty-state-to-banned-members-list](https://github.com/element-hq/element-x-android/issues/+add-empty-state-to-banned-members-list)) +- Prevent sending empty messages. ([#995](https://github.com/element-hq/element-x-android/issues/995)) +- Use the display name only once in display name change events. The user should be referenced by `userId` instead. ([#2125](https://github.com/element-hq/element-x-android/issues/2125)) +- Hide blocked users list when there are no blocked users. ([#2198](https://github.com/element-hq/element-x-android/issues/2198)) +- Fix timeline not showing sender info when room is marked as direct but not a 1:1 room. ([#2530](https://github.com/element-hq/element-x-android/issues/2530)) Other changes ------------- - - Add `local_time`, `utc_time` and `sdk_sha` params to bug reports so they're easier to investigate. ([#+add-time-and-sdk-sha-params-to-bugreports](https://github.com/element-hq/element-x-android/issues/+add-time-and-sdk-sha-params-to-bugreports)) - - Improve room member list loading times, increase chunk size ([#2322](https://github.com/element-hq/element-x-android/issues/2322)) - - Improve room member list loading UX. ([#2452](https://github.com/element-hq/element-x-android/issues/2452)) - - Remove the special log level for the Rust SDK read receipts. ([#2511](https://github.com/element-hq/element-x-android/issues/2511)) - - Track UTD errors. ([#2544](https://github.com/element-hq/element-x-android/issues/2544)) +- Add `local_time`, `utc_time` and `sdk_sha` params to bug reports so they're easier to investigate. ([#+add-time-and-sdk-sha-params-to-bugreports](https://github.com/element-hq/element-x-android/issues/+add-time-and-sdk-sha-params-to-bugreports)) +- Improve room member list loading times, increase chunk size ([#2322](https://github.com/element-hq/element-x-android/issues/2322)) +- Improve room member list loading UX. ([#2452](https://github.com/element-hq/element-x-android/issues/2452)) +- Remove the special log level for the Rust SDK read receipts. ([#2511](https://github.com/element-hq/element-x-android/issues/2511)) +- Track UTD errors. ([#2544](https://github.com/element-hq/element-x-android/issues/2544)) Changes in Element X v0.4.5 (2024-02-28) @@ -103,22 +164,22 @@ Changes in Element X v0.4.5 (2024-02-28) Features ✨ ---------- - - Mark a room or dm as favourite. ([#2208](https://github.com/element-hq/element-x-android/issues/2208)) - - Add moderation to rooms: - - Sort member in room member list by powerlevel, display their roles. - - Display banner users in room member list for users with enough power level to ban/unban. ([#2256](https://github.com/element-hq/element-x-android/issues/2256)) - - MediaViewer : introduce fullscreen and flick to dismiss behavior. ([#2390](https://github.com/element-hq/element-x-android/issues/2390)) - - Allow user-installed certificates to be used by the HTTP client ([#2992](https://github.com/element-hq/element-x-android/issues/2992)) +- Mark a room or dm as favourite. ([#2208](https://github.com/element-hq/element-x-android/issues/2208)) +- Add moderation to rooms: + - Sort member in room member list by powerlevel, display their roles. + - Display banner users in room member list for users with enough power level to ban/unban. ([#2256](https://github.com/element-hq/element-x-android/issues/2256)) +- MediaViewer : introduce fullscreen and flick to dismiss behavior. ([#2390](https://github.com/element-hq/element-x-android/issues/2390)) +- Allow user-installed certificates to be used by the HTTP client ([#2992](https://github.com/element-hq/element-x-android/issues/2992)) Bugfixes 🐛 ---------- - - Do not display empty room list state before the loading one when we still don't have any items ([#+do-not-display-empty-state-before-loading-roomlist](https://github.com/element-hq/element-x-android/issues/+do-not-display-empty-state-before-loading-roomlist)) - - Improve how Talkback works with the timeline. Sadly, it's still not 100% working, but there is some issue with the `LazyColumn` using `reverseLayout` that only Google can fix. ([#+improve-accessibility-in-timeline](https://github.com/element-hq/element-x-android/issues/+improve-accessibility-in-timeline)) - - Add ability to enter a recovery key to verify the session. Also fixes some refresh issues with the verification session state. ([#2421](https://github.com/element-hq/element-x-android/issues/2421)) +- Do not display empty room list state before the loading one when we still don't have any items ([#+do-not-display-empty-state-before-loading-roomlist](https://github.com/element-hq/element-x-android/issues/+do-not-display-empty-state-before-loading-roomlist)) +- Improve how Talkback works with the timeline. Sadly, it's still not 100% working, but there is some issue with the `LazyColumn` using `reverseLayout` that only Google can fix. ([#+improve-accessibility-in-timeline](https://github.com/element-hq/element-x-android/issues/+improve-accessibility-in-timeline)) +- Add ability to enter a recovery key to verify the session. Also fixes some refresh issues with the verification session state. ([#2421](https://github.com/element-hq/element-x-android/issues/2421)) Other changes ------------- - - Provide the current system proxy setting to the Rust SDK. ([#2420](https://github.com/element-hq/element-x-android/issues/2420)) +- Provide the current system proxy setting to the Rust SDK. ([#2420](https://github.com/element-hq/element-x-android/issues/2420)) Changes in Element X v0.4.4 (2024-02-15) @@ -134,31 +195,31 @@ Changes in Element X v0.4.3 (2024-02-14) Features ✨ ---------- - - Change "Read receipts" advanced setting used to send private Read Receipt to "Share presence" settings. When disabled, private Read Receipts will be sent, and no typing notification will be sent. Also Read Receipts and typing notifications will not be rendered in the timeline. ([#2241](https://github.com/element-hq/element-x-android/issues/2241)) - - Render typing notifications. ([#2242](https://github.com/element-hq/element-x-android/issues/2242)) - - Manually mark a room as unread. ([#2261](https://github.com/element-hq/element-x-android/issues/2261)) - - Add empty state to the room list. ([#2330](https://github.com/element-hq/element-x-android/issues/2330)) - - Allow joining unencrypted video calls in non encrypted rooms. ([#2333](https://github.com/element-hq/element-x-android/issues/2333)) +- Change "Read receipts" advanced setting used to send private Read Receipt to "Share presence" settings. When disabled, private Read Receipts will be sent, and no typing notification will be sent. Also Read Receipts and typing notifications will not be rendered in the timeline. ([#2241](https://github.com/element-hq/element-x-android/issues/2241)) +- Render typing notifications. ([#2242](https://github.com/element-hq/element-x-android/issues/2242)) +- Manually mark a room as unread. ([#2261](https://github.com/element-hq/element-x-android/issues/2261)) +- Add empty state to the room list. ([#2330](https://github.com/element-hq/element-x-android/issues/2330)) +- Allow joining unencrypted video calls in non encrypted rooms. ([#2333](https://github.com/element-hq/element-x-android/issues/2333)) Bugfixes 🐛 ---------- - - Fix crash after unregistering UnifiedPush distributor ([#2304](https://github.com/element-hq/element-x-android/issues/2304)) - - Add missing device id to settings screen. ([#2316](https://github.com/element-hq/element-x-android/issues/2316)) - - Open the keyboard (and keep it opened) when creating a poll. ([#2329](https://github.com/element-hq/element-x-android/issues/2329)) - - Fix message forwarding after SDK API change related to Timeline intitialization. +- Fix crash after unregistering UnifiedPush distributor ([#2304](https://github.com/element-hq/element-x-android/issues/2304)) +- Add missing device id to settings screen. ([#2316](https://github.com/element-hq/element-x-android/issues/2316)) +- Open the keyboard (and keep it opened) when creating a poll. ([#2329](https://github.com/element-hq/element-x-android/issues/2329)) +- Fix message forwarding after SDK API change related to Timeline intitialization. Other changes ------------- - - Adjusted the login flow buttons so the continue button is always at the same height ([#825](https://github.com/element-hq/element-x-android/issues/825)) - - Move migration screen to within the room list ([#2310](https://github.com/element-hq/element-x-android/issues/2310)) - - Render correctly in reply to data when Event cannot be decrypted or has been redacted ([#2318](https://github.com/element-hq/element-x-android/issues/2318)) - - Remove Compose Foundation version pinning workaround. This was done to avoid a bug introduced in the default foundation version used by the material3 library, but that has already been fixed. - - Remove `FilterHiddenStateEventsProcessor`, as this is already handled by the Rust SDK. - - Remove session preferences on user log out. +- Adjusted the login flow buttons so the continue button is always at the same height ([#825](https://github.com/element-hq/element-x-android/issues/825)) +- Move migration screen to within the room list ([#2310](https://github.com/element-hq/element-x-android/issues/2310)) +- Render correctly in reply to data when Event cannot be decrypted or has been redacted ([#2318](https://github.com/element-hq/element-x-android/issues/2318)) +- Remove Compose Foundation version pinning workaround. This was done to avoid a bug introduced in the default foundation version used by the material3 library, but that has already been fixed. +- Remove `FilterHiddenStateEventsProcessor`, as this is already handled by the Rust SDK. +- Remove session preferences on user log out. Breaking changes 🚨 ------------------- - - Update Compound icons in the project. Since the icon prefix changed to `ic_compound_` and the `CompoundIcons` helper now contains the vector icons as composable functions. +- Update Compound icons in the project. Since the icon prefix changed to `ic_compound_` and the `CompoundIcons` helper now contains the vector icons as composable functions. Changes in Element X v0.4.2 (2024-01-31) ======================================== @@ -167,31 +228,31 @@ Matrix SDK 🦀 v0.1.95 Features ✨ ---------- - - Add 'send private read receipts' option in advanced settings ([#2204](https://github.com/element-hq/element-x-android/issues/2204)) - - Send typing notification ([#2240](https://github.com/element-hq/element-x-android/issues/2240)). Disabling the sending of typing notification and rendering typing notification will come soon. +- Add 'send private read receipts' option in advanced settings ([#2204](https://github.com/element-hq/element-x-android/issues/2204)) +- Send typing notification ([#2240](https://github.com/element-hq/element-x-android/issues/2240)). Disabling the sending of typing notification and rendering typing notification will come soon. Bugfixes 🐛 ---------- - - Make the room settings screen update automatically when new room info (name, avatar, topic) is available. ([#921](https://github.com/element-hq/element-x-android/issues/921)) - - Update timeline items' read receipts when the room members info is loaded. ([#2176](https://github.com/element-hq/element-x-android/issues/2176)) - - Edited text message bubbles should resize when edited ([#2260](https://github.com/element-hq/element-x-android/issues/2260)) - - Ensure login and password exclude `\n` ([#2263](https://github.com/element-hq/element-x-android/issues/2263)) - - Room list Ensure the indicators stay grey if the global setting is set to mention only and a regular message is received. ([#2282](https://github.com/element-hq/element-x-android/issues/2282)) +- Make the room settings screen update automatically when new room info (name, avatar, topic) is available. ([#921](https://github.com/element-hq/element-x-android/issues/921)) +- Update timeline items' read receipts when the room members info is loaded. ([#2176](https://github.com/element-hq/element-x-android/issues/2176)) +- Edited text message bubbles should resize when edited ([#2260](https://github.com/element-hq/element-x-android/issues/2260)) +- Ensure login and password exclude `\n` ([#2263](https://github.com/element-hq/element-x-android/issues/2263)) +- Room list Ensure the indicators stay grey if the global setting is set to mention only and a regular message is received. ([#2282](https://github.com/element-hq/element-x-android/issues/2282)) Other changes ------------- - - Add a special logging configuration for nightlies so we can get more detailed info for existing issues. ([#+add-special-tracing-configuration-for-nightlies](https://github.com/element-hq/element-x-android/issues/+add-special-tracing-configuration-for-nightlies)) - - Try mitigating unexpected logouts by making getting/storing session data use a Mutex for synchronization. +- Add a special logging configuration for nightlies so we can get more detailed info for existing issues. ([#+add-special-tracing-configuration-for-nightlies](https://github.com/element-hq/element-x-android/issues/+add-special-tracing-configuration-for-nightlies)) +- Try mitigating unexpected logouts by making getting/storing session data use a Mutex for synchronization. Also added some more logs so we can understand exactly where it's failing. ([#+try-mitigating-unexpected-logouts](https://github.com/element-hq/element-x-android/issues/+try-mitigating-unexpected-logouts)) - - Upgrade Material3 Compose to `1.2.0-beta02`. +- Upgrade Material3 Compose to `1.2.0-beta02`. There is also a constraint on a transitive Compose Foundation dependency version (1.6.0-beta02) that fixes the timeline scrolling issue. ([#0-beta02](https://github.com/element-hq/element-x-android/issues/0-beta02)) - - Disambiguate display name in the timeline. ([#2215](https://github.com/element-hq/element-x-android/issues/2215)) +- Disambiguate display name in the timeline. ([#2215](https://github.com/element-hq/element-x-android/issues/2215)) - Disambiguate display name in notifications ([#2224](https://github.com/element-hq/element-x-android/issues/2224)) - - Remove room creation, self-join of room creator and 'this is the beginning of X' timeline items for DMs. ([#2217](https://github.com/element-hq/element-x-android/issues/2217)) - - Encrypt databases used by the Rust SDK on Nightly and Debug builds. ([#2219](https://github.com/element-hq/element-x-android/issues/2219)) - - Fallback to UnifiedPush (if available) if the PlayServices are not installed on the device. ([#2248](https://github.com/element-hq/element-x-android/issues/2248)) - - Add "Report a problem" button to the onboarding screen ([#2275](https://github.com/element-hq/element-x-android/issues/2275)) - - Add in app logs viewer to the "Report a problem" screen. ([#2276](https://github.com/element-hq/element-x-android/issues/2276)) +- Remove room creation, self-join of room creator and 'this is the beginning of X' timeline items for DMs. ([#2217](https://github.com/element-hq/element-x-android/issues/2217)) +- Encrypt databases used by the Rust SDK on Nightly and Debug builds. ([#2219](https://github.com/element-hq/element-x-android/issues/2219)) +- Fallback to UnifiedPush (if available) if the PlayServices are not installed on the device. ([#2248](https://github.com/element-hq/element-x-android/issues/2248)) +- Add "Report a problem" button to the onboarding screen ([#2275](https://github.com/element-hq/element-x-android/issues/2275)) +- Add in app logs viewer to the "Report a problem" screen. ([#2276](https://github.com/element-hq/element-x-android/issues/2276)) Changes in Element X v0.4.1 (2024-01-17) @@ -199,35 +260,35 @@ Changes in Element X v0.4.1 (2024-01-17) Features ✨ ---------- - - Render m.sticker events ([#1949](https://github.com/element-hq/element-x-android/issues/1949)) - - Add support for sending images from the keyboard ([#1977](https://github.com/element-hq/element-x-android/issues/1977)) - - Added support for MSC4027 (render custom images in reactions) ([#2159](https://github.com/element-hq/element-x-android/issues/2159)) +- Render m.sticker events ([#1949](https://github.com/element-hq/element-x-android/issues/1949)) +- Add support for sending images from the keyboard ([#1977](https://github.com/element-hq/element-x-android/issues/1977)) +- Added support for MSC4027 (render custom images in reactions) ([#2159](https://github.com/element-hq/element-x-android/issues/2159)) Bugfixes 🐛 ---------- - - Fix crash sending image with latest Posthog because of an usage of an internal Android method. ([#+crash-sending-image-with-latest-posthog](https://github.com/element-hq/element-x-android/issues/+crash-sending-image-with-latest-posthog)) - - Make sure the media viewer tries the main url first (if not empty) then the thumbnail url and then not open if both are missing instead of failing with an error dialog ([#1949](https://github.com/element-hq/element-x-android/issues/1949)) - - Fix room transition animation happens twice. ([#2084](https://github.com/element-hq/element-x-android/issues/2084)) - - Disable ability to send reaction if the user does not have the permission to. ([#2093](https://github.com/element-hq/element-x-android/issues/2093)) - - Trim whitespace at the end of messages to ensure we render the right content. ([#2099](https://github.com/element-hq/element-x-android/issues/2099)) - - Fix crashes in room list when the last message for a room was an extremely long one (several thousands of characters) with no line breaks. ([#2105](https://github.com/element-hq/element-x-android/issues/2105)) - - Disable rasterisation of Vector XMLs, which was causing crashes on API 23. ([#2124](https://github.com/element-hq/element-x-android/issues/2124)) - - Use `SubomposeLayout` for `ContentAvoidingLayout` to prevent wrong measurements in the layout process, leading to cut-off text messages in the timeline. ([#2155](https://github.com/element-hq/element-x-android/issues/2155)) - - Improve rendering of voice messages in the timeline in large displays ([#2156](https://github.com/element-hq/element-x-android/issues/2156)) - - Fix no indication that user list is loading when inviting to room. ([#2172](https://github.com/element-hq/element-x-android/issues/2172)) - - Hide keyboard when tapping on a message in the timeline. ([#2182](https://github.com/element-hq/element-x-android/issues/2182)) - - Mention selector gets stuck when quickly deleting the prompt. ([#2192](https://github.com/element-hq/element-x-android/issues/2192)) - - Hide verbose state events from the timeline ([#2216](https://github.com/element-hq/element-x-android/issues/2216)) +- Fix crash sending image with latest Posthog because of an usage of an internal Android method. ([#+crash-sending-image-with-latest-posthog](https://github.com/element-hq/element-x-android/issues/+crash-sending-image-with-latest-posthog)) +- Make sure the media viewer tries the main url first (if not empty) then the thumbnail url and then not open if both are missing instead of failing with an error dialog ([#1949](https://github.com/element-hq/element-x-android/issues/1949)) +- Fix room transition animation happens twice. ([#2084](https://github.com/element-hq/element-x-android/issues/2084)) +- Disable ability to send reaction if the user does not have the permission to. ([#2093](https://github.com/element-hq/element-x-android/issues/2093)) +- Trim whitespace at the end of messages to ensure we render the right content. ([#2099](https://github.com/element-hq/element-x-android/issues/2099)) +- Fix crashes in room list when the last message for a room was an extremely long one (several thousands of characters) with no line breaks. ([#2105](https://github.com/element-hq/element-x-android/issues/2105)) +- Disable rasterisation of Vector XMLs, which was causing crashes on API 23. ([#2124](https://github.com/element-hq/element-x-android/issues/2124)) +- Use `SubomposeLayout` for `ContentAvoidingLayout` to prevent wrong measurements in the layout process, leading to cut-off text messages in the timeline. ([#2155](https://github.com/element-hq/element-x-android/issues/2155)) +- Improve rendering of voice messages in the timeline in large displays ([#2156](https://github.com/element-hq/element-x-android/issues/2156)) +- Fix no indication that user list is loading when inviting to room. ([#2172](https://github.com/element-hq/element-x-android/issues/2172)) +- Hide keyboard when tapping on a message in the timeline. ([#2182](https://github.com/element-hq/element-x-android/issues/2182)) +- Mention selector gets stuck when quickly deleting the prompt. ([#2192](https://github.com/element-hq/element-x-android/issues/2192)) +- Hide verbose state events from the timeline ([#2216](https://github.com/element-hq/element-x-android/issues/2216)) Other changes ------------- - - Only apply `com.autonomousapps.dependency-analysis` plugin in those modules that need it. ([#+only-apply-dependency-analysis-plugin-where-needed](https://github.com/element-hq/element-x-android/issues/+only-apply-dependency-analysis-plugin-where-needed)) - - Migrate to Kover 0.7.X ([#1782](https://github.com/element-hq/element-x-android/issues/1782)) - - Remove extra logout screen. ([#2072](https://github.com/element-hq/element-x-android/issues/2072)) - - Handle `MembershipChange.NONE` rendering in the timeline. ([#2102](https://github.com/element-hq/element-x-android/issues/2102)) - - Remove extra previews for timestamp view with 'document' case ([#2127](https://github.com/element-hq/element-x-android/issues/2127)) - - Bump AGP version to 8.2.0 ([#2142](https://github.com/element-hq/element-x-android/issues/2142)) - - Replace 'leave room' text with 'leave conversation' for DMs. ([#2218](https://github.com/element-hq/element-x-android/issues/2218)) +- Only apply `com.autonomousapps.dependency-analysis` plugin in those modules that need it. ([#+only-apply-dependency-analysis-plugin-where-needed](https://github.com/element-hq/element-x-android/issues/+only-apply-dependency-analysis-plugin-where-needed)) +- Migrate to Kover 0.7.X ([#1782](https://github.com/element-hq/element-x-android/issues/1782)) +- Remove extra logout screen. ([#2072](https://github.com/element-hq/element-x-android/issues/2072)) +- Handle `MembershipChange.NONE` rendering in the timeline. ([#2102](https://github.com/element-hq/element-x-android/issues/2102)) +- Remove extra previews for timestamp view with 'document' case ([#2127](https://github.com/element-hq/element-x-android/issues/2127)) +- Bump AGP version to 8.2.0 ([#2142](https://github.com/element-hq/element-x-android/issues/2142)) +- Replace 'leave room' text with 'leave conversation' for DMs. ([#2218](https://github.com/element-hq/element-x-android/issues/2218)) Changes in Element X v0.4.0 (2023-12-22) @@ -235,75 +296,75 @@ Changes in Element X v0.4.0 (2023-12-22) Features ✨ ---------- - - Use the RTE library `TextView` to render text events in the timeline. Add support for mention pills - with no interaction yet. ([#1433](https://github.com/element-hq/element-x-android/issues/1433)) - - Tapping on a user mention pill opens their profile. ([#1448](https://github.com/element-hq/element-x-android/issues/1448)) - - Display different notifications for mentions. ([#1451](https://github.com/element-hq/element-x-android/issues/1451)) - - Reply to a poll ([#1848](https://github.com/element-hq/element-x-android/issues/1848)) - - Add plain text representation of messages ([#1850](https://github.com/element-hq/element-x-android/issues/1850)) - - Allow polls to be edited when they have not been voted on ([#1869](https://github.com/element-hq/element-x-android/issues/1869)) - - Scroll to end of timeline when sending a new message. ([#1877](https://github.com/element-hq/element-x-android/issues/1877)) - - Confirm back navigation when editing a poll only if the poll was changed ([#1886](https://github.com/element-hq/element-x-android/issues/1886)) - - Add option to delete a poll while editing the poll ([#1895](https://github.com/element-hq/element-x-android/issues/1895)) - - Open room member avatar when you click on it inside the member details screen. ([#1907](https://github.com/element-hq/element-x-android/issues/1907)) - - Poll history of a room is now accessible from the room details screen. ([#2014](https://github.com/element-hq/element-x-android/issues/2014)) - - Always close the invite list screen when there is no more invite. ([#2022](https://github.com/element-hq/element-x-android/issues/2022)) +- Use the RTE library `TextView` to render text events in the timeline. Add support for mention pills - with no interaction yet. ([#1433](https://github.com/element-hq/element-x-android/issues/1433)) +- Tapping on a user mention pill opens their profile. ([#1448](https://github.com/element-hq/element-x-android/issues/1448)) +- Display different notifications for mentions. ([#1451](https://github.com/element-hq/element-x-android/issues/1451)) +- Reply to a poll ([#1848](https://github.com/element-hq/element-x-android/issues/1848)) +- Add plain text representation of messages ([#1850](https://github.com/element-hq/element-x-android/issues/1850)) +- Allow polls to be edited when they have not been voted on ([#1869](https://github.com/element-hq/element-x-android/issues/1869)) +- Scroll to end of timeline when sending a new message. ([#1877](https://github.com/element-hq/element-x-android/issues/1877)) +- Confirm back navigation when editing a poll only if the poll was changed ([#1886](https://github.com/element-hq/element-x-android/issues/1886)) +- Add option to delete a poll while editing the poll ([#1895](https://github.com/element-hq/element-x-android/issues/1895)) +- Open room member avatar when you click on it inside the member details screen. ([#1907](https://github.com/element-hq/element-x-android/issues/1907)) +- Poll history of a room is now accessible from the room details screen. ([#2014](https://github.com/element-hq/element-x-android/issues/2014)) +- Always close the invite list screen when there is no more invite. ([#2022](https://github.com/element-hq/element-x-android/issues/2022)) Bugfixes 🐛 ---------- - - Fix see room in the room list after leaving it. ([#1006](https://github.com/element-hq/element-x-android/issues/1006)) - - Adjust mention pills font weight and horizontal padding ([#1449](https://github.com/element-hq/element-x-android/issues/1449)) - - Font size in 'All Chats' header was changing mid-animation. ([#1572](https://github.com/element-hq/element-x-android/issues/1572)) - - Accessibility: do not read initial used for avatar out loud. ([#1864](https://github.com/element-hq/element-x-android/issues/1864)) - - Use the right avatar for DMs in DM rooms ([#1912](https://github.com/element-hq/element-x-android/issues/1912)) - - Fix scaling of timeline images: don't crop, don't set min/max aspect ratio values. ([#1940](https://github.com/element-hq/element-x-android/issues/1940)) - - Fix rendering of user name with vertical text by clipping the text. ([#1950](https://github.com/element-hq/element-x-android/issues/1950)) - - Do not render `roomId` if the room has no canonical alias. ([#1970](https://github.com/element-hq/element-x-android/issues/1970)) - - Fix avatar not displayed in notification when the app is not in background ([#1991](https://github.com/element-hq/element-x-android/issues/1991)) - - Fix wording in room invite members view: `Send` -> `Invite`. ([#2037](https://github.com/element-hq/element-x-android/issues/2037)) - - Timestamp positioning was broken, specially for edited messages. ([#2060](https://github.com/element-hq/element-x-android/issues/2060)) - - Emojis in custom reaction bottom sheet are too tiny. ([#2066](https://github.com/element-hq/element-x-android/issues/2066)) - - Set a default power level to join calls. Also, create new rooms taking this power level into account. +- Fix see room in the room list after leaving it. ([#1006](https://github.com/element-hq/element-x-android/issues/1006)) +- Adjust mention pills font weight and horizontal padding ([#1449](https://github.com/element-hq/element-x-android/issues/1449)) +- Font size in 'All Chats' header was changing mid-animation. ([#1572](https://github.com/element-hq/element-x-android/issues/1572)) +- Accessibility: do not read initial used for avatar out loud. ([#1864](https://github.com/element-hq/element-x-android/issues/1864)) +- Use the right avatar for DMs in DM rooms ([#1912](https://github.com/element-hq/element-x-android/issues/1912)) +- Fix scaling of timeline images: don't crop, don't set min/max aspect ratio values. ([#1940](https://github.com/element-hq/element-x-android/issues/1940)) +- Fix rendering of user name with vertical text by clipping the text. ([#1950](https://github.com/element-hq/element-x-android/issues/1950)) +- Do not render `roomId` if the room has no canonical alias. ([#1970](https://github.com/element-hq/element-x-android/issues/1970)) +- Fix avatar not displayed in notification when the app is not in background ([#1991](https://github.com/element-hq/element-x-android/issues/1991)) +- Fix wording in room invite members view: `Send` -> `Invite`. ([#2037](https://github.com/element-hq/element-x-android/issues/2037)) +- Timestamp positioning was broken, specially for edited messages. ([#2060](https://github.com/element-hq/element-x-android/issues/2060)) +- Emojis in custom reaction bottom sheet are too tiny. ([#2066](https://github.com/element-hq/element-x-android/issues/2066)) +- Set a default power level to join calls. Also, create new rooms taking this power level into account. Other changes ------------- - - Add a warning for 'mentions and keywords only' notification option if your homeserver does not support it ([#1749](https://github.com/element-hq/element-x-android/issues/1749)) - - Remove `:libraries:theme` module, extract theme and tokens to [Compound Android](https://github.com/element-hq/compound-android). ([#1833](https://github.com/element-hq/element-x-android/issues/1833)) - - Update poll icons from Compound ([#1849](https://github.com/element-hq/element-x-android/issues/1849)) - - Add ability to see the room avatar in the media viewer. ([#1918](https://github.com/element-hq/element-x-android/issues/1918)) - - RoomList: introduce incremental loading to improve performances. ([#1920](https://github.com/element-hq/element-x-android/issues/1920)) - - Add toggle in the notification settings to disable notifications for room invites. ([#1944](https://github.com/element-hq/element-x-android/issues/1944)) - - Update rendering of Emojis displayed during verification. ([#1965](https://github.com/element-hq/element-x-android/issues/1965)) - - Hide sender info in direct rooms ([#1979](https://github.com/element-hq/element-x-android/issues/1979)) - - Render images in Notification ([#1991](https://github.com/element-hq/element-x-android/issues/1991)) - - Only process content.json from Localazy. ([#2031](https://github.com/element-hq/element-x-android/issues/2031)) - - Always show user avatar in message action sheet ([#2032](https://github.com/element-hq/element-x-android/issues/2032)) - - Hide room list dropdown menu. ([#2062](https://github.com/element-hq/element-x-android/issues/2062)) - - Enable Chat backup, Mentions and Read Receipt in release. ([#2087](https://github.com/element-hq/element-x-android/issues/2087)) - - Make most code used in Compose from `:libraries:matrix` and derived classes Immutable or Stable. +- Add a warning for 'mentions and keywords only' notification option if your homeserver does not support it ([#1749](https://github.com/element-hq/element-x-android/issues/1749)) +- Remove `:libraries:theme` module, extract theme and tokens to [Compound Android](https://github.com/element-hq/compound-android). ([#1833](https://github.com/element-hq/element-x-android/issues/1833)) +- Update poll icons from Compound ([#1849](https://github.com/element-hq/element-x-android/issues/1849)) +- Add ability to see the room avatar in the media viewer. ([#1918](https://github.com/element-hq/element-x-android/issues/1918)) +- RoomList: introduce incremental loading to improve performances. ([#1920](https://github.com/element-hq/element-x-android/issues/1920)) +- Add toggle in the notification settings to disable notifications for room invites. ([#1944](https://github.com/element-hq/element-x-android/issues/1944)) +- Update rendering of Emojis displayed during verification. ([#1965](https://github.com/element-hq/element-x-android/issues/1965)) +- Hide sender info in direct rooms ([#1979](https://github.com/element-hq/element-x-android/issues/1979)) +- Render images in Notification ([#1991](https://github.com/element-hq/element-x-android/issues/1991)) +- Only process content.json from Localazy. ([#2031](https://github.com/element-hq/element-x-android/issues/2031)) +- Always show user avatar in message action sheet ([#2032](https://github.com/element-hq/element-x-android/issues/2032)) +- Hide room list dropdown menu. ([#2062](https://github.com/element-hq/element-x-android/issues/2062)) +- Enable Chat backup, Mentions and Read Receipt in release. ([#2087](https://github.com/element-hq/element-x-android/issues/2087)) +- Make most code used in Compose from `:libraries:matrix` and derived classes Immutable or Stable. Changes in Element X v0.3.2 (2023-11-22) ======================================== Features ✨ ---------- - - Add ongoing call indicator to rooms lists items. ([#1158](https://github.com/element-hq/element-x-android/issues/1158)) - - Add support for typing mentions in the message composer. ([#1453](https://github.com/element-hq/element-x-android/issues/1453)) - - Add intentional mentions to messages. This needs to be enabled in developer options since it's disabled by default. ([#1591](https://github.com/element-hq/element-x-android/issues/1591)) - - Update voice message recording behaviour. Instead of holding the record button, users can now tap the record button to start recording and tap again to stop recording. ([#1784](https://github.com/element-hq/element-x-android/issues/1784)) +- Add ongoing call indicator to rooms lists items. ([#1158](https://github.com/element-hq/element-x-android/issues/1158)) +- Add support for typing mentions in the message composer. ([#1453](https://github.com/element-hq/element-x-android/issues/1453)) +- Add intentional mentions to messages. This needs to be enabled in developer options since it's disabled by default. ([#1591](https://github.com/element-hq/element-x-android/issues/1591)) +- Update voice message recording behaviour. Instead of holding the record button, users can now tap the record button to start recording and tap again to stop recording. ([#1784](https://github.com/element-hq/element-x-android/issues/1784)) Bugfixes 🐛 ---------- - - Always ensure media temp dir exists ([#1790](https://github.com/element-hq/element-x-android/issues/1790)) +- Always ensure media temp dir exists ([#1790](https://github.com/element-hq/element-x-android/issues/1790)) Other changes ------------- - - Update icons and move away from `PreferenceText` components. ([#1718](https://github.com/element-hq/element-x-android/issues/1718)) - - Add item "This is the beginning of..." at the beginning of the timeline. ([#1801](https://github.com/element-hq/element-x-android/issues/1801)) - - LockScreen : rework LoggedInFlowNode and back management when locked. ([#1806](https://github.com/element-hq/element-x-android/issues/1806)) - - Suppress usage of removeTimeline method. ([#1824](https://github.com/element-hq/element-x-android/issues/1824)) - - Remove Element Call feature flag, it's now always enabled. - - Reverted the EC base URL to `https://call.element.io`. - - Moved the option to override this URL to developer settings from advanced settings. +- Update icons and move away from `PreferenceText` components. ([#1718](https://github.com/element-hq/element-x-android/issues/1718)) +- Add item "This is the beginning of..." at the beginning of the timeline. ([#1801](https://github.com/element-hq/element-x-android/issues/1801)) +- LockScreen : rework LoggedInFlowNode and back management when locked. ([#1806](https://github.com/element-hq/element-x-android/issues/1806)) +- Suppress usage of removeTimeline method. ([#1824](https://github.com/element-hq/element-x-android/issues/1824)) +- Remove Element Call feature flag, it's now always enabled. +- Reverted the EC base URL to `https://call.element.io`. +- Moved the option to override this URL to developer settings from advanced settings. Changes in Element X v0.3.1 (2023-11-09) @@ -311,16 +372,16 @@ Changes in Element X v0.3.1 (2023-11-09) Features ✨ ---------- - - Chat backup is still under a feature flag, but when enabled, user can enter their recovery key (it's also possible to input a passphrase) to unlock the encrypted room history. ([#1770](https://github.com/element-hq/element-x-android/pull/1770)) +- Chat backup is still under a feature flag, but when enabled, user can enter their recovery key (it's also possible to input a passphrase) to unlock the encrypted room history. ([#1770](https://github.com/element-hq/element-x-android/pull/1770)) Bugfixes 🐛 ---------- - - Improve confusing text in the 'ready to start verification' screen. ([#879](https://github.com/element-hq/element-x-android/issues/879)) - - Message composer wasn't resized when selecting a several lines message to reply to, then a single line one. ([#1560](https://github.com/element-hq/element-x-android/issues/1560)) +- Improve confusing text in the 'ready to start verification' screen. ([#879](https://github.com/element-hq/element-x-android/issues/879)) +- Message composer wasn't resized when selecting a several lines message to reply to, then a single line one. ([#1560](https://github.com/element-hq/element-x-android/issues/1560)) Other changes ------------- - - PIN: Set lock grace period to 0. ([#1732](https://github.com/element-hq/element-x-android/issues/1732)) +- PIN: Set lock grace period to 0. ([#1732](https://github.com/element-hq/element-x-android/issues/1732)) Changes in Element X v0.3.0 (2023-10-31) @@ -328,24 +389,24 @@ Changes in Element X v0.3.0 (2023-10-31) Features ✨ ---------- - - Element Call: change the 'join call' button in a chat room when there's an active call. ([#1158](https://github.com/element-hq/element-x-android/issues/1158)) - - Mentions: add mentions suggestion view in RTE ([#1452](https://github.com/element-hq/element-x-android/issues/1452)) - - Record and send voice messages ([#1596](https://github.com/element-hq/element-x-android/issues/1596)) - - Enable voice messages for all users ([#1669](https://github.com/element-hq/element-x-android/issues/1669)) - - Receive and play a voice message ([#2084](https://github.com/element-hq/element-x-android/issues/2084)) - - Enable Element Call integration in rooms by default, fix several issues when creating or joining calls. +- Element Call: change the 'join call' button in a chat room when there's an active call. ([#1158](https://github.com/element-hq/element-x-android/issues/1158)) +- Mentions: add mentions suggestion view in RTE ([#1452](https://github.com/element-hq/element-x-android/issues/1452)) +- Record and send voice messages ([#1596](https://github.com/element-hq/element-x-android/issues/1596)) +- Enable voice messages for all users ([#1669](https://github.com/element-hq/element-x-android/issues/1669)) +- Receive and play a voice message ([#2084](https://github.com/element-hq/element-x-android/issues/2084)) +- Enable Element Call integration in rooms by default, fix several issues when creating or joining calls. Bugfixes 🐛 ---------- - - Group fallback notification to avoid having plenty of them displayed. ([#994](https://github.com/element-hq/element-x-android/issues/994)) - - Hide keyboard when exiting the chat room screen. ([#1375](https://github.com/element-hq/element-x-android/issues/1375)) - - Always register the pusher when application starts ([#1481](https://github.com/element-hq/element-x-android/issues/1481)) - - Ensure screen does not turn off when playing a video ([#1519](https://github.com/element-hq/element-x-android/issues/1519)) - - Fix issue where text is cleared when cancelling a reply ([#1617](https://github.com/element-hq/element-x-android/issues/1617)) +- Group fallback notification to avoid having plenty of them displayed. ([#994](https://github.com/element-hq/element-x-android/issues/994)) +- Hide keyboard when exiting the chat room screen. ([#1375](https://github.com/element-hq/element-x-android/issues/1375)) +- Always register the pusher when application starts ([#1481](https://github.com/element-hq/element-x-android/issues/1481)) +- Ensure screen does not turn off when playing a video ([#1519](https://github.com/element-hq/element-x-android/issues/1519)) +- Fix issue where text is cleared when cancelling a reply ([#1617](https://github.com/element-hq/element-x-android/issues/1617)) Other changes ------------- - - Remove usage of blocking methods. ([#1563](https://github.com/element-hq/element-x-android/issues/1563)) +- Remove usage of blocking methods. ([#1563](https://github.com/element-hq/element-x-android/issues/1563)) Changes in Element X v0.2.4 (2023-10-12) @@ -353,20 +414,20 @@ Changes in Element X v0.2.4 (2023-10-12) Features ✨ ---------- - - [Rich text editor] Add full screen mode ([#1447](https://github.com/element-hq/element-x-android/issues/1447)) - - Improve rendering of m.emote. ([#1497](https://github.com/element-hq/element-x-android/issues/1497)) - - Improve deleted session behavior. ([#1520](https://github.com/element-hq/element-x-android/issues/1520)) +- [Rich text editor] Add full screen mode ([#1447](https://github.com/element-hq/element-x-android/issues/1447)) +- Improve rendering of m.emote. ([#1497](https://github.com/element-hq/element-x-android/issues/1497)) +- Improve deleted session behavior. ([#1520](https://github.com/element-hq/element-x-android/issues/1520)) Bugfixes 🐛 ---------- - - WebP images can't be sent as media. ([#1483](https://github.com/element-hq/element-x-android/issues/1483)) - - Fix back button not working in bottom sheets. ([#1517](https://github.com/element-hq/element-x-android/issues/1517)) - - Render body of unknown msgtype in the timeline and in the room list ([#1539](https://github.com/element-hq/element-x-android/issues/1539)) +- WebP images can't be sent as media. ([#1483](https://github.com/element-hq/element-x-android/issues/1483)) +- Fix back button not working in bottom sheets. ([#1517](https://github.com/element-hq/element-x-android/issues/1517)) +- Render body of unknown msgtype in the timeline and in the room list ([#1539](https://github.com/element-hq/element-x-android/issues/1539)) Other changes ------------- - - Room : makes subscribeToSync/unsubscribeFromSync suspendable. ([#1457](https://github.com/element-hq/element-x-android/issues/1457)) - - Add some Konsist tests. ([#1526](https://github.com/element-hq/element-x-android/issues/1526)) +- Room : makes subscribeToSync/unsubscribeFromSync suspendable. ([#1457](https://github.com/element-hq/element-x-android/issues/1457)) +- Add some Konsist tests. ([#1526](https://github.com/element-hq/element-x-android/issues/1526)) Changes in Element X v0.2.3 (2023-09-27) @@ -374,12 +435,12 @@ Changes in Element X v0.2.3 (2023-09-27) Features ✨ ---------- - - Handle installation of Apks from the media viewer. ([#1432](https://github.com/element-hq/element-x-android/pull/1432)) - - Integrate SDK 0.1.58 ([#1437](https://github.com/element-hq/element-x-android/pull/1437)) +- Handle installation of Apks from the media viewer. ([#1432](https://github.com/element-hq/element-x-android/pull/1432)) +- Integrate SDK 0.1.58 ([#1437](https://github.com/element-hq/element-x-android/pull/1437)) Other changes ------------- - - Element call: add custom parameters to Element Call urls. ([#1434](https://github.com/element-hq/element-x-android/issues/1434)) +- Element call: add custom parameters to Element Call urls. ([#1434](https://github.com/element-hq/element-x-android/issues/1434)) Changes in Element X v0.2.2 (2023-09-21) @@ -387,8 +448,8 @@ Changes in Element X v0.2.2 (2023-09-21) Bugfixes 🐛 ---------- - - Add animation when rendering the timeline to avoid glitches. ([#1323](https://github.com/element-hq/element-x-android/issues/1323)) - - Fix crash when trying to take a photo or record a video. ([#1395](https://github.com/element-hq/element-x-android/issues/1395)) +- Add animation when rendering the timeline to avoid glitches. ([#1323](https://github.com/element-hq/element-x-android/issues/1323)) +- Fix crash when trying to take a photo or record a video. ([#1395](https://github.com/element-hq/element-x-android/issues/1395)) Changes in Element X v0.2.1 (2023-09-20) @@ -396,19 +457,19 @@ Changes in Element X v0.2.1 (2023-09-20) Features ✨ ---------- - - Bump Rust SDK to `v0.1.56` - - [Rich text editor] Add link support to rich text editor ([#1309](https://github.com/element-hq/element-x-android/issues/1309)) - - Let the SDK figure the best scheme given an homeserver URL (thus allowing HTTP homeservers) ([#1382](https://github.com/element-hq/element-x-android/issues/1382)) +- Bump Rust SDK to `v0.1.56` +- [Rich text editor] Add link support to rich text editor ([#1309](https://github.com/element-hq/element-x-android/issues/1309)) +- Let the SDK figure the best scheme given an homeserver URL (thus allowing HTTP homeservers) ([#1382](https://github.com/element-hq/element-x-android/issues/1382)) Bugfixes 🐛 ---------- - - Fix ANR on RoomList when notification settings change. ([#1370](https://github.com/element-hq/element-x-android/issues/1370)) +- Fix ANR on RoomList when notification settings change. ([#1370](https://github.com/element-hq/element-x-android/issues/1370)) Other changes ------------- - - Element Call: support scheme `io.element.call` ([#1377](https://github.com/element-hq/element-x-android/issues/1377)) - - [DI] Rework how dagger components are created and provided. ([#1378](https://github.com/element-hq/element-x-android/issues/1378)) - - Remove usage of async-uniffi as it leads to a deadlocks and memory leaks. ([#1381](https://github.com/element-hq/element-x-android/issues/1381)) +- Element Call: support scheme `io.element.call` ([#1377](https://github.com/element-hq/element-x-android/issues/1377)) +- [DI] Rework how dagger components are created and provided. ([#1378](https://github.com/element-hq/element-x-android/issues/1378)) +- Remove usage of async-uniffi as it leads to a deadlocks and memory leaks. ([#1381](https://github.com/element-hq/element-x-android/issues/1381)) Changes in Element X v0.2.0 (2023-09-18) @@ -416,38 +477,38 @@ Changes in Element X v0.2.0 (2023-09-18) Features ✨ ---------- - - Bump Rust SDK to `v0.1.54` - - Add a "Mute" shortcut icon and a "Notifications" section in the room details screen ([#506](https://github.com/element-hq/element-x-android/issues/506)) - - Add a notification permission screen to the initial flow. ([#897](https://github.com/element-hq/element-x-android/issues/897)) - - Integrate Element Call into EX by embedding a call in a WebView. ([#1300](https://github.com/element-hq/element-x-android/issues/1300)) - - Implement Bloom effect modifier. ([#1217](https://github.com/element-hq/element-x-android/issues/1217)) - - Set color on display name and default avatar in the timeline. ([#1224](https://github.com/element-hq/element-x-android/issues/1224)) - - Display a thread decorator in timeline so we know when a message is coming from a thread. ([#1236](https://github.com/element-hq/element-x-android/issues/1236)) - - [Rich text editor] Integrate rich text editor library. Note that markdown is now not supported and further formatting support will be introduced through the rich text editor. ([#1172](https://github.com/element-hq/element-x-android/issues/1172)) - - [Rich text editor] Add formatting menu (accessible via the '+' button) ([#1261](https://github.com/element-hq/element-x-android/issues/1261)) - - [Rich text editor] Add feature flag for rich text editor. Markdown support can now be enabled by disabling the rich text editor. ([#1289](https://github.com/element-hq/element-x-android/issues/1289)) - - [Rich text editor] Update design ([#1332](https://github.com/element-hq/element-x-android/issues/1332)) +- Bump Rust SDK to `v0.1.54` +- Add a "Mute" shortcut icon and a "Notifications" section in the room details screen ([#506](https://github.com/element-hq/element-x-android/issues/506)) +- Add a notification permission screen to the initial flow. ([#897](https://github.com/element-hq/element-x-android/issues/897)) +- Integrate Element Call into EX by embedding a call in a WebView. ([#1300](https://github.com/element-hq/element-x-android/issues/1300)) +- Implement Bloom effect modifier. ([#1217](https://github.com/element-hq/element-x-android/issues/1217)) +- Set color on display name and default avatar in the timeline. ([#1224](https://github.com/element-hq/element-x-android/issues/1224)) +- Display a thread decorator in timeline so we know when a message is coming from a thread. ([#1236](https://github.com/element-hq/element-x-android/issues/1236)) +- [Rich text editor] Integrate rich text editor library. Note that markdown is now not supported and further formatting support will be introduced through the rich text editor. ([#1172](https://github.com/element-hq/element-x-android/issues/1172)) +- [Rich text editor] Add formatting menu (accessible via the '+' button) ([#1261](https://github.com/element-hq/element-x-android/issues/1261)) +- [Rich text editor] Add feature flag for rich text editor. Markdown support can now be enabled by disabling the rich text editor. ([#1289](https://github.com/element-hq/element-x-android/issues/1289)) +- [Rich text editor] Update design ([#1332](https://github.com/element-hq/element-x-android/issues/1332)) Bugfixes 🐛 ---------- - - Make links in room topic clickable ([#612](https://github.com/element-hq/element-x-android/issues/612)) - - Reply action: harmonize conditions in bottom sheet and swipe to reply. ([#1173](https://github.com/element-hq/element-x-android/issues/1173)) - - Fix system bar color after login on light theme. ([#1222](https://github.com/element-hq/element-x-android/issues/1222)) - - Fix long click on simple formatted messages ([#1232](https://github.com/element-hq/element-x-android/issues/1232)) - - Enable polls in release build. ([#1241](https://github.com/element-hq/element-x-android/issues/1241)) - - Fix top padding in room list when app is opened in offline mode. ([#1297](https://github.com/element-hq/element-x-android/issues/1297)) - - [Rich text editor] Fix 'text formatting' option only partially visible ([#1335](https://github.com/element-hq/element-x-android/issues/1335)) - - [Rich text editor] Ensure keyboard opens for reply and text formatting modes ([#1337](https://github.com/element-hq/element-x-android/issues/1337)) - - [Rich text editor] Fix placeholder spilling onto multiple lines ([#1347](https://github.com/element-hq/element-x-android/issues/1347)) +- Make links in room topic clickable ([#612](https://github.com/element-hq/element-x-android/issues/612)) +- Reply action: harmonize conditions in bottom sheet and swipe to reply. ([#1173](https://github.com/element-hq/element-x-android/issues/1173)) +- Fix system bar color after login on light theme. ([#1222](https://github.com/element-hq/element-x-android/issues/1222)) +- Fix long click on simple formatted messages ([#1232](https://github.com/element-hq/element-x-android/issues/1232)) +- Enable polls in release build. ([#1241](https://github.com/element-hq/element-x-android/issues/1241)) +- Fix top padding in room list when app is opened in offline mode. ([#1297](https://github.com/element-hq/element-x-android/issues/1297)) +- [Rich text editor] Fix 'text formatting' option only partially visible ([#1335](https://github.com/element-hq/element-x-android/issues/1335)) +- [Rich text editor] Ensure keyboard opens for reply and text formatting modes ([#1337](https://github.com/element-hq/element-x-android/issues/1337)) +- [Rich text editor] Fix placeholder spilling onto multiple lines ([#1347](https://github.com/element-hq/element-x-android/issues/1347)) Other changes ------------- - - Add a sub-screen "Notifications" in the existing application Settings ([#510](https://github.com/element-hq/element-x-android/issues/510)) - - Exclude some groups related to analytics to be included. ([#1191](https://github.com/element-hq/element-x-android/issues/1191)) - - Use the new SyncIndicator API. ([#1244](https://github.com/element-hq/element-x-android/issues/1244)) - - Improve RoomSummary mapping by using RoomInfo. ([#1251](https://github.com/element-hq/element-x-android/issues/1251)) - - Ensure Posthog data are sent to "https://posthog.element.io" ([#1269](https://github.com/element-hq/element-x-android/issues/1269)) - - New app icon, with monochrome support. ([#1363](https://github.com/element-hq/element-x-android/issues/1363)) +- Add a sub-screen "Notifications" in the existing application Settings ([#510](https://github.com/element-hq/element-x-android/issues/510)) +- Exclude some groups related to analytics to be included. ([#1191](https://github.com/element-hq/element-x-android/issues/1191)) +- Use the new SyncIndicator API. ([#1244](https://github.com/element-hq/element-x-android/issues/1244)) +- Improve RoomSummary mapping by using RoomInfo. ([#1251](https://github.com/element-hq/element-x-android/issues/1251)) +- Ensure Posthog data are sent to "https://posthog.element.io" ([#1269](https://github.com/element-hq/element-x-android/issues/1269)) +- New app icon, with monochrome support. ([#1363](https://github.com/element-hq/element-x-android/issues/1363)) Changes in Element X v0.1.6 (2023-09-04) @@ -455,22 +516,22 @@ Changes in Element X v0.1.6 (2023-09-04) Features ✨ ---------- - - Enable the Polls feature. Allows to create, view, vote and end polls. ([#1196](https://github.com/element-hq/element-x-android/issues/1196)) +- Enable the Polls feature. Allows to create, view, vote and end polls. ([#1196](https://github.com/element-hq/element-x-android/issues/1196)) - Create poll. ([#1143](https://github.com/element-hq/element-x-android/issues/1143)) Bugfixes 🐛 ---------- - Ensure notification for Event from encrypted room get decrypted content. ([#1178](https://github.com/element-hq/element-x-android/issues/1178)) - - Make sure Snackbars are only displayed once. ([#928](https://github.com/element-hq/element-x-android/issues/928)) - - Fix the orientation of sent images. ([#1135](https://github.com/element-hq/element-x-android/issues/1135)) - - Bug reporter crashes when 'send logs' is disabled. ([#1168](https://github.com/element-hq/element-x-android/issues/1168)) - - Add missing link to the terms on the analytics setting screen. ([#1177](https://github.com/element-hq/element-x-android/issues/1177)) - - Re-enable `SyncService.withEncryptionSync` to improve decryption of notifications. ([#1198](https://github.com/element-hq/element-x-android/issues/1198)) - - Crash with `aspectRatio` modifier when `Float.NaN` was used as input. ([#1995](https://github.com/element-hq/element-x-android/issues/1995)) +- Make sure Snackbars are only displayed once. ([#928](https://github.com/element-hq/element-x-android/issues/928)) +- Fix the orientation of sent images. ([#1135](https://github.com/element-hq/element-x-android/issues/1135)) +- Bug reporter crashes when 'send logs' is disabled. ([#1168](https://github.com/element-hq/element-x-android/issues/1168)) +- Add missing link to the terms on the analytics setting screen. ([#1177](https://github.com/element-hq/element-x-android/issues/1177)) +- Re-enable `SyncService.withEncryptionSync` to improve decryption of notifications. ([#1198](https://github.com/element-hq/element-x-android/issues/1198)) +- Crash with `aspectRatio` modifier when `Float.NaN` was used as input. ([#1995](https://github.com/element-hq/element-x-android/issues/1995)) Other changes ------------- - - Remove unnecessary year in copyright mention. ([#1187](https://github.com/element-hq/element-x-android/issues/1187)) +- Remove unnecessary year in copyright mention. ([#1187](https://github.com/element-hq/element-x-android/issues/1187)) Changes in Element X v0.1.5 (2023-08-28) @@ -478,7 +539,7 @@ Changes in Element X v0.1.5 (2023-08-28) Bugfixes 🐛 ---------- - - Fix crash when opening any room. ([#1160](https://github.com/element-hq/element-x-android/issues/1160)) +- Fix crash when opening any room. ([#1160](https://github.com/element-hq/element-x-android/issues/1160)) Changes in Element X v0.1.4 (2023-08-28) @@ -486,32 +547,32 @@ Changes in Element X v0.1.4 (2023-08-28) Features ✨ ---------- - - Allow cancelling media upload ([#769](https://github.com/element-hq/element-x-android/issues/769)) - - Enable OIDC support. ([#1127](https://github.com/element-hq/element-x-android/issues/1127)) - - Add a "Setting up account" screen, displayed the first time the user logs in to the app (per account). ([#1149](https://github.com/element-hq/element-x-android/issues/1149)) +- Allow cancelling media upload ([#769](https://github.com/element-hq/element-x-android/issues/769)) +- Enable OIDC support. ([#1127](https://github.com/element-hq/element-x-android/issues/1127)) +- Add a "Setting up account" screen, displayed the first time the user logs in to the app (per account). ([#1149](https://github.com/element-hq/element-x-android/issues/1149)) Bugfixes 🐛 ---------- - - Videos sent from the app were cropped in some cases. ([#862](https://github.com/element-hq/element-x-android/issues/862)) - - Timeline: sender names are now displayed in one single line. ([#1033](https://github.com/element-hq/element-x-android/issues/1033)) - - Fix `TextButtons` being displayed in black. ([#1077](https://github.com/element-hq/element-x-android/issues/1077)) - - Linkify links in HTML contents. ([#1079](https://github.com/element-hq/element-x-android/issues/1079)) - - Fix bug reporter failing after not finding some log files. ([#1082](https://github.com/element-hq/element-x-android/issues/1082)) - - Fix rendering of inline elements in list items. ([#1090](https://github.com/element-hq/element-x-android/issues/1090)) - - Fix crash RuntimeException "No matching key found for the ciphertext in the stream" ([#1101](https://github.com/element-hq/element-x-android/issues/1101)) - - Make links in messages clickable again. ([#1111](https://github.com/element-hq/element-x-android/issues/1111)) - - When event has no id, just cancel parsing the latest room message for a room. ([#1125](https://github.com/element-hq/element-x-android/issues/1125)) - - Only display verification prompt after initial sync is done. ([#1131](https://github.com/element-hq/element-x-android/issues/1131)) +- Videos sent from the app were cropped in some cases. ([#862](https://github.com/element-hq/element-x-android/issues/862)) +- Timeline: sender names are now displayed in one single line. ([#1033](https://github.com/element-hq/element-x-android/issues/1033)) +- Fix `TextButtons` being displayed in black. ([#1077](https://github.com/element-hq/element-x-android/issues/1077)) +- Linkify links in HTML contents. ([#1079](https://github.com/element-hq/element-x-android/issues/1079)) +- Fix bug reporter failing after not finding some log files. ([#1082](https://github.com/element-hq/element-x-android/issues/1082)) +- Fix rendering of inline elements in list items. ([#1090](https://github.com/element-hq/element-x-android/issues/1090)) +- Fix crash RuntimeException "No matching key found for the ciphertext in the stream" ([#1101](https://github.com/element-hq/element-x-android/issues/1101)) +- Make links in messages clickable again. ([#1111](https://github.com/element-hq/element-x-android/issues/1111)) +- When event has no id, just cancel parsing the latest room message for a room. ([#1125](https://github.com/element-hq/element-x-android/issues/1125)) +- Only display verification prompt after initial sync is done. ([#1131](https://github.com/element-hq/element-x-android/issues/1131)) In development 🚧 ---------------- - - [Poll] Add feature flag in developer options ([#1064](https://github.com/element-hq/element-x-android/issues/1064)) - - [Polls] Improve UI and render ended state ([#1113](https://github.com/element-hq/element-x-android/issues/1113)) +- [Poll] Add feature flag in developer options ([#1064](https://github.com/element-hq/element-x-android/issues/1064)) +- [Polls] Improve UI and render ended state ([#1113](https://github.com/element-hq/element-x-android/issues/1113)) Other changes ------------- - - Compound: add `ListItem` and `ListSectionHeader` components. ([#990](https://github.com/element-hq/element-x-android/issues/990)) - - Migrate `object` to `data object` in sealed interface / class #1135 ([#1135](https://github.com/element-hq/element-x-android/issues/1135)) +- Compound: add `ListItem` and `ListSectionHeader` components. ([#990](https://github.com/element-hq/element-x-android/issues/990)) +- Migrate `object` to `data object` in sealed interface / class #1135 ([#1135](https://github.com/element-hq/element-x-android/issues/1135)) Changes in Element X v0.1.2 (2023-08-16) @@ -519,20 +580,20 @@ Changes in Element X v0.1.2 (2023-08-16) Bugfixes 🐛 ---------- - - Filter push notifications using push rules. ([#640](https://github.com/element-hq/element-x-android/issues/640)) - - Use `for` instead of `forEach` in `DefaultDiffCacheInvalidator` to improve performance. ([#1035](https://github.com/element-hq/element-x-android/issues/1035)) +- Filter push notifications using push rules. ([#640](https://github.com/element-hq/element-x-android/issues/640)) +- Use `for` instead of `forEach` in `DefaultDiffCacheInvalidator` to improve performance. ([#1035](https://github.com/element-hq/element-x-android/issues/1035)) In development 🚧 ---------------- - - [Poll] Render start event in the timeline ([#1031](https://github.com/element-hq/element-x-android/issues/1031)) +- [Poll] Render start event in the timeline ([#1031](https://github.com/element-hq/element-x-android/issues/1031)) Other changes ------------- - - Add Button component based on Compound designs ([#1021](https://github.com/element-hq/element-x-android/issues/1021)) - - Compound: implement dialogs. ([#1043](https://github.com/element-hq/element-x-android/issues/1043)) - - Compound: customise `IconButton` component. ([#1049](https://github.com/element-hq/element-x-android/issues/1049)) - - Compound: implement `DropdownMenu` customisations. ([#1050](https://github.com/element-hq/element-x-android/issues/1050)) - - Compound: implement Snackbar component. ([#1054](https://github.com/element-hq/element-x-android/issues/1054)) +- Add Button component based on Compound designs ([#1021](https://github.com/element-hq/element-x-android/issues/1021)) +- Compound: implement dialogs. ([#1043](https://github.com/element-hq/element-x-android/issues/1043)) +- Compound: customise `IconButton` component. ([#1049](https://github.com/element-hq/element-x-android/issues/1049)) +- Compound: implement `DropdownMenu` customisations. ([#1050](https://github.com/element-hq/element-x-android/issues/1050)) +- Compound: implement Snackbar component. ([#1054](https://github.com/element-hq/element-x-android/issues/1054)) Changes in Element X v0.1.0 (2023-07-19) diff --git a/app/src/main/kotlin/io/element/android/x/ElementXApplication.kt b/app/src/main/kotlin/io/element/android/x/ElementXApplication.kt index 437c6700f5..605dda5691 100644 --- a/app/src/main/kotlin/io/element/android/x/ElementXApplication.kt +++ b/app/src/main/kotlin/io/element/android/x/ElementXApplication.kt @@ -36,6 +36,6 @@ class ElementXApplication : Application(), DaggerComponentOwner { initializeComponent(TracingInitializer::class.java) initializeComponent(CacheCleanerInitializer::class.java) } - logApplicationInfo() + logApplicationInfo(this) } } diff --git a/app/src/main/kotlin/io/element/android/x/MainActivity.kt b/app/src/main/kotlin/io/element/android/x/MainActivity.kt index c11e63a164..eb3f9450f5 100644 --- a/app/src/main/kotlin/io/element/android/x/MainActivity.kt +++ b/app/src/main/kotlin/io/element/android/x/MainActivity.kt @@ -32,6 +32,9 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalUriHandler import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import com.bumble.appyx.core.integration.NodeHost import com.bumble.appyx.core.integrationpoint.NodeActivity import com.bumble.appyx.core.plugin.NodeReadyObserver @@ -39,13 +42,16 @@ import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.Theme import io.element.android.compound.theme.isDark import io.element.android.compound.theme.mapToTheme +import io.element.android.features.lockscreen.api.LockScreenEntryPoint +import io.element.android.features.lockscreen.api.LockScreenLockState +import io.element.android.features.lockscreen.api.LockScreenService import io.element.android.features.lockscreen.api.handleSecureFlag -import io.element.android.features.lockscreen.api.isLocked import io.element.android.libraries.architecture.bindings import io.element.android.libraries.core.log.logger.LoggerTag import io.element.android.libraries.designsystem.utils.snackbar.LocalSnackbarDispatcher import io.element.android.x.di.AppBindings import io.element.android.x.intent.SafeUriHandler +import kotlinx.coroutines.launch import timber.log.Timber private val loggerTag = LoggerTag("MainActivity") @@ -59,27 +65,13 @@ class MainActivity : NodeActivity() { installSplashScreen() super.onCreate(savedInstanceState) appBindings = bindings() - appBindings.lockScreenService().handleSecureFlag(this) + setupLockManagement(appBindings.lockScreenService(), appBindings.lockScreenEntryPoint()) enableEdgeToEdge() setContent { MainContent(appBindings) } } - @Deprecated("") - override fun onBackPressed() { - // If the app is locked, we need to intercept onBackPressed before it goes to OnBackPressedDispatcher. - // Indeed, otherwise we would need to trick Appyx backstack management everywhere. - // Without this trick, we would get pop operations on the hidden backstack. - if (appBindings.lockScreenService().isLocked) { - // Do not kill the app in this case, just go to background. - moveTaskToBack(false) - } else { - @Suppress("DEPRECATION") - super.onBackPressed() - } - } - @Composable private fun MainContent(appBindings: AppBindings) { val theme by remember { @@ -96,8 +88,8 @@ class MainActivity : NodeActivity() { ) { Box( modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.background), + .fillMaxSize() + .background(MaterialTheme.colorScheme.background), ) { if (migrationState.migrationAction.isSuccess()) { MainNodeHost() @@ -131,6 +123,22 @@ class MainActivity : NodeActivity() { } } + private fun setupLockManagement( + lockScreenService: LockScreenService, + lockScreenEntryPoint: LockScreenEntryPoint + ) { + lockScreenService.handleSecureFlag(this) + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.RESUMED) { + lockScreenService.lockState.collect { state -> + if (state == LockScreenLockState.Locked) { + startActivity(lockScreenEntryPoint.pinUnlockIntent(this@MainActivity)) + } + } + } + } + } + /** * Called when: * - the launcher icon is clicked (if the app is already running); 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 d8be841b97..bad34edfa0 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 @@ -18,6 +18,7 @@ package io.element.android.x.di import com.squareup.anvil.annotations.ContributesTo import io.element.android.features.api.MigrationEntryPoint +import io.element.android.features.lockscreen.api.LockScreenEntryPoint import io.element.android.features.lockscreen.api.LockScreenService import io.element.android.features.preferences.api.store.AppPreferencesStore import io.element.android.features.rageshake.api.reporter.BugReporter @@ -38,4 +39,6 @@ interface AppBindings { fun preferencesStore(): AppPreferencesStore fun migrationEntryPoint(): MigrationEntryPoint + + fun lockScreenEntryPoint(): LockScreenEntryPoint } diff --git a/app/src/main/kotlin/io/element/android/x/di/AppModule.kt b/app/src/main/kotlin/io/element/android/x/di/AppModule.kt index a121e0a6fb..3187cae410 100644 --- a/app/src/main/kotlin/io/element/android/x/di/AppModule.kt +++ b/app/src/main/kotlin/io/element/android/x/di/AppModule.kt @@ -26,6 +26,7 @@ import dagger.Provides import io.element.android.appconfig.ApplicationConfig import io.element.android.features.messages.impl.timeline.components.customreaction.DefaultEmojibaseProvider import io.element.android.features.messages.impl.timeline.components.customreaction.EmojibaseProvider +import io.element.android.libraries.androidutils.system.getVersionCodeFromManifest import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.core.meta.BuildType @@ -87,7 +88,7 @@ object AppModule { // TODO EAx Config.LOW_PRIVACY_LOG_ENABLE, lowPrivacyLoggingEnabled = false, versionName = BuildConfig.VERSION_NAME, - versionCode = BuildConfig.VERSION_CODE, + versionCode = context.getVersionCodeFromManifest(), gitRevision = BuildConfig.GIT_REVISION, gitBranchName = BuildConfig.GIT_BRANCH_NAME, flavorDescription = BuildConfig.FLAVOR_DESCRIPTION, diff --git a/app/src/main/kotlin/io/element/android/x/info/Logs.kt b/app/src/main/kotlin/io/element/android/x/info/Logs.kt index 3cabc937a1..53eaf8f824 100644 --- a/app/src/main/kotlin/io/element/android/x/info/Logs.kt +++ b/app/src/main/kotlin/io/element/android/x/info/Logs.kt @@ -16,17 +16,19 @@ package io.element.android.x.info +import android.content.Context +import io.element.android.libraries.androidutils.system.getVersionCodeFromManifest import io.element.android.x.BuildConfig import timber.log.Timber import java.text.SimpleDateFormat import java.util.Date import java.util.Locale -fun logApplicationInfo() { +fun logApplicationInfo(context: Context) { val appVersion = buildString { append(BuildConfig.VERSION_NAME) append(" (") - append(BuildConfig.VERSION_CODE) + append(context.getVersionCodeFromManifest()) append(") - ") append(BuildConfig.BUILD_TYPE) append(" / ") diff --git a/app/src/main/res/xml/locales_config.xml b/app/src/main/res/xml/locales_config.xml index 17e0192414..0c0f3a851d 100644 --- a/app/src/main/res/xml/locales_config.xml +++ b/app/src/main/res/xml/locales_config.xml @@ -10,10 +10,13 @@ + + + diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/MockkSummaryGroupMessageCreator.kt b/appconfig/src/main/kotlin/io/element/android/appconfig/MessageComposerConfig.kt similarity index 64% rename from libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/MockkSummaryGroupMessageCreator.kt rename to appconfig/src/main/kotlin/io/element/android/appconfig/MessageComposerConfig.kt index 8f99651c89..a927064f3d 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/MockkSummaryGroupMessageCreator.kt +++ b/appconfig/src/main/kotlin/io/element/android/appconfig/MessageComposerConfig.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2021 New Vector Ltd + * Copyright (c) 2024 New Vector Ltd * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,11 +14,11 @@ * limitations under the License. */ -package io.element.android.libraries.push.impl.notifications.fake +package io.element.android.appconfig -import io.element.android.libraries.push.impl.notifications.SummaryGroupMessageCreator -import io.mockk.mockk - -class MockkSummaryGroupMessageCreator { - val instance = mockk() +object MessageComposerConfig { + /** + * Enable the rich text editing in the composer. + */ + const val ENABLE_RICH_TEXT_EDITING = true } diff --git a/appconfig/src/main/kotlin/io/element/android/appconfig/NotificationConfig.kt b/appconfig/src/main/kotlin/io/element/android/appconfig/NotificationConfig.kt index 8d41a2a207..9ca4f73314 100644 --- a/appconfig/src/main/kotlin/io/element/android/appconfig/NotificationConfig.kt +++ b/appconfig/src/main/kotlin/io/element/android/appconfig/NotificationConfig.kt @@ -20,6 +20,9 @@ object NotificationConfig { // TODO EAx Implement and set to true at some point const val SUPPORT_MARK_AS_READ_ACTION = false + // TODO EAx Implement and set to true at some point + const val SUPPORT_JOIN_DECLINE_INVITE = false + // TODO EAx Implement and set to true at some point const val SUPPORT_QUICK_REPLY_ACTION = false } 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 9c8b29b356..032b4ce70e 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt @@ -37,6 +37,7 @@ import com.bumble.appyx.navmodel.backstack.operation.push import com.bumble.appyx.navmodel.backstack.operation.replace import dagger.assisted.Assisted import dagger.assisted.AssistedInject +import im.vector.app.features.analytics.plan.JoinedRoom import io.element.android.anvilannotations.ContributesNode import io.element.android.appnav.loggedin.LoggedInNode import io.element.android.appnav.room.RoomFlowNode @@ -46,9 +47,6 @@ import io.element.android.features.createroom.api.CreateRoomEntryPoint 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.lockscreen.api.LockScreenEntryPoint -import io.element.android.features.lockscreen.api.LockScreenLockState -import io.element.android.features.lockscreen.api.LockScreenService import io.element.android.features.networkmonitor.api.NetworkMonitor import io.element.android.features.networkmonitor.api.NetworkStatus import io.element.android.features.preferences.api.PreferencesEntryPoint @@ -99,8 +97,6 @@ class LoggedInFlowNode @AssistedInject constructor( private val coroutineScope: CoroutineScope, private val networkMonitor: NetworkMonitor, private val ftueService: FtueService, - private val lockScreenEntryPoint: LockScreenEntryPoint, - private val lockScreenStateService: LockScreenService, private val roomDirectoryEntryPoint: RoomDirectoryEntryPoint, private val matrixClient: MatrixClient, snackbarDispatcher: SnackbarDispatcher, @@ -110,7 +106,7 @@ class LoggedInFlowNode @AssistedInject constructor( savedStateMap = buildContext.savedStateMap, ), permanentNavModel = PermanentNavModel( - navTargets = setOf(NavTarget.LoggedInPermanent, NavTarget.LockPermanent), + navTargets = setOf(NavTarget.LoggedInPermanent), savedStateMap = buildContext.savedStateMap, ), buildContext = buildContext, @@ -188,15 +184,14 @@ class LoggedInFlowNode @AssistedInject constructor( @Parcelize data object LoggedInPermanent : NavTarget - @Parcelize - data object LockPermanent : NavTarget - @Parcelize data object RoomList : NavTarget @Parcelize data class Room( val roomIdOrAlias: RoomIdOrAlias, + val serverNames: List = emptyList(), + val trigger: JoinedRoom.Trigger? = null, val roomDescription: RoomDescription? = null, val initialElement: RoomNavigationTarget = RoomNavigationTarget.Messages() ) : NavTarget @@ -232,38 +227,33 @@ class LoggedInFlowNode @AssistedInject constructor( NavTarget.LoggedInPermanent -> { createNode(buildContext) } - NavTarget.LockPermanent -> { - lockScreenEntryPoint.nodeBuilder(this, buildContext) - .target(LockScreenEntryPoint.Target.Unlock) - .build() - } NavTarget.RoomList -> { val callback = object : RoomListEntryPoint.Callback { - override fun onRoomClicked(roomId: RoomId) { + override fun onRoomClick(roomId: RoomId) { backstack.push(NavTarget.Room(roomId.toRoomIdOrAlias())) } - override fun onSettingsClicked() { + override fun onSettingsClick() { backstack.push(NavTarget.Settings()) } - override fun onCreateRoomClicked() { + override fun onCreateRoomClick() { backstack.push(NavTarget.CreateRoom) } - override fun onSessionConfirmRecoveryKeyClicked() { + override fun onSessionConfirmRecoveryKeyClick() { backstack.push(NavTarget.SecureBackup(initialElement = SecureBackupEntryPoint.InitialTarget.EnterRecoveryKey)) } - override fun onRoomSettingsClicked(roomId: RoomId) { + override fun onRoomSettingsClick(roomId: RoomId) { backstack.push(NavTarget.Room(roomId.toRoomIdOrAlias(), initialElement = RoomNavigationTarget.Details)) } - override fun onReportBugClicked() { + override fun onReportBugClick() { plugins().forEach { it.onOpenBugReport() } } - override fun onRoomDirectorySearchClicked() { + override fun onRoomDirectorySearchClick() { backstack.push(NavTarget.RoomDirectorySearch) } } @@ -282,7 +272,7 @@ class LoggedInFlowNode @AssistedInject constructor( coroutineScope.launch { attachRoom(roomId.toRoomIdOrAlias()) } } - override fun onPermalinkClicked(data: PermalinkData) { + override fun onPermalinkClick(data: PermalinkData) { when (data) { is PermalinkData.UserLink -> { // Should not happen (handled by MessagesNode) @@ -292,8 +282,9 @@ class LoggedInFlowNode @AssistedInject constructor( backstack.push( NavTarget.Room( roomIdOrAlias = data.roomIdOrAlias, + serverNames = data.viaParameters, + trigger = JoinedRoom.Trigger.Timeline, initialElement = RoomNavigationTarget.Messages(data.eventId), - // TODO Use the viaParameters ) ) } @@ -311,6 +302,8 @@ class LoggedInFlowNode @AssistedInject constructor( val inputs = RoomFlowNode.Inputs( roomIdOrAlias = navTarget.roomIdOrAlias, roomDescription = Optional.ofNullable(navTarget.roomDescription), + serverNames = navTarget.serverNames, + trigger = Optional.ofNullable(navTarget.trigger), initialElement = navTarget.initialElement ) createNode(buildContext, plugins = listOf(inputs, callback)) @@ -332,7 +325,7 @@ class LoggedInFlowNode @AssistedInject constructor( plugins().forEach { it.onOpenBugReport() } } - override fun onSecureBackupClicked() { + override fun onSecureBackupClick() { backstack.push(NavTarget.SecureBackup()) } @@ -370,12 +363,14 @@ class LoggedInFlowNode @AssistedInject constructor( NavTarget.RoomDirectorySearch -> { roomDirectoryEntryPoint.nodeBuilder(this, buildContext) .callback(object : RoomDirectoryEntryPoint.Callback { - override fun onRoomJoined(roomId: RoomId) { - backstack.push(NavTarget.Room(roomId.toRoomIdOrAlias())) - } - - override fun onResultClicked(roomDescription: RoomDescription) { - backstack.push(NavTarget.Room(roomDescription.roomId.toRoomIdOrAlias(), roomDescription)) + override fun onResultClick(roomDescription: RoomDescription) { + backstack.push( + NavTarget.Room( + roomIdOrAlias = roomDescription.roomId.toRoomIdOrAlias(), + roomDescription = roomDescription, + trigger = JoinedRoom.Trigger.RoomDirectory, + ) + ) } }) .build() @@ -383,7 +378,12 @@ class LoggedInFlowNode @AssistedInject constructor( } } - suspend fun attachRoom(roomIdOrAlias: RoomIdOrAlias, eventId: EventId? = null) { + suspend fun attachRoom( + roomIdOrAlias: RoomIdOrAlias, + serverNames: List = emptyList(), + trigger: JoinedRoom.Trigger? = null, + eventId: EventId? = null, + ) { waitForNavTargetAttached { navTarget -> navTarget is NavTarget.RoomList } @@ -391,6 +391,8 @@ class LoggedInFlowNode @AssistedInject constructor( backstack.push( NavTarget.Room( roomIdOrAlias = roomIdOrAlias, + serverNames = serverNames, + trigger = trigger, initialElement = RoomNavigationTarget.Messages( focusedEventId = eventId ) @@ -415,15 +417,11 @@ class LoggedInFlowNode @AssistedInject constructor( @Composable override fun View(modifier: Modifier) { Box(modifier = modifier) { - val lockScreenState by lockScreenStateService.lockState.collectAsState() val ftueState by ftueService.state.collectAsState() BackstackView() if (ftueState is FtueState.Complete) { PermanentChild(permanentNavModel = permanentNavModel, navTarget = NavTarget.LoggedInPermanent) } - if (lockScreenState == LockScreenLockState.Locked) { - PermanentChild(permanentNavModel = permanentNavModel, navTarget = NavTarget.LockPermanent) - } } } 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 db04345a4e..4b886d9c99 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt @@ -34,6 +34,7 @@ import com.bumble.appyx.navmodel.backstack.operation.pop import com.bumble.appyx.navmodel.backstack.operation.push import dagger.assisted.Assisted import dagger.assisted.AssistedInject +import im.vector.app.features.analytics.plan.JoinedRoom import io.element.android.anvilannotations.ContributesNode import io.element.android.appnav.di.MatrixClientsHolder import io.element.android.appnav.intent.IntentResolver @@ -295,6 +296,8 @@ class RootFlowNode @AssistedInject constructor( is PermalinkData.RoomLink -> { attachRoom( roomIdOrAlias = permalinkData.roomIdOrAlias, + trigger = JoinedRoom.Trigger.MobilePermalink, + serverNames = permalinkData.viaParameters, eventId = permalinkData.eventId, ) } diff --git a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInPresenter.kt b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInPresenter.kt index 313f4aafe0..6362695772 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInPresenter.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInPresenter.kt @@ -36,6 +36,7 @@ import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatu import io.element.android.libraries.push.api.PushService import io.element.android.services.analytics.api.AnalyticsService import kotlinx.coroutines.flow.map +import timber.log.Timber import javax.inject.Inject class LoggedInPresenter @Inject constructor( @@ -55,10 +56,26 @@ class LoggedInPresenter @Inject constructor( LaunchedEffect(isVerified) { if (isVerified) { // Ensure pusher is registered - // TODO Manually select push provider for now - val pushProvider = pushService.getAvailablePushProviders().firstOrNull() ?: return@LaunchedEffect - val distributor = pushProvider.getDistributors().firstOrNull() ?: return@LaunchedEffect - pushService.registerWith(matrixClient, pushProvider, distributor) + val currentPushProvider = pushService.getCurrentPushProvider() + val result = if (currentPushProvider == null) { + // Register with the first available push provider + val pushProvider = pushService.getAvailablePushProviders().firstOrNull() ?: return@LaunchedEffect + val distributor = pushProvider.getDistributors().firstOrNull() ?: return@LaunchedEffect + pushService.registerWith(matrixClient, pushProvider, distributor) + } else { + val currentPushDistributor = currentPushProvider.getCurrentDistributor(matrixClient) + if (currentPushDistributor == null) { + // Register with the first available distributor + val distributor = currentPushProvider.getDistributors().firstOrNull() ?: return@LaunchedEffect + pushService.registerWith(matrixClient, currentPushProvider, distributor) + } else { + // Re-register with the current distributor + pushService.registerWith(matrixClient, currentPushProvider, currentPushDistributor) + } + } + result.onFailure { + Timber.e(it, "Failed to register pusher") + } } } 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 800b7037cf..7133fae9f0 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 @@ -32,6 +32,7 @@ import com.bumble.appyx.navmodel.backstack.BackStack import com.bumble.appyx.navmodel.backstack.operation.newRoot import dagger.assisted.Assisted import dagger.assisted.AssistedInject +import im.vector.app.features.analytics.plan.JoinedRoom import io.element.android.anvilannotations.ContributesNode import io.element.android.appnav.room.joined.JoinedRoomFlowNode import io.element.android.appnav.room.joined.JoinedRoomLoadedFlowNode @@ -54,6 +55,7 @@ import io.element.android.libraries.matrix.api.core.RoomAlias import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomIdOrAlias import io.element.android.libraries.matrix.api.room.CurrentUserMembership +import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.launchIn @@ -83,6 +85,8 @@ class RoomFlowNode @AssistedInject constructor( data class Inputs( val roomIdOrAlias: RoomIdOrAlias, val roomDescription: Optional, + val serverNames: List, + val trigger: Optional, val initialElement: RoomNavigationTarget, ) : NodeInputs @@ -96,7 +100,11 @@ class RoomFlowNode @AssistedInject constructor( data class Resolving(val roomAlias: RoomAlias) : NavTarget @Parcelize - data class JoinRoom(val roomId: RoomId) : NavTarget + data class JoinRoom( + val roomId: RoomId, + val serverNames: List, + val trigger: im.vector.app.features.analytics.plan.JoinedRoom.Trigger, + ) : NavTarget @Parcelize data class JoinedRoom(val roomId: RoomId) : NavTarget @@ -114,13 +122,13 @@ class RoomFlowNode @AssistedInject constructor( backstack.newRoot(NavTarget.Resolving(i.roomAlias)) } is RoomIdOrAlias.Id -> { - subscribeToRoomInfoFlow(i.roomId) + subscribeToRoomInfoFlow(i.roomId, inputs.serverNames) } } } } - private fun subscribeToRoomInfoFlow(roomId: RoomId) { + private fun subscribeToRoomInfoFlow(roomId: RoomId, serverNames: List) { val roomInfoFlow = client.getRoomInfoFlow( roomId = roomId ).map { it.getOrNull() } @@ -136,7 +144,13 @@ class RoomFlowNode @AssistedInject constructor( // we can have a space here in case the space has just been joined. // So navigate to the JoinRoom target for now, which will // handle the space not supported screen - backstack.newRoot(NavTarget.JoinRoom(roomId)) + backstack.newRoot( + NavTarget.JoinRoom( + roomId = roomId, + serverNames = serverNames, + trigger = inputs.trigger.getOrNull() ?: JoinedRoom.Trigger.Invite, + ) + ) } else { backstack.newRoot(NavTarget.JoinedRoom(roomId)) } @@ -147,7 +161,13 @@ class RoomFlowNode @AssistedInject constructor( } else -> { // Was invited or the room is not known, display the join room screen - backstack.newRoot(NavTarget.JoinRoom(roomId)) + backstack.newRoot( + NavTarget.JoinRoom( + roomId = roomId, + serverNames = serverNames, + trigger = inputs.trigger.getOrNull() ?: JoinedRoom.Trigger.Invite, + ) + ) } } }.launchIn(lifecycleScope) @@ -158,8 +178,11 @@ class RoomFlowNode @AssistedInject constructor( is NavTarget.Loading -> loadingNode(buildContext) is NavTarget.Resolving -> { val callback = object : RoomAliasResolverEntryPoint.Callback { - override fun onAliasResolved(roomId: RoomId) { - subscribeToRoomInfoFlow(roomId) + override fun onAliasResolved(data: ResolvedRoomAlias) { + subscribeToRoomInfoFlow( + roomId = data.roomId, + serverNames = data.servers, + ) } } val params = RoomAliasResolverEntryPoint.Params(navTarget.roomAlias) @@ -173,6 +196,8 @@ class RoomFlowNode @AssistedInject constructor( roomId = navTarget.roomId, roomIdOrAlias = inputs.roomIdOrAlias, roomDescription = inputs.roomDescription, + serverNames = navTarget.serverNames, + trigger = navTarget.trigger, ) joinRoomEntryPoint.createNode(this, buildContext, inputs) } @@ -192,7 +217,7 @@ class RoomFlowNode @AssistedInject constructor( LoadingRoomNodeView( state = LoadingRoomState.Loading, hasNetworkConnection = networkStatus == NetworkStatus.Online, - onBackClicked = { navigateUp() }, + onBackClick = { navigateUp() }, modifier = modifier, ) } diff --git a/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomFlowNode.kt index 49bcb53048..6adb371fdc 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/room/joined/JoinedRoomFlowNode.kt @@ -121,14 +121,14 @@ class JoinedRoomFlowNode @AssistedInject constructor( } } - private fun loadingNode(buildContext: BuildContext, onBackClicked: () -> Unit) = node(buildContext) { modifier -> + private fun loadingNode(buildContext: BuildContext, onBackClick: () -> Unit) = node(buildContext) { modifier -> val loadingRoomState by loadingRoomStateStateFlow.collectAsState() val networkStatus by networkMonitor.connectivity.collectAsState() LoadingRoomNodeView( state = loadingRoomState, hasNetworkConnection = networkStatus == NetworkStatus.Online, modifier = modifier, - onBackClicked = onBackClicked + onBackClick = onBackClick ) } 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 142b658e5e..915001c919 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 @@ -77,7 +77,7 @@ class JoinedRoomLoadedFlowNode @AssistedInject constructor( ), DaggerComponentOwner { interface Callback : Plugin { fun onOpenRoom(roomId: RoomId) - fun onPermalinkClicked(data: PermalinkData) + fun onPermalinkClick(data: PermalinkData) fun onForwardedToSingleRoom(roomId: RoomId) fun onOpenGlobalNotificationSettings() } @@ -144,16 +144,16 @@ class JoinedRoomLoadedFlowNode @AssistedInject constructor( return when (navTarget) { is NavTarget.Messages -> { val callback = object : MessagesEntryPoint.Callback { - override fun onRoomDetailsClicked() { + override fun onRoomDetailsClick() { backstack.push(NavTarget.RoomDetails) } - override fun onUserDataClicked(userId: UserId) { + override fun onUserDataClick(userId: UserId) { backstack.push(NavTarget.RoomMemberDetails(userId)) } - override fun onPermalinkClicked(data: PermalinkData) { - callbacks.forEach { it.onPermalinkClicked(data) } + override fun onPermalinkClick(data: PermalinkData) { + callbacks.forEach { it.onPermalinkClick(data) } } override fun onForwardedToSingleRoom(roomId: RoomId) { diff --git a/appnav/src/main/kotlin/io/element/android/appnav/room/joined/LoadingRoomNodeView.kt b/appnav/src/main/kotlin/io/element/android/appnav/room/joined/LoadingRoomNodeView.kt index 14fb955cb5..d85ccfa398 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/room/joined/LoadingRoomNodeView.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/room/joined/LoadingRoomNodeView.kt @@ -46,7 +46,7 @@ import io.element.android.libraries.ui.strings.CommonStrings fun LoadingRoomNodeView( state: LoadingRoomState, hasNetworkConnection: Boolean, - onBackClicked: () -> Unit, + onBackClick: () -> Unit, modifier: Modifier = Modifier, ) { Scaffold( @@ -54,7 +54,7 @@ fun LoadingRoomNodeView( topBar = { Column { ConnectivityIndicatorView(isOnline = hasNetworkConnection) - LoadingRoomTopBar(onBackClicked) + LoadingRoomTopBar(onBackClick) } }, content = { padding -> @@ -83,11 +83,11 @@ fun LoadingRoomNodeView( @OptIn(ExperimentalMaterial3Api::class) @Composable private fun LoadingRoomTopBar( - onBackClicked: () -> Unit, + onBackClick: () -> Unit, ) { TopAppBar( navigationIcon = { - BackButton(onClick = onBackClicked) + BackButton(onClick = onBackClick) }, title = { IconTitlePlaceholdersRowMolecule(iconSize = AvatarSize.TimelineRoom.dp) @@ -101,7 +101,7 @@ private fun LoadingRoomTopBar( internal fun LoadingRoomNodeViewPreview(@PreviewParameter(LoadingRoomStateProvider::class) state: LoadingRoomState) = ElementPreview { LoadingRoomNodeView( state = state, - onBackClicked = {}, + onBackClick = {}, hasNetworkConnection = false ) } diff --git a/appnav/src/test/kotlin/io/element/android/appnav/JoinRoomLoadedFlowNodeTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/JoinRoomLoadedFlowNodeTest.kt index 93e727da75..d307f851cd 100644 --- a/appnav/src/test/kotlin/io/element/android/appnav/JoinRoomLoadedFlowNodeTest.kt +++ b/appnav/src/test/kotlin/io/element/android/appnav/JoinRoomLoadedFlowNodeTest.kt @@ -156,7 +156,7 @@ class JoinRoomLoadedFlowNodeTest { ) val roomFlowNodeTestHelper = roomFlowNode.parentNodeTestHelper() // WHEN - fakeMessagesEntryPoint.callback?.onRoomDetailsClicked() + fakeMessagesEntryPoint.callback?.onRoomDetailsClick() // THEN roomFlowNodeTestHelper.assertChildHasLifecycle(JoinedRoomLoadedFlowNode.NavTarget.RoomDetails, Lifecycle.State.CREATED) val roomDetailsNode = roomFlowNode.childNode(JoinedRoomLoadedFlowNode.NavTarget.RoomDetails)!! diff --git a/appnav/src/test/kotlin/io/element/android/appnav/di/MatrixClientsHolderTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/di/MatrixClientsHolderTest.kt index 6dce8a6310..4fb518b058 100644 --- a/appnav/src/test/kotlin/io/element/android/appnav/di/MatrixClientsHolderTest.kt +++ b/appnav/src/test/kotlin/io/element/android/appnav/di/MatrixClientsHolderTest.kt @@ -20,21 +20,21 @@ import com.bumble.appyx.core.state.MutableSavedStateMapImpl import com.google.common.truth.Truth.assertThat 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.auth.FakeAuthenticationService +import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService import kotlinx.coroutines.test.runTest import org.junit.Test class MatrixClientsHolderTest { @Test fun `test getOrNull`() { - val fakeAuthenticationService = FakeAuthenticationService() + val fakeAuthenticationService = FakeMatrixAuthenticationService() val matrixClientsHolder = MatrixClientsHolder(fakeAuthenticationService) assertThat(matrixClientsHolder.getOrNull(A_SESSION_ID)).isNull() } @Test fun `test getOrRestore`() = runTest { - val fakeAuthenticationService = FakeAuthenticationService() + val fakeAuthenticationService = FakeMatrixAuthenticationService() val matrixClientsHolder = MatrixClientsHolder(fakeAuthenticationService) val fakeMatrixClient = FakeMatrixClient() fakeAuthenticationService.givenMatrixClient(fakeMatrixClient) @@ -47,7 +47,7 @@ class MatrixClientsHolderTest { @Test fun `test remove`() = runTest { - val fakeAuthenticationService = FakeAuthenticationService() + val fakeAuthenticationService = FakeMatrixAuthenticationService() val matrixClientsHolder = MatrixClientsHolder(fakeAuthenticationService) val fakeMatrixClient = FakeMatrixClient() fakeAuthenticationService.givenMatrixClient(fakeMatrixClient) @@ -60,7 +60,7 @@ class MatrixClientsHolderTest { @Test fun `test remove all`() = runTest { - val fakeAuthenticationService = FakeAuthenticationService() + val fakeAuthenticationService = FakeMatrixAuthenticationService() val matrixClientsHolder = MatrixClientsHolder(fakeAuthenticationService) val fakeMatrixClient = FakeMatrixClient() fakeAuthenticationService.givenMatrixClient(fakeMatrixClient) @@ -73,7 +73,7 @@ class MatrixClientsHolderTest { @Test fun `test save and restore`() = runTest { - val fakeAuthenticationService = FakeAuthenticationService() + val fakeAuthenticationService = FakeMatrixAuthenticationService() val matrixClientsHolder = MatrixClientsHolder(fakeAuthenticationService) val fakeMatrixClient = FakeMatrixClient() fakeAuthenticationService.givenMatrixClient(fakeMatrixClient) diff --git a/appnav/src/test/kotlin/io/element/android/appnav/intent/IntentResolverTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/intent/IntentResolverTest.kt index 074009cacc..0a0b507742 100644 --- a/appnav/src/test/kotlin/io/element/android/appnav/intent/IntentResolverTest.kt +++ b/appnav/src/test/kotlin/io/element/android/appnav/intent/IntentResolverTest.kt @@ -33,6 +33,7 @@ import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_THREAD_ID import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser +import io.element.android.tests.testutils.lambda.lambdaError import org.junit.Assert.assertThrows import org.junit.Test import org.junit.runner.RunWith @@ -229,7 +230,7 @@ class IntentResolverTest { } private fun createIntentResolver( - permalinkParserResult: () -> PermalinkData = { throw NotImplementedError() } + permalinkParserResult: () -> PermalinkData = { lambdaError() } ): IntentResolver { return IntentResolver( deeplinkParser = DeeplinkParser(), diff --git a/build.gradle.kts b/build.gradle.kts index a0eedc8db7..869bae5f8b 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -60,7 +60,7 @@ allprojects { config.from(files("$rootDir/tools/detekt/detekt.yml")) } dependencies { - detektPlugins("io.nlopez.compose.rules:detekt:0.3.12") + detektPlugins("io.nlopez.compose.rules:detekt:0.4.3") } // KtLint diff --git a/changelog.d/2718.bugfix b/changelog.d/2718.bugfix deleted file mode 100644 index 999d47b59b..0000000000 --- a/changelog.d/2718.bugfix +++ /dev/null @@ -1 +0,0 @@ -Fix session verification being asked again for already verified users. diff --git a/changelog.d/2721.misc b/changelog.d/2721.misc deleted file mode 100644 index 2c38dc4ac7..0000000000 --- a/changelog.d/2721.misc +++ /dev/null @@ -1 +0,0 @@ -RoomMember screen: fallback to userProfile data, if the member is not a user of the room. diff --git a/changelog.d/2740.bugfix b/changelog.d/2740.bugfix deleted file mode 100644 index 21aee0b66b..0000000000 --- a/changelog.d/2740.bugfix +++ /dev/null @@ -1 +0,0 @@ -Instead of displaying 'create new recovery key' on the session verification screen when there is no other session active, display it always under the 'enter recovery key' screen. diff --git a/changelog.d/2749.misc b/changelog.d/2749.misc deleted file mode 100644 index 95f60d7085..0000000000 --- a/changelog.d/2749.misc +++ /dev/null @@ -1 +0,0 @@ -Migrate application data. diff --git a/changelog.d/2754.feature b/changelog.d/2754.feature deleted file mode 100644 index 85880c7adc..0000000000 --- a/changelog.d/2754.feature +++ /dev/null @@ -1 +0,0 @@ -Add support for expected decryption errors due to membership (UX and analytics). diff --git a/changelog.d/2758.misc b/changelog.d/2758.misc deleted file mode 100644 index 1e3d894f79..0000000000 --- a/changelog.d/2758.misc +++ /dev/null @@ -1 +0,0 @@ - Let the SDK manage the file log cleanup, and keep one week of log. diff --git a/changelog.d/2759.feature b/changelog.d/2759.feature deleted file mode 100644 index bdd2c3ba77..0000000000 --- a/changelog.d/2759.feature +++ /dev/null @@ -1 +0,0 @@ -Handle permalink navigation to Events. diff --git a/changelog.d/2760.bugfix b/changelog.d/2760.bugfix deleted file mode 100644 index f74383d596..0000000000 --- a/changelog.d/2760.bugfix +++ /dev/null @@ -1 +0,0 @@ -Adjust the typography used in the selected user component so a user's display name fits better. diff --git a/changelog.d/2761.bugfix b/changelog.d/2761.bugfix deleted file mode 100644 index d5afb55597..0000000000 --- a/changelog.d/2761.bugfix +++ /dev/null @@ -1 +0,0 @@ -User display name overflows in timeline messages when it's way too long. diff --git a/changelog.d/2771.feature b/changelog.d/2771.feature deleted file mode 100644 index 2bda11c84c..0000000000 --- a/changelog.d/2771.feature +++ /dev/null @@ -1 +0,0 @@ -Pretty-print event JSON in debug viewer diff --git a/changelog.d/2776.feature b/changelog.d/2776.feature deleted file mode 100644 index 8eec28ef80..0000000000 --- a/changelog.d/2776.feature +++ /dev/null @@ -1 +0,0 @@ -Add support for external permalinks. diff --git a/changelog.d/2778.bugfix b/changelog.d/2778.bugfix deleted file mode 100644 index 9353dda39f..0000000000 --- a/changelog.d/2778.bugfix +++ /dev/null @@ -1 +0,0 @@ -Ensure the application open the room when a notification is clicked. diff --git a/changelog.d/2795.feature b/changelog.d/2795.feature deleted file mode 100644 index 4dec51f765..0000000000 --- a/changelog.d/2795.feature +++ /dev/null @@ -1 +0,0 @@ -Enable support for Android per-app language preferences diff --git a/changelog.d/2801.misc b/changelog.d/2801.misc deleted file mode 100644 index 571d0296e0..0000000000 --- a/changelog.d/2801.misc +++ /dev/null @@ -1 +0,0 @@ -UX cleanup: reorder options in the main settings screen. diff --git a/changelog.d/2806.misc b/changelog.d/2806.misc deleted file mode 100644 index 5f6f53667b..0000000000 --- a/changelog.d/2806.misc +++ /dev/null @@ -1 +0,0 @@ -Analytics: Add support to report current session verification and recovery state diff --git a/changelog.d/2809.bugfix b/changelog.d/2809.bugfix new file mode 100644 index 0000000000..70e3079686 --- /dev/null +++ b/changelog.d/2809.bugfix @@ -0,0 +1 @@ +Render selected/deselected room list filters on top diff --git a/changelog.d/2810.bugfix b/changelog.d/2810.bugfix deleted file mode 100644 index d6683a221a..0000000000 --- a/changelog.d/2810.bugfix +++ /dev/null @@ -1 +0,0 @@ -Enforce mandatory session verification only for new logins. diff --git a/changelog.d/2814.misc b/changelog.d/2814.misc deleted file mode 100644 index 14fa36637f..0000000000 --- a/changelog.d/2814.misc +++ /dev/null @@ -1 +0,0 @@ -UX cleanup: room details screen, add new CTA buttons for Invite and Call actions. diff --git a/changelog.d/2818.misc b/changelog.d/2818.misc deleted file mode 100644 index 6146c2ca82..0000000000 --- a/changelog.d/2818.misc +++ /dev/null @@ -1 +0,0 @@ -UX cleanup: user profile. Move send DM to a call to action button, add 'Call' CTA too. diff --git a/changelog.d/2822.misc b/changelog.d/2822.misc deleted file mode 100644 index 5c83de91fe..0000000000 --- a/changelog.d/2822.misc +++ /dev/null @@ -1 +0,0 @@ -Add room badges to room details screen. diff --git a/changelog.d/2825.bugfix b/changelog.d/2825.bugfix deleted file mode 100644 index 786464f40d..0000000000 --- a/changelog.d/2825.bugfix +++ /dev/null @@ -1 +0,0 @@ -Make log less verbose, make sure we upload as many log files as possible before reaching the request size limit of the bug reporting service, discard older logs if they don't fit. diff --git a/changelog.d/2827.bugfix b/changelog.d/2827.bugfix deleted file mode 100644 index 47539e279d..0000000000 --- a/changelog.d/2827.bugfix +++ /dev/null @@ -1 +0,0 @@ -Remove 'Join' button in room directory search results. diff --git a/changelog.d/2829.bugfix b/changelog.d/2829.bugfix deleted file mode 100644 index 203af1f361..0000000000 --- a/changelog.d/2829.bugfix +++ /dev/null @@ -1 +0,0 @@ -Add missing `app_id` and `Version` properties to bug reports. diff --git a/changelog.d/2893.misc b/changelog.d/2893.misc new file mode 100644 index 0000000000..ee122ae436 --- /dev/null +++ b/changelog.d/2893.misc @@ -0,0 +1 @@ +BugReporting | Add public device keys to rageshakes diff --git a/changelog.d/2896.bugfix b/changelog.d/2896.bugfix new file mode 100644 index 0000000000..ac1c95b47b --- /dev/null +++ b/changelog.d/2896.bugfix @@ -0,0 +1 @@ +Set auto captilization, multiline and autocompletion flags for the markdown EditText. diff --git a/changelog.d/2898.bugfix b/changelog.d/2898.bugfix new file mode 100644 index 0000000000..74dbdb7b85 --- /dev/null +++ b/changelog.d/2898.bugfix @@ -0,0 +1 @@ +Restoree Markdown text input contents when returning to the room screen. diff --git a/changelog.d/2912.misc b/changelog.d/2912.misc new file mode 100644 index 0000000000..c9e5ea0ae2 --- /dev/null +++ b/changelog.d/2912.misc @@ -0,0 +1 @@ +Move push provider setting to the "Notifications" screen and display it only when several push provider are available. diff --git a/changelog.d/2917.bugfix b/changelog.d/2917.bugfix new file mode 100644 index 0000000000..cc810393fc --- /dev/null +++ b/changelog.d/2917.bugfix @@ -0,0 +1 @@ +Fixed sending rich content from android keyboards on the markdown text input diff --git a/changelog.d/2924.misc b/changelog.d/2924.misc new file mode 100644 index 0000000000..c43093e0fa --- /dev/null +++ b/changelog.d/2924.misc @@ -0,0 +1,3 @@ +Simplify notifications by removing the custom persistence layer. + +Bump minSdk to 24 (Android 7). diff --git a/changelog.d/2930.misc b/changelog.d/2930.misc new file mode 100644 index 0000000000..dd04ffcffc --- /dev/null +++ b/changelog.d/2930.misc @@ -0,0 +1 @@ +Add a feature flag ShowBlockedUsersDetails, disabled by default to render display name and avatar of blocked users in the blocked users list. diff --git a/fastlane/metadata/android/en-US/changelogs/40004120.txt b/fastlane/metadata/android/en-US/changelogs/40004120.txt new file mode 100644 index 0000000000..6ecb5ad718 --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/40004120.txt @@ -0,0 +1,10 @@ +Main changes in this version: + +- Added support for opening matrix URLs inside the app and navigating to replied to messages. +- Added per-app language support for Android 13+. +- Session verification is no longer mandatory for already logged in users. +- Better log handling. +- Fixed CVE-2024-34353 / GHSA-9ggc-845v-gcgv. +- UX improvements. + +Full changelog: https://github.com/element-hq/element-x-android/releases \ No newline at end of file diff --git a/fastlane/metadata/android/en-US/changelogs/40004130.txt b/fastlane/metadata/android/en-US/changelogs/40004130.txt new file mode 100644 index 0000000000..a00be64eae --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/40004130.txt @@ -0,0 +1,2 @@ +Main changes in this version: Add plain text editor based on Markdown input. +Full changelog: https://github.com/element-hq/element-x-android/releases diff --git a/features/analytics/api/src/main/res/values-ka/translations.xml b/features/analytics/api/src/main/res/values-ka/translations.xml new file mode 100644 index 0000000000..fa12b9cc94 --- /dev/null +++ b/features/analytics/api/src/main/res/values-ka/translations.xml @@ -0,0 +1,7 @@ + + + "გააზიარეთ ანონიმური გამოყენების მონაცემები, რათა დაგვეხმაროთ პრობლემების იდენტიფიცირებაში." + "შეგიძლიათ, წაიკითხოთ ჩვენი ყველა პირობა %1$s." + "აქ" + "გააზიარეთ ანალიტიკური მონაცემები" + diff --git a/features/analytics/api/src/main/res/values-pt-rBR/translations.xml b/features/analytics/api/src/main/res/values-pt-rBR/translations.xml new file mode 100644 index 0000000000..434df0b892 --- /dev/null +++ b/features/analytics/api/src/main/res/values-pt-rBR/translations.xml @@ -0,0 +1,7 @@ + + + "Partilhe dados de utilização anónimos para nos ajudar a identificar problemas." + "Podes ler todos os nossos termos %1$s." + "aqui" + "Partilhar dados de utilização" + diff --git a/features/analytics/api/src/main/res/values-zh/translations.xml b/features/analytics/api/src/main/res/values-zh/translations.xml new file mode 100644 index 0000000000..e5f9fccd66 --- /dev/null +++ b/features/analytics/api/src/main/res/values-zh/translations.xml @@ -0,0 +1,7 @@ + + + "共享匿名使用数据以帮助我们排查问题。" + "您可以阅读我们的所有条款 %1$s。" + "此处" + "共享分析数据" + diff --git a/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInView.kt b/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInView.kt index a2290619ae..73fe9e083a 100644 --- a/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInView.kt +++ b/features/analytics/impl/src/main/kotlin/io/element/android/features/analytics/impl/AnalyticsOptInView.kt @@ -63,15 +63,15 @@ fun AnalyticsOptInView( ) { val eventSink = state.eventSink - fun onTermsAccepted() { + fun onAcceptTerms() { eventSink(AnalyticsOptInEvents.EnableAnalytics(true)) } - fun onTermsDeclined() { + fun onDeclineTerms() { eventSink(AnalyticsOptInEvents.EnableAnalytics(false)) } - BackHandler(onBack = ::onTermsDeclined) + BackHandler(onBack = ::onDeclineTerms) HeaderFooterPage( modifier = modifier .fillMaxSize() @@ -82,8 +82,8 @@ fun AnalyticsOptInView( content = { AnalyticsOptInContent() }, footer = { AnalyticsOptInFooter( - onTermsAccepted = ::onTermsAccepted, - onTermsDeclined = ::onTermsDeclined, + onAcceptTerms = ::onAcceptTerms, + onDeclineTerms = ::onDeclineTerms, ) } ) @@ -165,19 +165,19 @@ private fun AnalyticsOptInContent() { @Composable private fun AnalyticsOptInFooter( - onTermsAccepted: () -> Unit, - onTermsDeclined: () -> Unit, + onAcceptTerms: () -> Unit, + onDeclineTerms: () -> Unit, ) { ButtonColumnMolecule { Button( text = stringResource(id = CommonStrings.action_ok), - onClick = onTermsAccepted, + onClick = onAcceptTerms, modifier = Modifier.fillMaxWidth(), ) TextButton( text = stringResource(id = CommonStrings.action_not_now), size = ButtonSize.Medium, - onClick = onTermsDeclined, + onClick = onDeclineTerms, modifier = Modifier.fillMaxWidth(), ) } diff --git a/features/analytics/impl/src/main/res/values-ka/translations.xml b/features/analytics/impl/src/main/res/values-ka/translations.xml new file mode 100644 index 0000000000..fb561f4442 --- /dev/null +++ b/features/analytics/impl/src/main/res/values-ka/translations.xml @@ -0,0 +1,10 @@ + + + "ჩვენ არ ჩავწერთ და არ დავაფიქსირებთ პერსონალურ მონაცემებს" + "გააზიარეთ ანონიმური გამოყენების მონაცემები, რათა დაგვეხმაროთ პრობლემების იდენტიფიცირებაში." + "შეგიძლიათ, წაიკითხოთ ჩვენი ყველა პირობა %1$s." + "აქ" + "ამის გამორთვა ნებისმიერ დროს შეგიძლიათ" + "თქვენს მონაცემებს მესამე პირს არ გადავცემთ" + "დაგვეხმარეთ, გავაუმჯობესოთ %1$s" + diff --git a/features/analytics/impl/src/main/res/values-pt-rBR/translations.xml b/features/analytics/impl/src/main/res/values-pt-rBR/translations.xml new file mode 100644 index 0000000000..1a837d6313 --- /dev/null +++ b/features/analytics/impl/src/main/res/values-pt-rBR/translations.xml @@ -0,0 +1,10 @@ + + + "Não recolheremos ou analisaremos quaisquer dados pessoais" + "Partilhe dados de utilização anónimos para nos ajudar a identificar problemas." + "Podes ler todos os nossos termos %1$s." + "aqui" + "Podes desligar qualquer momento" + "Não partilharemos os teus dados com terceiros" + "Ajude a melhorar a %1$s" + diff --git a/features/analytics/impl/src/main/res/values-zh/translations.xml b/features/analytics/impl/src/main/res/values-zh/translations.xml new file mode 100644 index 0000000000..887930eac7 --- /dev/null +++ b/features/analytics/impl/src/main/res/values-zh/translations.xml @@ -0,0 +1,10 @@ + + + "我们不会记录或分析任何个人数据" + "共享匿名使用数据以帮助我们排查问题。" + "您可以阅读我们的所有条款 %1$s。" + "此处" + "你可以随时关闭此功能" + "我们不会与第三方共享您的数据" + "帮助改进 %1$s" + diff --git a/features/call/src/main/kotlin/io/element/android/features/call/CallForegroundService.kt b/features/call/src/main/kotlin/io/element/android/features/call/CallForegroundService.kt index 5f12ace0ac..10168eba2f 100644 --- a/features/call/src/main/kotlin/io/element/android/features/call/CallForegroundService.kt +++ b/features/call/src/main/kotlin/io/element/android/features/call/CallForegroundService.kt @@ -72,15 +72,10 @@ class CallForegroundService : Service() { startForeground(1, notification) } - @Suppress("DEPRECATION") override fun onDestroy() { super.onDestroy() - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - stopForeground(STOP_FOREGROUND_REMOVE) - } else { - stopForeground(true) - } + stopForeground(STOP_FOREGROUND_REMOVE) } override fun onBind(intent: Intent?): IBinder? { diff --git a/features/call/src/main/kotlin/io/element/android/features/call/ui/CallScreenView.kt b/features/call/src/main/kotlin/io/element/android/features/call/ui/CallScreenView.kt index c3f157a876..cc62ce03e1 100644 --- a/features/call/src/main/kotlin/io/element/android/features/call/ui/CallScreenView.kt +++ b/features/call/src/main/kotlin/io/element/android/features/call/ui/CallScreenView.kt @@ -81,12 +81,12 @@ internal fun CallScreenView( .fillMaxSize(), url = state.urlState, userAgent = state.userAgent, - onPermissionsRequested = { request -> + onPermissionsRequest = { request -> val androidPermissions = mapWebkitPermissions(request.resources) val callback: RequestPermissionCallback = { request.grant(it) } requestPermissions(androidPermissions.toTypedArray(), callback) }, - onWebViewCreated = { webView -> + onWebViewCreate = { webView -> val interceptor = WebViewWidgetMessageInterceptor(webView) state.eventSink(CallScreenEvents.SetupMessageChannels(interceptor)) } @@ -98,8 +98,8 @@ internal fun CallScreenView( private fun CallWebView( url: AsyncData, userAgent: String, - onPermissionsRequested: (PermissionRequest) -> Unit, - onWebViewCreated: (WebView) -> Unit, + onPermissionsRequest: (PermissionRequest) -> Unit, + onWebViewCreate: (WebView) -> Unit, modifier: Modifier = Modifier, ) { if (LocalInspectionMode.current) { @@ -111,8 +111,8 @@ private fun CallWebView( modifier = modifier, factory = { context -> WebView(context).apply { - onWebViewCreated(this) - setup(userAgent, onPermissionsRequested) + onWebViewCreate(this) + setup(userAgent, onPermissionsRequest) } }, update = { webView -> diff --git a/features/call/src/main/res/values-ka/translations.xml b/features/call/src/main/res/values-ka/translations.xml new file mode 100644 index 0000000000..755bb2402b --- /dev/null +++ b/features/call/src/main/res/values-ka/translations.xml @@ -0,0 +1,6 @@ + + + "მიმდინარე ზარი" + "დააწკაპუნეთ ზარში დასაბრუნებლად" + "☎️ ზარი მიმდინარეობს" + diff --git a/features/call/src/main/res/values-pt-rBR/translations.xml b/features/call/src/main/res/values-pt-rBR/translations.xml new file mode 100644 index 0000000000..87f439ace5 --- /dev/null +++ b/features/call/src/main/res/values-pt-rBR/translations.xml @@ -0,0 +1,6 @@ + + + "Chamada em curso" + "Toca para voltar à chamada" + "☎️ Chamada em curso" + diff --git a/features/call/src/main/res/values-zh/translations.xml b/features/call/src/main/res/values-zh/translations.xml new file mode 100644 index 0000000000..4d57257d19 --- /dev/null +++ b/features/call/src/main/res/values-zh/translations.xml @@ -0,0 +1,6 @@ + + + "通话进行中" + "点按即可返回通话" + "☎️ 通话中" + diff --git a/features/call/src/test/kotlin/io/element/android/features/call/ui/CallScreenPresenterTest.kt b/features/call/src/test/kotlin/io/element/android/features/call/ui/CallScreenPresenterTest.kt index 8eecc94e78..a9ddb1bf6b 100644 --- a/features/call/src/test/kotlin/io/element/android/features/call/ui/CallScreenPresenterTest.kt +++ b/features/call/src/test/kotlin/io/element/android/features/call/ui/CallScreenPresenterTest.kt @@ -30,7 +30,7 @@ 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.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClientProvider -import io.element.android.libraries.matrix.test.widget.FakeWidgetDriver +import io.element.android.libraries.matrix.test.widget.FakeMatrixWidgetDriver import io.element.android.libraries.network.useragent.UserAgentProvider import io.element.android.services.toolbox.api.systemclock.SystemClock import io.element.android.tests.testutils.WarmUpRule @@ -68,7 +68,7 @@ class CallScreenPresenterTest { @Test fun `present - with CallType RoomCall loads URL and runs WidgetDriver`() = runTest { - val widgetDriver = FakeWidgetDriver() + val widgetDriver = FakeMatrixWidgetDriver() val widgetProvider = FakeCallWidgetProvider(widgetDriver) val presenter = createCallScreenPresenter( callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID), @@ -91,7 +91,7 @@ class CallScreenPresenterTest { @Test fun `present - set message interceptor, send and receive messages`() = runTest { - val widgetDriver = FakeWidgetDriver() + val widgetDriver = FakeMatrixWidgetDriver() val presenter = createCallScreenPresenter( callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID), widgetDriver = widgetDriver, @@ -119,7 +119,7 @@ class CallScreenPresenterTest { @Test fun `present - hang up event closes the screen and stops the widget driver`() = runTest(UnconfinedTestDispatcher()) { val navigator = FakeCallScreenNavigator() - val widgetDriver = FakeWidgetDriver() + val widgetDriver = FakeMatrixWidgetDriver() val presenter = createCallScreenPresenter( callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID), widgetDriver = widgetDriver, @@ -149,7 +149,7 @@ class CallScreenPresenterTest { @Test fun `present - a received hang up message closes the screen and stops the widget driver`() = runTest(UnconfinedTestDispatcher()) { val navigator = FakeCallScreenNavigator() - val widgetDriver = FakeWidgetDriver() + val widgetDriver = FakeMatrixWidgetDriver() val presenter = createCallScreenPresenter( callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID), widgetDriver = widgetDriver, @@ -178,7 +178,7 @@ class CallScreenPresenterTest { @Test fun `present - automatically starts the Matrix client sync when on RoomCall`() = runTest { val navigator = FakeCallScreenNavigator() - val widgetDriver = FakeWidgetDriver() + val widgetDriver = FakeMatrixWidgetDriver() val matrixClient = FakeMatrixClient() val presenter = createCallScreenPresenter( callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID), @@ -201,7 +201,7 @@ class CallScreenPresenterTest { @Test fun `present - automatically stops the Matrix client sync on dispose`() = runTest { val navigator = FakeCallScreenNavigator() - val widgetDriver = FakeWidgetDriver() + val widgetDriver = FakeMatrixWidgetDriver() val matrixClient = FakeMatrixClient() val presenter = createCallScreenPresenter( callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID), @@ -229,7 +229,7 @@ class CallScreenPresenterTest { private fun TestScope.createCallScreenPresenter( callType: CallType, navigator: CallScreenNavigator = FakeCallScreenNavigator(), - widgetDriver: FakeWidgetDriver = FakeWidgetDriver(), + widgetDriver: FakeMatrixWidgetDriver = FakeMatrixWidgetDriver(), widgetProvider: FakeCallWidgetProvider = FakeCallWidgetProvider(widgetDriver), dispatchers: CoroutineDispatchers = testCoroutineDispatchers(), matrixClientsProvider: FakeMatrixClientProvider = FakeMatrixClientProvider(), diff --git a/features/call/src/test/kotlin/io/element/android/features/call/utils/DefaultCallWidgetProviderTest.kt b/features/call/src/test/kotlin/io/element/android/features/call/utils/DefaultCallWidgetProviderTest.kt index 368db19fcc..0b7e2ce953 100644 --- a/features/call/src/test/kotlin/io/element/android/features/call/utils/DefaultCallWidgetProviderTest.kt +++ b/features/call/src/test/kotlin/io/element/android/features/call/utils/DefaultCallWidgetProviderTest.kt @@ -26,7 +26,7 @@ import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClientProvider import io.element.android.libraries.matrix.test.room.FakeMatrixRoom import io.element.android.libraries.matrix.test.widget.FakeCallWidgetSettingsProvider -import io.element.android.libraries.matrix.test.widget.FakeWidgetDriver +import io.element.android.libraries.matrix.test.widget.FakeMatrixWidgetDriver import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore import kotlinx.coroutines.test.runTest import org.junit.Test @@ -76,7 +76,7 @@ class DefaultCallWidgetProviderTest { fun `getWidget - returns a widget driver when all steps are successful`() = runTest { val room = FakeMatrixRoom().apply { givenGenerateWidgetWebViewUrlResult(Result.success("url")) - givenGetWidgetDriverResult(Result.success(FakeWidgetDriver())) + givenGetWidgetDriverResult(Result.success(FakeMatrixWidgetDriver())) } val client = FakeMatrixClient().apply { givenGetRoomResult(A_ROOM_ID, room) @@ -89,7 +89,7 @@ class DefaultCallWidgetProviderTest { fun `getWidget - will use a custom base url if it exists`() = runTest { val room = FakeMatrixRoom().apply { givenGenerateWidgetWebViewUrlResult(Result.success("url")) - givenGetWidgetDriverResult(Result.success(FakeWidgetDriver())) + givenGetWidgetDriverResult(Result.success(FakeMatrixWidgetDriver())) } val client = FakeMatrixClient().apply { givenGetRoomResult(A_ROOM_ID, room) diff --git a/features/call/src/test/kotlin/io/element/android/features/call/utils/FakeCallWidgetProvider.kt b/features/call/src/test/kotlin/io/element/android/features/call/utils/FakeCallWidgetProvider.kt index 27e47ee708..c9e9ebb2ae 100644 --- a/features/call/src/test/kotlin/io/element/android/features/call/utils/FakeCallWidgetProvider.kt +++ b/features/call/src/test/kotlin/io/element/android/features/call/utils/FakeCallWidgetProvider.kt @@ -19,10 +19,10 @@ package io.element.android.features.call.utils 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.widget.MatrixWidgetDriver -import io.element.android.libraries.matrix.test.widget.FakeWidgetDriver +import io.element.android.libraries.matrix.test.widget.FakeMatrixWidgetDriver class FakeCallWidgetProvider( - private val widgetDriver: FakeWidgetDriver = FakeWidgetDriver(), + private val widgetDriver: FakeMatrixWidgetDriver = FakeMatrixWidgetDriver(), private val url: String = "https://call.element.io", ) : CallWidgetProvider { var getWidgetCalled = false diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleNode.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleNode.kt index f00f10905d..eba288859c 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleNode.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleNode.kt @@ -47,8 +47,8 @@ class AddPeopleNode @AssistedInject constructor( AddPeopleView( state = state, modifier = modifier, - onBackPressed = this::navigateUp, - onNextPressed = this::onContinue, + onBackClick = this::navigateUp, + onNextClick = this::onContinue, ) } } diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleView.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleView.kt index 1bfb680d00..93a71cda53 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleView.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleView.kt @@ -42,8 +42,8 @@ import io.element.android.libraries.ui.strings.CommonStrings @Composable fun AddPeopleView( state: UserListState, - onBackPressed: () -> Unit, - onNextPressed: () -> Unit, + onBackClick: () -> Unit, + onNextClick: () -> Unit, modifier: Modifier = Modifier, ) { Scaffold( @@ -51,14 +51,14 @@ fun AddPeopleView( topBar = { AddPeopleViewTopBar( hasSelectedUsers = state.selectedUsers.isNotEmpty(), - onBackPressed = { + onBackClick = { if (state.isSearchActive) { state.eventSink(UserListEvents.OnSearchActiveChanged(false)) } else { - onBackPressed() + onBackClick() } }, - onNextPressed = onNextPressed, + onNextClick = onNextClick, ) } ) { padding -> @@ -69,8 +69,8 @@ fun AddPeopleView( .consumeWindowInsets(padding), state = state, showBackButton = false, - onUserSelected = {}, - onUserDeselected = {}, + onSelectUser = {}, + onDeselectUser = {}, ) } } @@ -79,8 +79,8 @@ fun AddPeopleView( @Composable private fun AddPeopleViewTopBar( hasSelectedUsers: Boolean, - onBackPressed: () -> Unit, - onNextPressed: () -> Unit, + onBackClick: () -> Unit, + onNextClick: () -> Unit, ) { TopAppBar( title = { @@ -89,12 +89,12 @@ private fun AddPeopleViewTopBar( style = ElementTheme.typography.aliasScreenTitle ) }, - navigationIcon = { BackButton(onClick = onBackPressed) }, + navigationIcon = { BackButton(onClick = onBackClick) }, actions = { val textActionResId = if (hasSelectedUsers) CommonStrings.action_next else CommonStrings.action_skip TextButton( text = stringResource(id = textActionResId), - onClick = onNextPressed, + onClick = onNextClick, ) } ) @@ -105,7 +105,7 @@ private fun AddPeopleViewTopBar( internal fun AddPeopleViewPreview(@PreviewParameter(AddPeopleUserListStateProvider::class) state: UserListState) = ElementPreview { AddPeopleView( state = state, - onBackPressed = {}, - onNextPressed = {}, + onBackClick = {}, + onNextClick = {}, ) } diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/components/RoomPrivacyOption.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/components/RoomPrivacyOption.kt index 1bfd1567c2..f204aa6259 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/components/RoomPrivacyOption.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/components/RoomPrivacyOption.kt @@ -41,7 +41,7 @@ import io.element.android.libraries.designsystem.theme.components.Text @Composable fun RoomPrivacyOption( roomPrivacyItem: RoomPrivacyItem, - onOptionSelected: (RoomPrivacyItem) -> Unit, + onOptionClick: (RoomPrivacyItem) -> Unit, modifier: Modifier = Modifier, isSelected: Boolean = false, ) { @@ -50,7 +50,7 @@ fun RoomPrivacyOption( .fillMaxWidth() .selectable( selected = isSelected, - onClick = { onOptionSelected(roomPrivacyItem) }, + onClick = { onOptionClick(roomPrivacyItem) }, role = Role.RadioButton, ) .padding(8.dp), @@ -98,12 +98,12 @@ internal fun RoomPrivacyOptionPreview() = ElementPreview { Column { RoomPrivacyOption( roomPrivacyItem = aRoomPrivacyItem, - onOptionSelected = {}, + onOptionClick = {}, isSelected = true, ) RoomPrivacyOption( roomPrivacyItem = aRoomPrivacyItem, - onOptionSelected = {}, + onOptionClick = {}, isSelected = false, ) } diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/components/SearchUserBar.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/components/SearchUserBar.kt index 415ca054ff..0aafd515f8 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/components/SearchUserBar.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/components/SearchUserBar.kt @@ -53,11 +53,11 @@ fun SearchUserBar( showLoader: Boolean, selectedUsers: ImmutableList, active: Boolean, - isMultiSelectionEnabled: Boolean, - onActiveChanged: (Boolean) -> Unit, - onTextChanged: (String) -> Unit, - onUserSelected: (MatrixUser) -> Unit, - onUserDeselected: (MatrixUser) -> Unit, + isMultiSelectionEnable: Boolean, + onActiveChange: (Boolean) -> Unit, + onTextChange: (String) -> Unit, + onUserSelect: (MatrixUser) -> Unit, + onUserDeselect: (MatrixUser) -> Unit, modifier: Modifier = Modifier, showBackButton: Boolean = true, placeHolderTitle: String = stringResource(CommonStrings.common_search_for_someone), @@ -66,14 +66,14 @@ fun SearchUserBar( SearchBar( query = query, - onQueryChange = onTextChanged, + onQueryChange = onTextChange, active = active, - onActiveChange = onActiveChanged, + onActiveChange = onActiveChange, modifier = modifier, placeHolderTitle = placeHolderTitle, showBackButton = showBackButton, contentPrefix = { - if (isMultiSelectionEnabled && active && selectedUsers.isNotEmpty()) { + if (isMultiSelectionEnable && active && selectedUsers.isNotEmpty()) { // We want the selected users to behave a bit like a top bar - when the list below is scrolled, the colour // should change to indicate elevation. @@ -96,7 +96,7 @@ fun SearchUserBar( contentPadding = PaddingValues(16.dp), selectedUsers = selectedUsers, autoScroll = true, - onUserRemoved = onUserDeselected, + onUserRemove = onUserDeselect, modifier = Modifier.background(appBarContainerColor) ) } @@ -109,7 +109,7 @@ fun SearchUserBar( resultState = state, resultHandler = { users -> LazyColumn(state = columnState) { - if (isMultiSelectionEnabled) { + if (isMultiSelectionEnable) { itemsIndexed(users) { index, searchResult -> SearchMultipleUsersResultItem( modifier = Modifier.fillMaxWidth(), @@ -117,9 +117,9 @@ fun SearchUserBar( isUserSelected = selectedUsers.contains(searchResult.matrixUser), onCheckedChange = { checked -> if (checked) { - onUserSelected(searchResult.matrixUser) + onUserSelect(searchResult.matrixUser) } else { - onUserDeselected(searchResult.matrixUser) + onUserDeselect(searchResult.matrixUser) } } ) @@ -132,7 +132,7 @@ fun SearchUserBar( SearchSingleUserResultItem( modifier = Modifier.fillMaxWidth(), searchResult = searchResult, - onClick = { onUserSelected(searchResult.matrixUser) } + onClick = { onUserSelect(searchResult.matrixUser) } ) if (index < users.lastIndex) { HorizontalDivider() diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/components/UserListView.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/components/UserListView.kt index 0e1c448015..7eead66276 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/components/UserListView.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/components/UserListView.kt @@ -44,8 +44,8 @@ import io.element.android.libraries.ui.strings.CommonStrings @Composable fun UserListView( state: UserListState, - onUserSelected: (MatrixUser) -> Unit, - onUserDeselected: (MatrixUser) -> Unit, + onSelectUser: (MatrixUser) -> Unit, + onDeselectUser: (MatrixUser) -> Unit, modifier: Modifier = Modifier, showBackButton: Boolean = true, ) { @@ -59,17 +59,17 @@ fun UserListView( selectedUsers = state.selectedUsers, active = state.isSearchActive, showLoader = state.showSearchLoader, - isMultiSelectionEnabled = state.isMultiSelectionEnabled, + isMultiSelectionEnable = state.isMultiSelectionEnabled, showBackButton = showBackButton, - onActiveChanged = { state.eventSink(UserListEvents.OnSearchActiveChanged(it)) }, - onTextChanged = { state.eventSink(UserListEvents.UpdateSearchQuery(it)) }, - onUserSelected = { + onActiveChange = { state.eventSink(UserListEvents.OnSearchActiveChanged(it)) }, + onTextChange = { state.eventSink(UserListEvents.UpdateSearchQuery(it)) }, + onUserSelect = { state.eventSink(UserListEvents.AddToSelection(it)) - onUserSelected(it) + onSelectUser(it) }, - onUserDeselected = { + onUserDeselect = { state.eventSink(UserListEvents.RemoveFromSelection(it)) - onUserDeselected(it) + onDeselectUser(it) }, ) @@ -78,9 +78,9 @@ fun UserListView( contentPadding = PaddingValues(16.dp), selectedUsers = state.selectedUsers, autoScroll = true, - onUserRemoved = { + onUserRemove = { state.eventSink(UserListEvents.RemoveFromSelection(it)) - onUserDeselected(it) + onDeselectUser(it) }, ) } @@ -102,10 +102,10 @@ fun UserListView( onCheckedChange = { if (isSelected) { state.eventSink(UserListEvents.RemoveFromSelection(recentDirectRoom.matrixUser)) - onUserDeselected(recentDirectRoom.matrixUser) + onDeselectUser(recentDirectRoom.matrixUser) } else { state.eventSink(UserListEvents.AddToSelection(recentDirectRoom.matrixUser)) - onUserSelected(recentDirectRoom.matrixUser) + onSelectUser(recentDirectRoom.matrixUser) } }, data = CheckableUserRowData.Resolved( @@ -129,7 +129,7 @@ fun UserListView( internal fun UserListViewPreview(@PreviewParameter(UserListStateProvider::class) state: UserListState) = ElementPreview { UserListView( state = state, - onUserSelected = {}, - onUserDeselected = {}, + onSelectUser = {}, + onDeselectUser = {}, ) } 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 1d3499f743..3d8f96b9d6 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 @@ -50,7 +50,7 @@ class ConfigureRoomNode @AssistedInject constructor( fun onCreateRoomSuccess(roomId: RoomId) } - private fun onRoomCreated(roomId: RoomId) { + private fun onCreateRoomSuccess(roomId: RoomId) { plugins().forEach { it.onCreateRoomSuccess(roomId) } } @@ -60,8 +60,8 @@ class ConfigureRoomNode @AssistedInject constructor( ConfigureRoomView( state = state, modifier = modifier, - onBackPressed = this::navigateUp, - onRoomCreated = this::onRoomCreated, + onBackClick = this::navigateUp, + onCreateRoomSuccess = this::onCreateRoomSuccess, ) } } diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomView.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomView.kt index 5852103b92..73441f9d16 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomView.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/configureroom/ConfigureRoomView.kt @@ -29,12 +29,10 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.selection.selectableGroup import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll -import androidx.compose.material.ExperimentalMaterialApi -import androidx.compose.material.ModalBottomSheetValue -import androidx.compose.material.rememberModalBottomSheetState import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable -import androidx.compose.runtime.rememberCoroutineScope +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 @@ -63,27 +61,20 @@ import io.element.android.libraries.matrix.ui.components.SelectedUsersRowList import io.element.android.libraries.matrix.ui.components.UnsavedAvatar import io.element.android.libraries.permissions.api.PermissionsView import io.element.android.libraries.ui.strings.CommonStrings -import kotlinx.coroutines.launch -@OptIn(ExperimentalMaterialApi::class) @Composable fun ConfigureRoomView( state: ConfigureRoomState, - onBackPressed: () -> Unit, - onRoomCreated: (RoomId) -> Unit, + onBackClick: () -> Unit, + onCreateRoomSuccess: (RoomId) -> Unit, modifier: Modifier = Modifier, ) { - val coroutineScope = rememberCoroutineScope() val focusManager = LocalFocusManager.current - val itemActionsBottomSheetState = rememberModalBottomSheetState( - initialValue = ModalBottomSheetValue.Hidden, - ) + val isAvatarActionsSheetVisible = remember { mutableStateOf(false) } - fun onAvatarClicked() { + fun onAvatarClick() { focusManager.clearFocus() - coroutineScope.launch { - itemActionsBottomSheetState.show() - } + isAvatarActionsSheetVisible.value = true } Scaffold( @@ -91,8 +82,8 @@ fun ConfigureRoomView( topBar = { ConfigureRoomToolbar( isNextActionEnabled = state.isCreateButtonEnabled, - onBackPressed = onBackPressed, - onNextPressed = { + onBackClick = onBackClick, + onNextClick = { focusManager.clearFocus() state.eventSink(ConfigureRoomEvents.CreateRoom(state.config)) }, @@ -111,20 +102,20 @@ fun ConfigureRoomView( modifier = Modifier.padding(horizontal = 16.dp), avatarUri = state.config.avatarUri, roomName = state.config.roomName.orEmpty(), - onAvatarClick = ::onAvatarClicked, - onRoomNameChanged = { state.eventSink(ConfigureRoomEvents.RoomNameChanged(it)) }, + onAvatarClick = ::onAvatarClick, + onChangeRoomName = { state.eventSink(ConfigureRoomEvents.RoomNameChanged(it)) }, ) RoomTopic( modifier = Modifier.padding(horizontal = 16.dp), topic = state.config.topic.orEmpty(), - onTopicChanged = { state.eventSink(ConfigureRoomEvents.TopicChanged(it)) }, + onTopicChange = { state.eventSink(ConfigureRoomEvents.TopicChanged(it)) }, ) if (state.config.invites.isNotEmpty()) { SelectedUsersRowList( modifier = Modifier.padding(bottom = 16.dp), contentPadding = PaddingValues(horizontal = 24.dp), selectedUsers = state.config.invites, - onUserRemoved = { + onUserRemove = { focusManager.clearFocus() state.eventSink(ConfigureRoomEvents.RemoveFromSelection(it)) }, @@ -133,7 +124,7 @@ fun ConfigureRoomView( RoomPrivacyOptions( modifier = Modifier.padding(bottom = 40.dp), selected = state.config.privacy, - onOptionSelected = { + onOptionClick = { focusManager.clearFocus() state.eventSink(ConfigureRoomEvents.RoomPrivacyChanged(it.privacy)) }, @@ -143,8 +134,9 @@ fun ConfigureRoomView( AvatarActionBottomSheet( actions = state.avatarActions, - modalBottomSheetState = itemActionsBottomSheetState, - onActionSelected = { state.eventSink(ConfigureRoomEvents.HandleAvatarAction(it)) } + isVisible = isAvatarActionsSheetVisible.value, + onDismiss = { isAvatarActionsSheetVisible.value = false }, + onSelectAction = { state.eventSink(ConfigureRoomEvents.HandleAvatarAction(it)) } ) AsyncActionView( @@ -154,7 +146,7 @@ fun ConfigureRoomView( progressText = stringResource(CommonStrings.common_creating_room), ) }, - onSuccess = { onRoomCreated(it) }, + onSuccess = { onCreateRoomSuccess(it) }, errorMessage = { stringResource(R.string.screen_create_room_error_creating_room) }, onRetry = { state.eventSink(ConfigureRoomEvents.CreateRoom(state.config)) }, onErrorDismiss = { state.eventSink(ConfigureRoomEvents.CancelCreateRoom) }, @@ -169,8 +161,8 @@ fun ConfigureRoomView( @Composable private fun ConfigureRoomToolbar( isNextActionEnabled: Boolean, - onBackPressed: () -> Unit, - onNextPressed: () -> Unit, + onBackClick: () -> Unit, + onNextClick: () -> Unit, ) { TopAppBar( title = { @@ -179,12 +171,12 @@ private fun ConfigureRoomToolbar( style = ElementTheme.typography.aliasScreenTitle, ) }, - navigationIcon = { BackButton(onClick = onBackPressed) }, + navigationIcon = { BackButton(onClick = onBackClick) }, actions = { TextButton( text = stringResource(CommonStrings.action_create), enabled = isNextActionEnabled, - onClick = onNextPressed, + onClick = onNextClick, ) } ) @@ -195,7 +187,7 @@ private fun RoomNameWithAvatar( avatarUri: Uri?, roomName: String, onAvatarClick: () -> Unit, - onRoomNameChanged: (String) -> Unit, + onChangeRoomName: (String) -> Unit, modifier: Modifier = Modifier, ) { Row( @@ -213,7 +205,7 @@ private fun RoomNameWithAvatar( value = roomName, placeholder = stringResource(CommonStrings.common_room_name_placeholder), singleLine = true, - onValueChange = onRoomNameChanged, + onValueChange = onChangeRoomName, ) } } @@ -221,7 +213,7 @@ private fun RoomNameWithAvatar( @Composable private fun RoomTopic( topic: String, - onTopicChanged: (String) -> Unit, + onTopicChange: (String) -> Unit, modifier: Modifier = Modifier, ) { LabelledTextField( @@ -229,7 +221,7 @@ private fun RoomTopic( label = stringResource(R.string.screen_create_room_topic_label), value = topic, placeholder = stringResource(CommonStrings.common_topic_placeholder), - onValueChange = onTopicChanged, + onValueChange = onTopicChange, maxLines = 3, keyboardOptions = KeyboardOptions( capitalization = KeyboardCapitalization.Sentences, @@ -240,7 +232,7 @@ private fun RoomTopic( @Composable private fun RoomPrivacyOptions( selected: RoomPrivacy?, - onOptionSelected: (RoomPrivacyItem) -> Unit, + onOptionClick: (RoomPrivacyItem) -> Unit, modifier: Modifier = Modifier, ) { val items = roomPrivacyItems() @@ -249,7 +241,7 @@ private fun RoomPrivacyOptions( RoomPrivacyOption( roomPrivacyItem = item, isSelected = selected == item.privacy, - onOptionSelected = onOptionSelected, + onOptionClick = onOptionClick, ) } } @@ -260,7 +252,7 @@ private fun RoomPrivacyOptions( internal fun ConfigureRoomViewPreview(@PreviewParameter(ConfigureRoomStateProvider::class) state: ConfigureRoomState) = ElementPreview { ConfigureRoomView( state = state, - onBackPressed = {}, - onRoomCreated = {}, + onBackClick = {}, + onCreateRoomSuccess = {}, ) } diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootNode.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootNode.kt index 14b0061f53..745fb3377e 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootNode.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootNode.kt @@ -68,10 +68,10 @@ class CreateRoomRootNode @AssistedInject constructor( CreateRoomRootView( state = state, modifier = modifier, - onClosePressed = this::navigateUp, - onNewRoomClicked = ::onCreateNewRoom, + onCloseClick = this::navigateUp, + onNewRoomClick = ::onCreateNewRoom, onOpenDM = ::onStartChatSuccess, - onInviteFriendsClicked = { invitePeople(activity) } + onInviteFriendsClick = { invitePeople(activity) } ) } diff --git a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootView.kt b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootView.kt index 33707896fa..015483ff7f 100644 --- a/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootView.kt +++ b/features/createroom/impl/src/main/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootView.kt @@ -59,17 +59,17 @@ import kotlinx.collections.immutable.persistentListOf @Composable fun CreateRoomRootView( state: CreateRoomRootState, - onClosePressed: () -> Unit, - onNewRoomClicked: () -> Unit, + onCloseClick: () -> Unit, + onNewRoomClick: () -> Unit, onOpenDM: (RoomId) -> Unit, - onInviteFriendsClicked: () -> Unit, + onInviteFriendsClick: () -> Unit, modifier: Modifier = Modifier, ) { Scaffold( modifier = modifier.fillMaxWidth(), topBar = { if (!state.userListState.isSearchActive) { - CreateRoomRootViewTopBar(onClosePressed = onClosePressed) + CreateRoomRootViewTopBar(onCloseClick = onCloseClick) } } ) { paddingValues -> @@ -86,18 +86,18 @@ fun CreateRoomRootView( state = state.userListState.copy( recentDirectRooms = persistentListOf(), ), - onUserSelected = { + onSelectUser = { state.eventSink(CreateRoomRootEvents.StartDM(it)) }, - onUserDeselected = { }, + onDeselectUser = { }, ) if (!state.userListState.isSearchActive) { CreateRoomActionButtonsList( state = state, - onNewRoomClicked = onNewRoomClicked, - onInvitePeopleClicked = onInviteFriendsClicked, - onDmClicked = onOpenDM, + onNewRoomClick = onNewRoomClick, + onInvitePeopleClick = onInviteFriendsClick, + onDmClick = onOpenDM, ) } } @@ -125,7 +125,7 @@ fun CreateRoomRootView( @OptIn(ExperimentalMaterial3Api::class) @Composable private fun CreateRoomRootViewTopBar( - onClosePressed: () -> Unit, + onCloseClick: () -> Unit, ) { TopAppBar( title = { @@ -137,7 +137,7 @@ private fun CreateRoomRootViewTopBar( navigationIcon = { BackButton( imageVector = CompoundIcons.Close(), - onClick = onClosePressed, + onClick = onCloseClick, ) } ) @@ -146,23 +146,23 @@ private fun CreateRoomRootViewTopBar( @Composable private fun CreateRoomActionButtonsList( state: CreateRoomRootState, - onNewRoomClicked: () -> Unit, - onInvitePeopleClicked: () -> Unit, - onDmClicked: (RoomId) -> Unit, + onNewRoomClick: () -> Unit, + onInvitePeopleClick: () -> Unit, + onDmClick: (RoomId) -> Unit, ) { LazyColumn { item { CreateRoomActionButton( iconRes = CompoundDrawables.ic_compound_plus, text = stringResource(id = R.string.screen_create_room_action_create_room), - onClick = onNewRoomClicked, + onClick = onNewRoomClick, ) } item { CreateRoomActionButton( iconRes = CompoundDrawables.ic_compound_share_android, text = stringResource(id = CommonStrings.action_invite_friends_to_app, state.applicationName), - onClick = onInvitePeopleClicked, + onClick = onInvitePeopleClick, ) } if (state.userListState.recentDirectRooms.isNotEmpty()) { @@ -177,7 +177,7 @@ private fun CreateRoomActionButtonsList( MatrixUserRow( modifier = Modifier.clickable( onClick = { - onDmClicked(recentDirectRoom.roomId) + onDmClick(recentDirectRoom.roomId) } ), matrixUser = recentDirectRoom.matrixUser, @@ -222,9 +222,9 @@ internal fun CreateRoomRootViewPreview(@PreviewParameter(CreateRoomRootStateProv ElementPreview { CreateRoomRootView( state = state, - onClosePressed = {}, - onNewRoomClicked = {}, + onCloseClick = {}, + onNewRoomClick = {}, onOpenDM = {}, - onInviteFriendsClicked = {}, + onInviteFriendsClick = {}, ) } diff --git a/features/createroom/impl/src/main/res/values-ka/translations.xml b/features/createroom/impl/src/main/res/values-ka/translations.xml new file mode 100644 index 0000000000..45a6c79d33 --- /dev/null +++ b/features/createroom/impl/src/main/res/values-ka/translations.xml @@ -0,0 +1,14 @@ + + + "ახალი ოთახი" + "ხალხის მოწვევა" + "ოთახის შექმნისას შეცდომა მოხდა" + "ამ ოთახში შეტყობინებები დაშიფრულია. შემდგომ დაშიფვრის გამორთვა შეუძლებელია." + "კერძო ოთახი (მხოლოდ მოწვევა)" + "შეტყობინებები არ არის დაშიფრული და ყველას შეუძლია მათი წაკითხვა. შეგიძლიათ ჩართოთ დაშიფვრა მოგვიანებით." + "საჯარო ოთახი (ნებისმიერი)" + "ოთახის სახელი" + "ოთახის შექმნა" + "თემა (სურვილისამებრ)" + "ჩატის დაწყების მცდელობისას შეცდომა მოხდა" + 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 new file mode 100644 index 0000000000..398be62190 --- /dev/null +++ b/features/createroom/impl/src/main/res/values-pt-rBR/translations.xml @@ -0,0 +1,14 @@ + + + "Nova sala" + "Convidar pessoas" + "Ocorreu um erro ao criar a sala" + "As mensagens serão cifradas. Uma vez ativada, não é possível desativar a cifragem." + "Sala privada (entrada apenas por convite)" + "As mensagens não serão cifradas e qualquer um as poderá ler. É possível ativar a cifragem posteriormente." + "Sala pública (entrada livre)" + "Nome da sala" + "Criar uma sala" + "Descrição (opcional)" + "Ocorreu um erro ao tentar iniciar uma conversa" + diff --git a/features/createroom/impl/src/main/res/values-zh/translations.xml b/features/createroom/impl/src/main/res/values-zh/translations.xml new file mode 100644 index 0000000000..4a5c2120a2 --- /dev/null +++ b/features/createroom/impl/src/main/res/values-zh/translations.xml @@ -0,0 +1,14 @@ + + + "新聊天室" + "邀请朋友" + "创建房间时出错" + "此聊天室中的消息已加密。加密无法禁用。" + "私人房间(仅限受邀者)" + "消息未加密,任何人都可以查看。你可以稍后启用加密。" + "公共房间(任何人)" + "房间名称" + "创建房间" + "主题(可选)" + "在开始聊天时发生了错误" + diff --git a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleViewTest.kt b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleViewTest.kt index 36741347e5..da9f1594e5 100644 --- a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleViewTest.kt +++ b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/addpeople/AddPeopleViewTest.kt @@ -47,7 +47,7 @@ class AddPeopleViewTest { aUserListState( eventSink = eventsRecorder, ), - onBackPressed = it + onBackClick = it ) rule.pressBack() } @@ -75,7 +75,7 @@ class AddPeopleViewTest { aUserListState( eventSink = eventsRecorder, ), - onNextPressed = it + onNextClick = it ) rule.clickOn(CommonStrings.action_skip) } @@ -85,14 +85,14 @@ class AddPeopleViewTest { private fun AndroidComposeTestRule.setAddPeopleView( state: UserListState, - onBackPressed: () -> Unit = EnsureNeverCalled(), - onNextPressed: () -> Unit = EnsureNeverCalled(), + onBackClick: () -> Unit = EnsureNeverCalled(), + onNextClick: () -> Unit = EnsureNeverCalled(), ) { setContent { AddPeopleView( state = state, - onBackPressed = onBackPressed, - onNextPressed = onNextPressed, + onBackClick = onBackClick, + onNextClick = onNextClick, ) } } diff --git a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootViewTest.kt b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootViewTest.kt index dcb2e02347..bdcb524e33 100644 --- a/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootViewTest.kt +++ b/features/createroom/impl/src/test/kotlin/io/element/android/features/createroom/impl/root/CreateRoomRootViewTest.kt @@ -54,7 +54,7 @@ class CreateRoomRootViewTest { aCreateRoomRootState( eventSink = eventsRecorder, ), - onClosePressed = it + onCloseClick = it ) rule.pressBack() } @@ -68,7 +68,7 @@ class CreateRoomRootViewTest { aCreateRoomRootState( eventSink = eventsRecorder, ), - onNewRoomClicked = it + onNewRoomClick = it ) rule.clickOn(R.string.screen_create_room_action_create_room) } @@ -84,7 +84,7 @@ class CreateRoomRootViewTest { applicationName = "test", eventSink = eventsRecorder, ), - onInviteFriendsClicked = it + onInviteFriendsClick = it ) val text = rule.activity.getString(CommonStrings.action_invite_friends_to_app, "test") rule.onNodeWithText(text).performClick() @@ -114,18 +114,18 @@ class CreateRoomRootViewTest { private fun AndroidComposeTestRule.setCreateRoomRootView( state: CreateRoomRootState, - onClosePressed: () -> Unit = EnsureNeverCalled(), - onNewRoomClicked: () -> Unit = EnsureNeverCalled(), + onCloseClick: () -> Unit = EnsureNeverCalled(), + onNewRoomClick: () -> Unit = EnsureNeverCalled(), onOpenDM: (RoomId) -> Unit = EnsureNeverCalledWithParam(), - onInviteFriendsClicked: () -> Unit = EnsureNeverCalled(), + onInviteFriendsClick: () -> Unit = EnsureNeverCalled(), ) { setContent { CreateRoomRootView( state = state, - onClosePressed = onClosePressed, - onNewRoomClicked = onNewRoomClicked, + onCloseClick = onCloseClick, + onNewRoomClick = onNewRoomClick, onOpenDM = onOpenDM, - onInviteFriendsClicked = onInviteFriendsClicked, + onInviteFriendsClick = onInviteFriendsClick, ) } } diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/FtueFlowNode.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/FtueFlowNode.kt index 772343ea58..6720fa0274 100644 --- a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/FtueFlowNode.kt +++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/FtueFlowNode.kt @@ -132,9 +132,8 @@ class FtueFlowNode @AssistedInject constructor( lifecycleScope.launch { moveToNextStep() } } } - lockScreenEntryPoint.nodeBuilder(this, buildContext) + lockScreenEntryPoint.nodeBuilder(this, buildContext, LockScreenEntryPoint.Target.Setup) .callback(callback) - .target(LockScreenEntryPoint.Target.Setup) .build() } } diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/WelcomeNode.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/WelcomeNode.kt index 2f2d838269..68c2bc6410 100644 --- a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/WelcomeNode.kt +++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/WelcomeNode.kt @@ -34,18 +34,18 @@ class WelcomeNode @AssistedInject constructor( private val buildMeta: BuildMeta, ) : Node(buildContext, plugins = plugins) { interface Callback : Plugin { - fun onContinueClicked() + fun onContinueClick() } - private fun onContinueClicked() { - plugins.filterIsInstance().forEach { it.onContinueClicked() } + private fun onContinueClick() { + plugins.filterIsInstance().forEach { it.onContinueClick() } } @Composable override fun View(modifier: Modifier) { WelcomeView( applicationName = buildMeta.applicationName, - onContinueClicked = ::onContinueClicked, + onContinueClick = ::onContinueClick, modifier = modifier ) } diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/WelcomeView.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/WelcomeView.kt index 50af73903e..c491b7d4a1 100644 --- a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/WelcomeView.kt +++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/welcome/WelcomeView.kt @@ -52,9 +52,9 @@ import kotlinx.collections.immutable.persistentListOf fun WelcomeView( applicationName: String, modifier: Modifier = Modifier, - onContinueClicked: () -> Unit, + onContinueClick: () -> Unit, ) { - BackHandler(onBack = onContinueClicked) + BackHandler(onBack = onContinueClick) OnBoardingPage( modifier = modifier .systemBarsPadding() @@ -90,7 +90,7 @@ fun WelcomeView( Button( text = stringResource(CommonStrings.action_continue), modifier = Modifier.fillMaxWidth(), - onClick = onContinueClicked + onClick = onContinueClick ) Spacer(modifier = Modifier.height(32.dp)) } @@ -113,6 +113,6 @@ private fun listItems() = persistentListOf( @Composable internal fun WelcomeViewPreview() { ElementPreview { - WelcomeView(applicationName = "Element X", onContinueClicked = {}) + WelcomeView(applicationName = "Element X", onContinueClick = {}) } } diff --git a/features/ftue/impl/src/main/res/values-be/translations.xml b/features/ftue/impl/src/main/res/values-be/translations.xml index e9666b57e3..53e2370f8f 100644 --- a/features/ftue/impl/src/main/res/values-be/translations.xml +++ b/features/ftue/impl/src/main/res/values-be/translations.xml @@ -11,11 +11,24 @@ "Злучэнне небяспечнае" "Вам будзе прапанавана ўвесці дзве лічбы, паказаныя на гэтай прыладзе." "Увядзіце наступны нумар на іншай прыладзе." + "Уваход быў адменены на іншай прыладзе." + "Запыт на ўваход скасаваны" + "Запыт на іншай прыладзе не быў прыняты." + "Уваход адхілены" + "Тэрмін уваходу скончыўся. Калі ласка, паспрабуйце яшчэ раз." + "Уваход у сістэму не быў завершаны своечасова" + "Ваша іншая прылада не падтрымлівае ўваход у %s з дапамогай QR-кода. + +Паспрабуйце ўвайсці ў сістэму ўручную або адскануйце QR-код з дапамогай іншай прылады." + "QR-код не падтрымліваецца" + "Ваш правайдар уліковага запісу не падтрымлівае %1$s." + "%1$s не падтрымліваецца" + "Гатовы да сканавання" "Адкрыйце %1$s на настольнай прыладзе" "Націсніце на свой аватар" "Выберыце %1$s" "“Звязаць новую прыладу”" - "Выконвайце паказаныя інструкцыі" + "Адсканіруйце QR-код з дапамогай гэтай прылады" "Адкрыйце %1$s на іншай прыладзе, каб атрымаць QR-код" "Выкарыстоўвайце QR-код, паказаны на іншай прыладзе." "Паўтарыць спробу" diff --git a/features/ftue/impl/src/main/res/values-cs/translations.xml b/features/ftue/impl/src/main/res/values-cs/translations.xml index b6c0efa729..b9e93b071f 100644 --- a/features/ftue/impl/src/main/res/values-cs/translations.xml +++ b/features/ftue/impl/src/main/res/values-cs/translations.xml @@ -11,11 +11,24 @@ "Připojení není zabezpečené" "Budete požádáni o zadání dvou níže uvedených číslic." "Zadejte níže uvedené číslo na svém dalším zařízení" + "Přihlášení bylo na druhém zařízení zrušeno." + "Žádost o přihlášení zrušena" + "Požadavek na vašem druhém zařízení nebyl přijat." + "Přihlášení odmítnuto" + "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" + "Připraveno ke skenování" "Otevřete %1$s na stolním počítači" "Klikněte na svůj avatar" "Vybrat %1$s" "\"Připojit nové zařízení\"" - "Postupujte podle uvedených pokynů" + "Naskenujte QR kód pomocí tohoto zařízení" "Otevřete %1$s na jiném zařízení pro získání QR kódu" "Použijte QR kód zobrazený na druhém zařízení." "Zkusit znovu" diff --git a/features/ftue/impl/src/main/res/values-de/translations.xml b/features/ftue/impl/src/main/res/values-de/translations.xml index 4640cf119c..41fe886ee6 100644 --- a/features/ftue/impl/src/main/res/values-de/translations.xml +++ b/features/ftue/impl/src/main/res/values-de/translations.xml @@ -11,11 +11,12 @@ "Die Verbindung ist nicht sicher" "Du wirst aufgefordert, die beiden unten abgebildeten Ziffern einzugeben." "Trage die unten angezeigte Zahl auf einem anderen Device ein" + "Bereit zum Scannen" "%1$s auf einem Desktop-Gerät öffnen" "Klick auf deinen Avatar" "Wähle %1$s" "\"Neues Gerät verknüpfen\"" - "Befolge die angezeigten Anweisungen" + "Scanne den QR-Code mit diesem Gerät" "Öffne %1$s auf einem anderen Gerät, um den QR-Code zu erhalten" "Verwende den QR-Code, der auf dem anderen Gerät angezeigt wird." "Erneut versuchen" diff --git a/features/ftue/impl/src/main/res/values-fr/translations.xml b/features/ftue/impl/src/main/res/values-fr/translations.xml index c69b729fc7..f4180be584 100644 --- a/features/ftue/impl/src/main/res/values-fr/translations.xml +++ b/features/ftue/impl/src/main/res/values-fr/translations.xml @@ -11,11 +11,22 @@ "La connexion n’est pas sécurisée" "Il vous sera demandé de saisir les deux chiffres affichés sur cet appareil." "Saisissez le nombre ci-dessous sur votre autre appareil" + "La connexion a été annulée sur l’autre appareil." + "Demande de connexion annulée" + "La demande sur l’autre appareil n’a pas été acceptée." + "Connexion refusée" + "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é" + "Prêt à scanner" "Ouvrez %1$s sur un ordinateur" "Cliquez sur votre image de profil" "Choisissez %1$s" "“Associer une nouvelle session”" - "Suivez les instructions affichées" + "Scanner le code QR avec cet appareil" "Ouvrez %1$s sur un autre appareil pour obtenir le QR code" "Scannez le QR code affiché sur l’autre appareil." "Essayer à nouveau" diff --git a/features/ftue/impl/src/main/res/values-hu/translations.xml b/features/ftue/impl/src/main/res/values-hu/translations.xml index e5c11f0c2b..0d463d2900 100644 --- a/features/ftue/impl/src/main/res/values-hu/translations.xml +++ b/features/ftue/impl/src/main/res/values-hu/translations.xml @@ -11,11 +11,12 @@ "A kapcsolat nem biztonságos" "A rendszer kérni fogja, hogy adja meg az alábbi két számjegyet az eszközén." "Adja meg az alábbi számot a másik eszközén" + "Készen áll a beolvasásra" "Nyissa meg az %1$set egy asztali eszközön" "Kattintson a profilképére" "Válassza ezt: %1$s" "„Új eszköz összekapcsolása”" - "Kövesse a látható utasításokat" + "Olvassa be a QR-kódot ezzel az eszközzel" "Nyissa meg az %1$set egy másik eszközön a QR-kód lekéréséhez." "Használja a másik eszközön látható QR-kódot." "Próbálja újra" diff --git a/features/ftue/impl/src/main/res/values-it/translations.xml b/features/ftue/impl/src/main/res/values-it/translations.xml index 8c253c8c5a..cb81d4d97a 100644 --- a/features/ftue/impl/src/main/res/values-it/translations.xml +++ b/features/ftue/impl/src/main/res/values-it/translations.xml @@ -2,7 +2,33 @@ "Potrai modificare le tue impostazioni in seguito." "Consenti le notifiche e non perdere mai un messaggio" + "Stabilendo la connessione" + "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" + "Ti verrà chiesto di inserire le due cifre mostrate su questo dispositivo." + "Inserisci il numero qui sotto sull\'altro dispositivo" + "Apri %1$s su un dispositivo desktop" + "Clicca sul tuo avatar" + "Seleziona %1$s" + "\"Collega un nuovo dispositivo\"" + "Segui le istruzioni mostrate" + "Apri %1$s su un altro dispositivo per ottenere il codice QR" + "Usa il codice QR mostrato sull\'altro dispositivo." "Riprova" + "Codice QR sbagliato" + "Vai alle impostazioni della fotocamera" + "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" + "Scansiona il codice QR" + "Ricomincia" + "Si è verificato un errore inatteso. Riprova." + "In attesa dell\'altro dispositivo" + "Il fornitore dell\'account potrebbe richiedere il seguente codice per verificare l\'accesso." + "Il tuo codice di verifica" "Chiamate, sondaggi, ricerche e altro ancora saranno aggiunti nel corso dell\'anno." "La cronologia dei messaggi per le stanze crittografate non è ancora disponibile." "Ci piacerebbe sentire il tuo parere, facci sapere cosa ne pensi tramite la pagina delle impostazioni." diff --git a/features/ftue/impl/src/main/res/values-ka/translations.xml b/features/ftue/impl/src/main/res/values-ka/translations.xml new file mode 100644 index 0000000000..04aacccf83 --- /dev/null +++ b/features/ftue/impl/src/main/res/values-ka/translations.xml @@ -0,0 +1,11 @@ + + + "თქვენ შეგიძლიათ შეცვალოთ თქვენი პარამეტრები მოგვიანებით." + "ყველა შეტყობინებაზე შეტყობინებების მიღება" + "ზარები, გამოკითხვები, ძიება და სხვა დაემატება ამ წლის ბოლოს." + "დაშიფრული ოთახებისთვის შეტყობინებების ისტორია ჯერ არ არის ხელმისაწვდომი." + "ჩვენ სიამოვნებით მოვისმინოთ თქვენგან, შეგვატყობინეთ რას ფიქრობთ პარამეტრების გვერდზე." + "დავიწყოთ!" + "აი, რა უნდა იცოდეთ:" + "კეთილი იყოს თქვენი მობრძანება %1$s-ში!" + diff --git a/features/ftue/impl/src/main/res/values-pt-rBR/translations.xml b/features/ftue/impl/src/main/res/values-pt-rBR/translations.xml new file mode 100644 index 0000000000..1635e12c74 --- /dev/null +++ b/features/ftue/impl/src/main/res/values-pt-rBR/translations.xml @@ -0,0 +1,40 @@ + + + "Podes alterar as tuas definições mais tarde." + "Permite as notificações e nunca percas uma mensagem" + "A estabelecer uma ligação segura" + "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" + "Ser-te-á pedido que insiras os dois dígitos indicados neste dispositivo." + "Insere o número abaixo no teu dispositivo" + "Pedido de início de sessão cancelado" + "Pronto para ler" + "Abre a %1$s num computador" + "Carrega no teu avatar" + "Seleciona %1$s" + "“Ligar novo dispositivo”" + "Lê o código QR com este dispositivo" + "Abre a %1$s noutro dispositivo para obteres o código QR" + "Lê o código QR apresentado no outro dispositivo." + "Tentar novamente" + "Código QR inválido" + "Ir para as configurações da câmara" + "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" + "Ler o código QR" + "Começar de novo" + "Ocorreu um erro inesperado. Tenta novamente." + "À espera do teu outro dispositivo" + "O teu fornecedor de conta pode pedir o seguinte código para verificar o início de sessão." + "O teu código de verificação" + "Chamadas, sondagens, pesquisa e mais funcionalidades vão ser adicionadas ao longo do ano." + "O histórico de mensagens em salas cifradas ainda não está disponível." + "Gostaríamos de ouvir a tua opinião, diz-nos o que pensas através da página de configurações." + "Vamos lá!" + "Eis o que tens de saber:" + "Bem-vindo à %1$s!" + diff --git a/features/ftue/impl/src/main/res/values-ro/translations.xml b/features/ftue/impl/src/main/res/values-ro/translations.xml index 492e333fe2..58482738d6 100644 --- a/features/ftue/impl/src/main/res/values-ro/translations.xml +++ b/features/ftue/impl/src/main/res/values-ro/translations.xml @@ -2,7 +2,34 @@ "Puteți modifica setările mai târziu." "Permiteți notificările și nu pierdeți niciodată un mesaj" + "Se stabilește o conexiune securizată" + "Nu a putut fi făcută o conexiune sigură la noul dispozitiv. Dispozitivele existente sunt încă în siguranță și nu trebuie să vă faceți griji cu privire la ele." + "Ș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ă" + "Vi se va cere să introduceți cele două cifre afișate pe acest dispozitiv." + "Introduceți numărul de mai jos pe celălalt dispozitiv" + "Gata de scanare" + "Deschideți %1$s pe un dispozitiv desktop" + "Faceți clic pe avatarul dumneavoastră" + "Selectați %1$s" + "„Conectați un dispozitiv nou”" + "Scanați codul QR cu acest dispozitiv" + "Deschideți %1$s pe un alt dispozitiv pentru a obține codul QR" + "Utilizați codul QR afișat pe celălalt dispozitiv." "Încercați din nou" + "Cod QR greșit" + "Mergeți la setările camerei" + "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" + "Scanați codul QR" + "Începeți din nou" + "A apărut o eroare neașteptată. Vă rugăm să încercați din nou." + "În așteptarea celuilalt dispozitiv" + "Furnizorul dumneavoastră de cont poate solicita următorul cod pentru a verifica conectarea." + "Codul dumneavoastră de verificare" "Apelurile, sondajele, căutare și multe altele vor fi adăugate în cursul acestui an." "Istoricul mesajelor pentru camerele criptate nu va fi disponibil în această actualizare." "Ne-ar plăcea să auzim de la dumneavoastră, spuneți-ne ce părere aveți prin intermediul paginii de setări." diff --git a/features/ftue/impl/src/main/res/values-ru/translations.xml b/features/ftue/impl/src/main/res/values-ru/translations.xml index 3fe5ceefc2..c4e48539f3 100644 --- a/features/ftue/impl/src/main/res/values-ru/translations.xml +++ b/features/ftue/impl/src/main/res/values-ru/translations.xml @@ -2,19 +2,33 @@ "Вы можете изменить настройки позже." "Разрешите уведомления и никогда не пропустите сообщение" - "Установление соединения" + "Установление безопасного соединения" "Не удалось установить безопасное соединение с новым устройством. Существующие устройства по-прежнему в безопасности, и вам не нужно беспокоиться о них." "Что теперь?" "Попробуйте снова войти в систему с помощью QR-кода, если это была сетевая проблема" "Если вы столкнулись с той же проблемой, попробуйте сменить точку доступа Wi-Fi или используйте мобильные данные" "Если это не помогло, войдите вручную" "Соединение не защищено" - "Вам будет предложено ввести две цифры, показанные ниже." - "Введите номер на своем устройстве" + "Вам нужно будет ввести две цифры, показанные на этом устройстве." + "Введите показанный номер на своем другом устройстве" + "Вход на другом устройстве был отменен." + "Запрос на вход отменен" + "Запрос не был принят на другом устройстве." + "Вход отклонен" + "Срок действия входа истек. Пожалуйста, попробуйте еще раз." + "Вход в систему не был выполнен вовремя" + "Другое устройство не поддерживает вход в %s с помощью QR-кода. + +Попробуйте войти вручную или отсканируйте QR-код на другом устройстве." + "QR-код не поддерживается" + "Поставщик учетной записи не поддерживает %1$s." + "%1$s не поддерживается" + "Готово к сканированию" "Откройте %1$s на настольном устройстве" "Нажмите на свое изображение" "Выбрать %1$s" "\"Привязать новое устройство\"" + "Отсканируйте QR-код с помощью этого устройства" "Откройте %1$s на другом устройстве, чтобы получить QR-код" "Используйте QR-код, показанный на другом устройстве." "Повторить попытку" diff --git a/features/ftue/impl/src/main/res/values-sk/translations.xml b/features/ftue/impl/src/main/res/values-sk/translations.xml index 593fe78182..6f4d8b9b9b 100644 --- a/features/ftue/impl/src/main/res/values-sk/translations.xml +++ b/features/ftue/impl/src/main/res/values-sk/translations.xml @@ -11,11 +11,24 @@ "Pripojenie nie je bezpečené" "Budete požiadaní o zadanie dvoch číslic zobrazených na tomto zariadení." "Zadajte nižšie uvedené číslo na vašom druhom zariadení" + "Prihlásenie bolo zrušené na druhom zariadení." + "Žiadosť o prihlásenie bola zrušená" + "Žiadosť na vašom druhom zariadení nebola prijatá." + "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ý" + "Pripravené na skenovanie" "Otvorte %1$s na stolnom zariadení" "Kliknite na svoj obrázok" "Vyberte %1$s" "„Prepojiť nové zariadenie“" - "Postupujte podľa zobrazených pokynov" + "Naskenujte QR kód pomocou tohto zariadenia" "Ak chcete získať QR kód, otvorte %1$s na inom zariadení" "Použite QR kód zobrazený na druhom zariadení." "Skúste to znova" diff --git a/features/ftue/impl/src/main/res/values-zh/translations.xml b/features/ftue/impl/src/main/res/values-zh/translations.xml new file mode 100644 index 0000000000..f6432fb3c6 --- /dev/null +++ b/features/ftue/impl/src/main/res/values-zh/translations.xml @@ -0,0 +1,38 @@ + + + "您可以稍后更改设置。" + "允许通知,绝不错过任何消息" + "建立安全连接" + "无法与新设备建立安全连接。您现有的设备仍然安全,无需担心。" + "现在怎么办?" + "如果这是网络问题,请尝试使用二维码再次登录" + "如果你遇到同样的问题,请尝试使用不同的 WiFi 网络或使用你的移动数据代替 WiFi" + "如果不起作用,请手动登录" + "连接不安全" + "您会被要求输入此设备上显示的两位数。" + "在您的其他设备上输入下面的数字" + "在桌面设备上打开 %1$s" + "点击你的头像" + "选择 %1$s" + "「连接新设备」" + "按照说明进行操作" + "在另一台设备上打开 %1$s 以获取二维码" + "使用其他设备上显示的二维码。" + "再试一次" + "二维码错误" + "转到摄像头设置" + "您需要授予 %1$s 使用设备摄像头的权限才能继续。" + "允许摄像头权限以扫描 QR 码" + "扫描二维码" + "重新开始" + "发生了意外错误。请再试一次。" + "等着您的其他设备" + "您的账户提供商可能会要求您提供以下代码来验证登录。" + "您的验证码" + "今年晚些时候将增加通话、投票、搜索等功能。" + "加密房间的消息历史记录尚不可用。" + "我们很乐意听取您的意见,请通过设置页面告诉我们您的想法。" + "开始吧!" + "以下是您需要了解的内容:" + "欢迎使用 %1$s" + diff --git a/features/ftue/impl/src/main/res/values/localazy.xml b/features/ftue/impl/src/main/res/values/localazy.xml index 9264bfd4b1..4038f21908 100644 --- a/features/ftue/impl/src/main/res/values/localazy.xml +++ b/features/ftue/impl/src/main/res/values/localazy.xml @@ -11,11 +11,24 @@ "Connection not secure" "You’ll be asked to enter the two digits shown on this device." "Enter the number below on your other device" + "The sign in was cancelled on the other device." + "Sign in request cancelled" + "The request on your other device was not accepted." + "Sign in declined" + "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" + "Ready to scan" "Open %1$s on a desktop device" "Click on your avatar" "Select %1$s" "“Link new device”" - "Follow the instructions shown" + "Scan the QR code with this device" "Open %1$s on another device to get the QR code" "Use the QR code shown on the other device." "Try again" diff --git a/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/welcome/state/FakeWelcomeState.kt b/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/welcome/state/FakeWelcomeScreenState.kt similarity index 94% rename from features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/welcome/state/FakeWelcomeState.kt rename to features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/welcome/state/FakeWelcomeScreenState.kt index 6b4d4b2287..66ffe24285 100644 --- a/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/welcome/state/FakeWelcomeState.kt +++ b/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/welcome/state/FakeWelcomeScreenState.kt @@ -16,7 +16,7 @@ package io.element.android.features.ftue.impl.welcome.state -class FakeWelcomeState : WelcomeScreenState { +class FakeWelcomeScreenState : WelcomeScreenState { private var isWelcomeScreenNeeded = true override fun isWelcomeScreenNeeded(): Boolean { diff --git a/features/invite/api/build.gradle.kts b/features/invite/api/build.gradle.kts index 52df82e38a..95fceb41f2 100644 --- a/features/invite/api/build.gradle.kts +++ b/features/invite/api/build.gradle.kts @@ -25,4 +25,5 @@ android { dependencies { implementation(projects.libraries.architecture) implementation(projects.libraries.matrix.api) + implementation(projects.services.analytics.api) } diff --git a/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/response/AcceptDeclineInviteView.kt b/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/response/AcceptDeclineInviteView.kt index c969458122..02afe4fe94 100644 --- a/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/response/AcceptDeclineInviteView.kt +++ b/features/invite/api/src/main/kotlin/io/element/android/features/invite/api/response/AcceptDeclineInviteView.kt @@ -24,8 +24,8 @@ interface AcceptDeclineInviteView { @Composable fun Render( state: AcceptDeclineInviteState, - onInviteAccepted: (RoomId) -> Unit, - onInviteDeclined: (RoomId) -> Unit, + onAcceptInvite: (RoomId) -> Unit, + onDeclineInvite: (RoomId) -> Unit, modifier: Modifier, ) } diff --git a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInvitePresenter.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInvitePresenter.kt index ec18aea045..0972eb3eb8 100644 --- a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInvitePresenter.kt +++ b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInvitePresenter.kt @@ -33,9 +33,8 @@ import io.element.android.libraries.architecture.runCatchingUpdatingState import io.element.android.libraries.architecture.runUpdatingState 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.join.JoinRoom import io.element.android.libraries.push.api.notifications.NotificationDrawerManager -import io.element.android.services.analytics.api.AnalyticsService -import io.element.android.services.analytics.api.extensions.toAnalyticsJoinedRoom import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import java.util.Optional @@ -44,7 +43,7 @@ import kotlin.jvm.optionals.getOrNull class AcceptDeclineInvitePresenter @Inject constructor( private val client: MatrixClient, - private val analyticsService: AnalyticsService, + private val joinRoom: JoinRoom, private val notificationDrawerManager: NotificationDrawerManager, ) : Presenter { @Composable @@ -59,9 +58,11 @@ class AcceptDeclineInvitePresenter @Inject constructor( fun handleEvents(event: AcceptDeclineInviteEvents) { when (event) { is AcceptDeclineInviteEvents.AcceptInvite -> { - currentInvite = Optional.of(event.invite) - localCoroutineScope.acceptInvite(event.invite.roomId, acceptedAction) + // currentInvite is used to render the decline confirmation dialog + // and to reuse the roomId when the user confirm the rejection of the invitation. + // Just set it to empty here. currentInvite = Optional.empty() + localCoroutineScope.acceptInvite(event.invite.roomId, acceptedAction) } is AcceptDeclineInviteEvents.DeclineInvite -> { @@ -100,14 +101,18 @@ class AcceptDeclineInvitePresenter @Inject constructor( ) } - private fun CoroutineScope.acceptInvite(roomId: RoomId, acceptedAction: MutableState>) = launch { + private fun CoroutineScope.acceptInvite( + roomId: RoomId, + acceptedAction: MutableState>, + ) = launch { acceptedAction.runUpdatingState { - client.joinRoom(roomId) + joinRoom( + roomId = roomId, + serverNames = emptyList(), + trigger = JoinedRoom.Trigger.Invite, + ) .onSuccess { - notificationDrawerManager.clearMembershipNotificationForRoom(client.sessionId, roomId, doRender = true) - client.getRoom(roomId)?.use { room -> - analyticsService.capture(room.toAnalyticsJoinedRoom(JoinedRoom.Trigger.Invite)) - } + notificationDrawerManager.clearMembershipNotificationForRoom(client.sessionId, roomId) } .map { roomId } } @@ -117,7 +122,7 @@ class AcceptDeclineInvitePresenter @Inject constructor( suspend { client.getRoom(roomId)?.use { it.leave().getOrThrow() - notificationDrawerManager.clearMembershipNotificationForRoom(client.sessionId, roomId, doRender = true) + notificationDrawerManager.clearMembershipNotificationForRoom(client.sessionId, roomId) } roomId }.runCatchingUpdatingState(declinedAction) diff --git a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInviteView.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInviteView.kt index 3f229fbe85..38615fa553 100644 --- a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInviteView.kt +++ b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInviteView.kt @@ -36,21 +36,21 @@ import kotlin.jvm.optionals.getOrNull @Composable fun AcceptDeclineInviteView( state: AcceptDeclineInviteState, - onInviteAccepted: (RoomId) -> Unit, - onInviteDeclined: (RoomId) -> Unit, + onAcceptInvite: (RoomId) -> Unit, + onDeclineInvite: (RoomId) -> Unit, modifier: Modifier = Modifier, ) { Box(modifier = modifier) { AsyncActionView( async = state.acceptAction, - onSuccess = onInviteAccepted, + onSuccess = onAcceptInvite, onErrorDismiss = { state.eventSink(InternalAcceptDeclineInviteEvents.DismissAcceptError) }, ) AsyncActionView( async = state.declineAction, - onSuccess = onInviteDeclined, + onSuccess = onDeclineInvite, onErrorDismiss = { state.eventSink(InternalAcceptDeclineInviteEvents.DismissDeclineError) }, @@ -59,10 +59,10 @@ fun AcceptDeclineInviteView( if (invite != null) { DeclineConfirmationDialog( invite = invite, - onConfirmClicked = { + onConfirmClick = { state.eventSink(InternalAcceptDeclineInviteEvents.ConfirmDeclineInvite) }, - onDismissClicked = { + onDismissClick = { state.eventSink(InternalAcceptDeclineInviteEvents.CancelDeclineInvite) } ) @@ -75,8 +75,8 @@ fun AcceptDeclineInviteView( @Composable private fun DeclineConfirmationDialog( invite: InviteData, - onConfirmClicked: () -> Unit, - onDismissClicked: () -> Unit, + onConfirmClick: () -> Unit, + onDismissClick: () -> Unit, modifier: Modifier = Modifier ) { val contentResource = if (invite.isDirect) { @@ -97,8 +97,8 @@ private fun DeclineConfirmationDialog( title = stringResource(titleResource), submitText = stringResource(CommonStrings.action_decline), cancelText = stringResource(CommonStrings.action_cancel), - onSubmitClicked = onConfirmClicked, - onDismiss = onDismissClicked, + onSubmitClick = onConfirmClick, + onDismiss = onDismissClick, ) } @@ -108,7 +108,7 @@ internal fun AcceptDeclineInviteViewPreview(@PreviewParameter(AcceptDeclineInvit ElementPreview { AcceptDeclineInviteView( state = state, - onInviteAccepted = {}, - onInviteDeclined = {}, + onAcceptInvite = {}, + onDeclineInvite = {}, ) } diff --git a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInviteViewWrapper.kt b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInviteViewWrapper.kt index a86b220364..14080cdf12 100644 --- a/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInviteViewWrapper.kt +++ b/features/invite/impl/src/main/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInviteViewWrapper.kt @@ -30,14 +30,14 @@ class AcceptDeclineInviteViewWrapper @Inject constructor() : AcceptDeclineInvite @Composable override fun Render( state: AcceptDeclineInviteState, - onInviteAccepted: (RoomId) -> Unit, - onInviteDeclined: (RoomId) -> Unit, + onAcceptInvite: (RoomId) -> Unit, + onDeclineInvite: (RoomId) -> Unit, modifier: Modifier, ) { AcceptDeclineInviteView( state = state, - onInviteAccepted = onInviteAccepted, - onInviteDeclined = onInviteDeclined, + onAcceptInvite = onAcceptInvite, + onDeclineInvite = onDeclineInvite, modifier = modifier ) } diff --git a/features/invite/impl/src/main/res/values-ka/translations.xml b/features/invite/impl/src/main/res/values-ka/translations.xml new file mode 100644 index 0000000000..3accaf9eda --- /dev/null +++ b/features/invite/impl/src/main/res/values-ka/translations.xml @@ -0,0 +1,9 @@ + + + "დარწმუნებული ხართ, რომ გსურთ, უარი თქვათ მოწვევაზე %1$s-ში?" + "მოწვევაზე უარის თქმა" + "დარწმუნებული ხართ, რომ გსურთ, უარი თქვათ ჩატზე %1$s-თან?" + "ჩატზე უარის თქვა" + "მოწვევები არ არის" + "%1$s (%2$s) მოგიწვიათ" + diff --git a/features/invite/impl/src/main/res/values-pt-rBR/translations.xml b/features/invite/impl/src/main/res/values-pt-rBR/translations.xml new file mode 100644 index 0000000000..1bdde3eaee --- /dev/null +++ b/features/invite/impl/src/main/res/values-pt-rBR/translations.xml @@ -0,0 +1,9 @@ + + + "Tens a certeza que queres rejeitar o convite para %1$s?" + "Rejeitar conite" + "Tens a certeza que queres rejeitar esta conversa privada com %1$s?" + "Rejeitar conversa" + "Sem convites" + "%1$s (%2$s) convidou-te" + diff --git a/features/invite/impl/src/main/res/values-zh/translations.xml b/features/invite/impl/src/main/res/values-zh/translations.xml new file mode 100644 index 0000000000..3d25434438 --- /dev/null +++ b/features/invite/impl/src/main/res/values-zh/translations.xml @@ -0,0 +1,9 @@ + + + "您确定要拒绝加入 %1$s 的邀请吗?" + "拒绝邀请" + "您确定要拒绝与 %1$s 开始私聊吗?" + "拒绝聊天" + "没有邀请" + "%1$s (%2$s)邀请了你" + diff --git a/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInvitePresenterTest.kt b/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInvitePresenterTest.kt index 90dcc104d4..72552b9ed1 100644 --- a/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInvitePresenterTest.kt +++ b/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInvitePresenterTest.kt @@ -17,6 +17,7 @@ package io.element.android.features.invite.impl.response import com.google.common.truth.Truth.assertThat +import im.vector.app.features.analytics.plan.JoinedRoom import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents import io.element.android.features.invite.api.response.InviteData import io.element.android.libraries.architecture.AsyncAction @@ -26,13 +27,13 @@ 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.FakeMatrixClient import io.element.android.libraries.matrix.test.room.FakeMatrixRoom +import io.element.android.libraries.matrix.test.room.join.FakeJoinRoom import io.element.android.libraries.push.api.notifications.NotificationDrawerManager import io.element.android.libraries.push.test.notifications.FakeNotificationDrawerManager -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.lambda.assert 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 @@ -163,13 +164,10 @@ class AcceptDeclineInvitePresenterTest { @Test fun `present - accepting invite error flow`() = runTest { - val joinRoomFailure = lambdaRecorder { roomId: RoomId -> + val joinRoomFailure = lambdaRecorder { roomId: RoomId, _: List, _: JoinedRoom.Trigger -> Result.failure(RuntimeException("Failed to join room $roomId")) } - val client = FakeMatrixClient().apply { - joinRoomLambda = joinRoomFailure - } - val presenter = createAcceptDeclineInvitePresenter(client = client) + val presenter = createAcceptDeclineInvitePresenter(joinRoomLambda = joinRoomFailure) presenter.test { val inviteData = anInviteData() awaitItem().also { state -> @@ -177,33 +175,37 @@ class AcceptDeclineInvitePresenterTest { AcceptDeclineInviteEvents.AcceptInvite(inviteData) ) } - skipItems(1) awaitItem().also { state -> - assertThat(state.invite).isEqualTo(Optional.of(inviteData)) + assertThat(state.invite).isEqualTo(Optional.empty()) + assertThat(state.acceptAction).isEqualTo(AsyncAction.Loading) + } + awaitItem().also { state -> assertThat(state.acceptAction).isInstanceOf(AsyncAction.Failure::class.java) state.eventSink( InternalAcceptDeclineInviteEvents.DismissAcceptError ) } - skipItems(1) awaitItem().also { state -> assertThat(state.invite).isEqualTo(Optional.empty()) assertThat(state.acceptAction).isInstanceOf(AsyncAction.Uninitialized::class.java) } cancelAndConsumeRemainingEvents() } - assert(joinRoomFailure).isCalledOnce() + assert(joinRoomFailure) + .isCalledOnce() + .with( + value(A_ROOM_ID), + value(emptyList()), + value(JoinedRoom.Trigger.Invite) + ) } @Test fun `present - accepting invite success flow`() = runTest { - val joinRoomSuccess = lambdaRecorder { _: RoomId -> + val joinRoomSuccess = lambdaRecorder { _: RoomId, _: List, _: JoinedRoom.Trigger -> Result.success(Unit) } - val client = FakeMatrixClient().apply { - joinRoomLambda = joinRoomSuccess - } - val presenter = createAcceptDeclineInvitePresenter(client = client) + val presenter = createAcceptDeclineInvitePresenter(joinRoomLambda = joinRoomSuccess) presenter.test { val inviteData = anInviteData() awaitItem().also { state -> @@ -211,14 +213,22 @@ class AcceptDeclineInvitePresenterTest { AcceptDeclineInviteEvents.AcceptInvite(inviteData) ) } - skipItems(1) awaitItem().also { state -> - assertThat(state.invite).isEqualTo(Optional.of(inviteData)) + assertThat(state.invite).isEqualTo(Optional.empty()) + assertThat(state.acceptAction).isEqualTo(AsyncAction.Loading) + } + awaitItem().also { state -> assertThat(state.acceptAction).isInstanceOf(AsyncAction.Success::class.java) } cancelAndConsumeRemainingEvents() } - assert(joinRoomSuccess).isCalledOnce() + assert(joinRoomSuccess) + .isCalledOnce() + .with( + value(A_ROOM_ID), + value(emptyList()), + value(JoinedRoom.Trigger.Invite) + ) } private fun anInviteData( @@ -235,12 +245,14 @@ class AcceptDeclineInvitePresenterTest { private fun createAcceptDeclineInvitePresenter( client: MatrixClient = FakeMatrixClient(), - analyticsService: AnalyticsService = FakeAnalyticsService(), + joinRoomLambda: (RoomId, List, JoinedRoom.Trigger) -> Result = { _, _, _ -> + Result.success(Unit) + }, notificationDrawerManager: NotificationDrawerManager = FakeNotificationDrawerManager(), ): AcceptDeclineInvitePresenter { return AcceptDeclineInvitePresenter( client = client, - analyticsService = analyticsService, + joinRoom = FakeJoinRoom(joinRoomLambda), notificationDrawerManager = notificationDrawerManager, ) } diff --git a/features/joinroom/api/build.gradle.kts b/features/joinroom/api/build.gradle.kts index a016c2d195..461723f785 100644 --- a/features/joinroom/api/build.gradle.kts +++ b/features/joinroom/api/build.gradle.kts @@ -26,4 +26,5 @@ dependencies { implementation(projects.libraries.architecture) implementation(projects.libraries.matrix.api) implementation(projects.features.roomdirectory.api) + implementation(projects.services.analytics.api) } diff --git a/features/joinroom/api/src/main/kotlin/io/element/android/features/joinroom/api/JoinRoomEntryPoint.kt b/features/joinroom/api/src/main/kotlin/io/element/android/features/joinroom/api/JoinRoomEntryPoint.kt index d62a9819c9..04ca23340e 100644 --- a/features/joinroom/api/src/main/kotlin/io/element/android/features/joinroom/api/JoinRoomEntryPoint.kt +++ b/features/joinroom/api/src/main/kotlin/io/element/android/features/joinroom/api/JoinRoomEntryPoint.kt @@ -18,6 +18,7 @@ package io.element.android.features.joinroom.api import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.node.Node +import im.vector.app.features.analytics.plan.JoinedRoom import io.element.android.features.roomdirectory.api.RoomDescription import io.element.android.libraries.architecture.FeatureEntryPoint import io.element.android.libraries.architecture.NodeInputs @@ -32,5 +33,7 @@ interface JoinRoomEntryPoint : FeatureEntryPoint { val roomId: RoomId, val roomIdOrAlias: RoomIdOrAlias, val roomDescription: Optional, + val serverNames: List, + val trigger: JoinedRoom.Trigger, ) : NodeInputs } diff --git a/features/joinroom/impl/build.gradle.kts b/features/joinroom/impl/build.gradle.kts index 33fdbb6b47..319f15eea4 100644 --- a/features/joinroom/impl/build.gradle.kts +++ b/features/joinroom/impl/build.gradle.kts @@ -44,9 +44,10 @@ dependencies { implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrixui) implementation(projects.libraries.designsystem) + implementation(projects.libraries.uiStrings) implementation(projects.features.invite.api) implementation(projects.features.roomdirectory.api) - implementation(projects.libraries.uiStrings) + implementation(projects.services.analytics.api) testImplementation(libs.test.junit) testImplementation(libs.coroutines.test) diff --git a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomNode.kt b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomNode.kt index 5d302abc8b..fa320a6545 100644 --- a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomNode.kt +++ b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomNode.kt @@ -41,6 +41,8 @@ class JoinRoomNode @AssistedInject constructor( inputs.roomId, inputs.roomIdOrAlias, inputs.roomDescription, + inputs.serverNames, + inputs.trigger, ) @Composable @@ -48,14 +50,15 @@ class JoinRoomNode @AssistedInject constructor( val state = presenter.present() JoinRoomView( state = state, - onBackPressed = ::navigateUp, + onBackClick = ::navigateUp, + onJoinSuccess = ::navigateUp, onKnockSuccess = ::navigateUp, modifier = modifier ) acceptDeclineInviteView.Render( state = state.acceptDeclineInviteState, - onInviteAccepted = {}, - onInviteDeclined = { navigateUp() }, + onAcceptInvite = {}, + onDeclineInvite = { navigateUp() }, modifier = Modifier ) } diff --git a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenter.kt b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenter.kt index ea88150c78..5f1efe827e 100644 --- a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenter.kt +++ b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenter.kt @@ -29,6 +29,7 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import dagger.assisted.Assisted import dagger.assisted.AssistedInject +import im.vector.app.features.analytics.plan.JoinedRoom import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents import io.element.android.features.invite.api.response.AcceptDeclineInviteState import io.element.android.features.invite.api.response.InviteData @@ -41,10 +42,10 @@ import io.element.android.libraries.core.meta.BuildMeta 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.CurrentUserMembership import io.element.android.libraries.matrix.api.room.MatrixRoomInfo import io.element.android.libraries.matrix.api.room.RoomType +import io.element.android.libraries.matrix.api.room.join.JoinRoom import io.element.android.libraries.matrix.api.room.preview.RoomPreview import io.element.android.libraries.matrix.ui.model.toInviteSender import kotlinx.coroutines.CoroutineScope @@ -55,7 +56,10 @@ class JoinRoomPresenter @AssistedInject constructor( @Assisted private val roomId: RoomId, @Assisted private val roomIdOrAlias: RoomIdOrAlias, @Assisted private val roomDescription: Optional, + @Assisted private val serverNames: List, + @Assisted private val trigger: JoinedRoom.Trigger, private val matrixClient: MatrixClient, + private val joinRoom: JoinRoom, private val knockRoom: KnockRoom, private val acceptDeclineInvitePresenter: Presenter, private val buildMeta: BuildMeta, @@ -65,6 +69,8 @@ class JoinRoomPresenter @AssistedInject constructor( roomId: RoomId, roomIdOrAlias: RoomIdOrAlias, roomDescription: Optional, + serverNames: List, + trigger: JoinedRoom.Trigger, ): JoinRoomPresenter } @@ -73,6 +79,7 @@ class JoinRoomPresenter @AssistedInject constructor( val coroutineScope = rememberCoroutineScope() var retryCount by remember { mutableIntStateOf(0) } val roomInfo by matrixClient.getRoomInfoFlow(roomId).collectAsState(initial = Optional.empty()) + val joinAction: MutableState> = remember { mutableStateOf(AsyncAction.Uninitialized) } val knockAction: MutableState> = remember { mutableStateOf(AsyncAction.Uninitialized) } val contentState by produceState( initialValue = ContentState.Loading(roomIdOrAlias), @@ -88,7 +95,7 @@ class JoinRoomPresenter @AssistedInject constructor( } else -> { value = ContentState.Loading(roomIdOrAlias) - val result = matrixClient.getRoomPreview(roomId.toRoomIdOrAlias()) + val result = matrixClient.getRoomPreviewFromRoomId(roomId, serverNames) value = result.fold( onSuccess = { roomPreview -> roomPreview.toContentState() @@ -108,16 +115,14 @@ class JoinRoomPresenter @AssistedInject constructor( fun handleEvents(event: JoinRoomEvents) { when (event) { - JoinRoomEvents.AcceptInvite, - JoinRoomEvents.JoinRoom -> { + JoinRoomEvents.JoinRoom -> coroutineScope.joinRoom(joinAction) + JoinRoomEvents.KnockRoom -> coroutineScope.knockRoom(knockAction) + JoinRoomEvents.AcceptInvite -> { val inviteData = contentState.toInviteData() ?: return acceptDeclineInviteState.eventSink( AcceptDeclineInviteEvents.AcceptInvite(inviteData) ) } - JoinRoomEvents.KnockRoom -> { - coroutineScope.knockRoom(roomId, knockAction) - } JoinRoomEvents.DeclineInvite -> { val inviteData = contentState.toInviteData() ?: return acceptDeclineInviteState.eventSink( @@ -129,6 +134,7 @@ class JoinRoomPresenter @AssistedInject constructor( } JoinRoomEvents.ClearError -> { knockAction.value = AsyncAction.Uninitialized + joinAction.value = AsyncAction.Uninitialized } } } @@ -136,13 +142,24 @@ class JoinRoomPresenter @AssistedInject constructor( return JoinRoomState( contentState = contentState, acceptDeclineInviteState = acceptDeclineInviteState, + joinAction = joinAction.value, knockAction = knockAction.value, applicationName = buildMeta.applicationName, eventSink = ::handleEvents ) } - private fun CoroutineScope.knockRoom(roomId: RoomId, knockAction: MutableState>) = launch { + private fun CoroutineScope.joinRoom(joinAction: MutableState>) = launch { + joinAction.runUpdatingState { + joinRoom.invoke( + roomId = roomId, + serverNames = serverNames, + trigger = trigger + ) + } + } + + private fun CoroutineScope.knockRoom(knockAction: MutableState>) = launch { knockAction.runUpdatingState { knockRoom(roomId) } diff --git a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomState.kt b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomState.kt index 9146b13513..0f849aac24 100644 --- a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomState.kt +++ b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomState.kt @@ -31,6 +31,7 @@ import io.element.android.libraries.matrix.ui.model.InviteSender data class JoinRoomState( val contentState: ContentState, val acceptDeclineInviteState: AcceptDeclineInviteState, + val joinAction: AsyncAction, val knockAction: AsyncAction, val applicationName: String, val eventSink: (JoinRoomEvents) -> Unit diff --git a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomStateProvider.kt b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomStateProvider.kt index ee08ee954a..f897026600 100644 --- a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomStateProvider.kt +++ b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomStateProvider.kt @@ -125,11 +125,13 @@ fun aLoadedContentState( fun aJoinRoomState( contentState: ContentState = aLoadedContentState(), acceptDeclineInviteState: AcceptDeclineInviteState = anAcceptDeclineInviteState(), + joinAction: AsyncAction = AsyncAction.Uninitialized, knockAction: AsyncAction = AsyncAction.Uninitialized, eventSink: (JoinRoomEvents) -> Unit = {} ) = JoinRoomState( contentState = contentState, acceptDeclineInviteState = acceptDeclineInviteState, + joinAction = joinAction, knockAction = knockAction, applicationName = "AppName", eventSink = eventSink diff --git a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomView.kt b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomView.kt index 49541938d2..2a4989e613 100644 --- a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomView.kt +++ b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomView.kt @@ -65,7 +65,8 @@ import io.element.android.libraries.ui.strings.CommonStrings @Composable fun JoinRoomView( state: JoinRoomState, - onBackPressed: () -> Unit, + onBackClick: () -> Unit, + onJoinSuccess: () -> Unit, onKnockSuccess: () -> Unit, modifier: Modifier = Modifier, ) { @@ -77,7 +78,7 @@ fun JoinRoomView( containerColor = Color.Transparent, paddingValues = PaddingValues(16.dp), topBar = { - JoinRoomTopBar(onBackClicked = onBackPressed) + JoinRoomTopBar(onBackClick = onBackClick) }, content = { JoinRoomContent( @@ -103,12 +104,16 @@ fun JoinRoomView( onRetry = { state.eventSink(JoinRoomEvents.RetryFetchingContent) }, - onGoBack = onBackPressed, + onGoBack = onBackClick, ) } ) } - + AsyncActionView( + async = state.joinAction, + onSuccess = { onJoinSuccess() }, + onErrorDismiss = { state.eventSink(JoinRoomEvents.ClearError) }, + ) AsyncActionView( async = state.knockAction, onSuccess = { onKnockSuccess() }, @@ -307,11 +312,11 @@ private fun JoinRoomContent( @OptIn(ExperimentalMaterial3Api::class) @Composable private fun JoinRoomTopBar( - onBackClicked: () -> Unit, + onBackClick: () -> Unit, ) { TopAppBar( navigationIcon = { - BackButton(onClick = onBackClicked) + BackButton(onClick = onBackClick) }, title = {}, ) @@ -322,7 +327,8 @@ private fun JoinRoomTopBar( internal fun JoinRoomViewPreview(@PreviewParameter(JoinRoomStateProvider::class) state: JoinRoomState) = ElementPreview { JoinRoomView( state = state, - onBackPressed = { }, + onBackClick = { }, + onJoinSuccess = { }, onKnockSuccess = { }, ) } diff --git a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/di/JoinRoomModule.kt b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/di/JoinRoomModule.kt index c288021cdf..d1154eedae 100644 --- a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/di/JoinRoomModule.kt +++ b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/di/JoinRoomModule.kt @@ -19,6 +19,7 @@ package io.element.android.features.joinroom.impl.di import com.squareup.anvil.annotations.ContributesTo import dagger.Module import dagger.Provides +import im.vector.app.features.analytics.plan.JoinedRoom import io.element.android.features.invite.api.response.AcceptDeclineInviteState import io.element.android.features.joinroom.impl.JoinRoomPresenter import io.element.android.features.roomdirectory.api.RoomDescription @@ -28,6 +29,7 @@ import io.element.android.libraries.di.SessionScope 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.room.join.JoinRoom import java.util.Optional @Module @@ -36,6 +38,7 @@ object JoinRoomModule { @Provides fun providesJoinRoomPresenterFactory( client: MatrixClient, + joinRoom: JoinRoom, knockRoom: KnockRoom, acceptDeclineInvitePresenter: Presenter, buildMeta: BuildMeta, @@ -45,12 +48,17 @@ object JoinRoomModule { roomId: RoomId, roomIdOrAlias: RoomIdOrAlias, roomDescription: Optional, + serverNames: List, + trigger: JoinedRoom.Trigger, ): JoinRoomPresenter { return JoinRoomPresenter( roomId = roomId, roomIdOrAlias = roomIdOrAlias, roomDescription = roomDescription, + serverNames = serverNames, + trigger = trigger, matrixClient = client, + joinRoom = joinRoom, knockRoom = knockRoom, acceptDeclineInvitePresenter = acceptDeclineInvitePresenter, buildMeta = buildMeta, diff --git a/features/joinroom/impl/src/main/res/values-it/translations.xml b/features/joinroom/impl/src/main/res/values-it/translations.xml new file mode 100644 index 0000000000..55af91d9d7 --- /dev/null +++ b/features/joinroom/impl/src/main/res/values-it/translations.xml @@ -0,0 +1,11 @@ + + + "Entra nella stanza" + "Bussa per partecipare" + "%1$s non supporta ancora gli spazi. Puoi accedere agli spazi sul web." + "Gli spazi non sono ancora supportati" + "Clicca sul pulsante qui sotto e un amministratore della stanza riceverà una notifica. Potrai partecipare alla conversazione una volta approvato." + "Per visualizzare la cronologia dei messaggi devi essere un membro di questa stanza." + "Vuoi entrare in questa stanza?" + "L\'anteprima non è disponibile" + diff --git a/features/joinroom/impl/src/main/res/values-pt-rBR/translations.xml b/features/joinroom/impl/src/main/res/values-pt-rBR/translations.xml new file mode 100644 index 0000000000..4dc482327e --- /dev/null +++ b/features/joinroom/impl/src/main/res/values-pt-rBR/translations.xml @@ -0,0 +1,11 @@ + + + "Entrar na sala" + "Bater à porta" + "A %1$s ainda não funciona com espaços. Podes usá-los na aplicação web." + "Os espaços ainda não estão implementados" + "Carrega no botão abaixo para notificar um administrador da sala. Poderás entrar quando te aprovarem." + "Apenas os participantes podem ver o histórico de mensagens." + "Queres entrar nesta sala?" + "Pré-visualização indisponível" + diff --git a/features/joinroom/impl/src/main/res/values-ro/translations.xml b/features/joinroom/impl/src/main/res/values-ro/translations.xml new file mode 100644 index 0000000000..a31d63053c --- /dev/null +++ b/features/joinroom/impl/src/main/res/values-ro/translations.xml @@ -0,0 +1,11 @@ + + + "Alăturați-vă camerei" + "Bateți pentru a vă alătura" + "%1$s nu suporta încă spații. Puteți accesa spațiile pe web." + "Spațiile nu sunt încă suportate" + "Faceți clic pe butonul de mai jos și un administrator de cameră va fi notificat. Veți putea să vă alăturați conversației odată aprobată." + "Trebuie să fiți membru al acestei camere pentru a vizualiza istoricul mesajelor." + "Doriți să vă alăturați acestei camere?" + "Previzualizare indisponibilă" + diff --git a/features/joinroom/impl/src/main/res/values-ru/translations.xml b/features/joinroom/impl/src/main/res/values-ru/translations.xml index cec44b1c8c..c01bea738c 100644 --- a/features/joinroom/impl/src/main/res/values-ru/translations.xml +++ b/features/joinroom/impl/src/main/res/values-ru/translations.xml @@ -2,6 +2,8 @@ "Присоединиться к комнате" "Постучите, чтобы присоединиться" + "%1$s еще не поддерживает пространства. Вы можете получить к ним доступ в веб-версии." + "Пространства пока не поддерживаются" "Нажмите кнопку ниже и администратор комнаты получит уведомление. После одобрения вы сможете присоединиться к обсуждению." "Вы должны быть участником этой комнаты, чтобы просмотреть историю сообщений." "Хотите присоединиться к этой комнате?" diff --git a/features/joinroom/impl/src/main/res/values-zh/translations.xml b/features/joinroom/impl/src/main/res/values-zh/translations.xml new file mode 100644 index 0000000000..02a9f290b8 --- /dev/null +++ b/features/joinroom/impl/src/main/res/values-zh/translations.xml @@ -0,0 +1,11 @@ + + + "加入聊天室" + "加入房间" + "%1$s 尚不支持空间。您可以通过 Web 端访问空间" + "空间尚不支持" + "点击下面的按钮,系统将通知房间管理员。获得批准后,您将能够加入对话。" + "只有聊天室成员才能查看消息历史记录。" + "想加入这个房间吗?" + "预览不可用" + diff --git a/features/joinroom/impl/src/test/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenterTest.kt b/features/joinroom/impl/src/test/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenterTest.kt index 3928ca31e8..2a054b820f 100644 --- a/features/joinroom/impl/src/test/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenterTest.kt +++ b/features/joinroom/impl/src/test/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenterTest.kt @@ -17,6 +17,7 @@ package io.element.android.features.joinroom.impl import com.google.common.truth.Truth.assertThat +import im.vector.app.features.analytics.plan.JoinedRoom import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents import io.element.android.features.invite.api.response.AcceptDeclineInviteState import io.element.android.features.invite.api.response.anAcceptDeclineInviteState @@ -36,10 +37,12 @@ import io.element.android.libraries.matrix.api.room.preview.RoomPreview 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_NAME +import io.element.android.libraries.matrix.test.A_SERVER_LIST import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.matrix.test.room.aRoomInfo import io.element.android.libraries.matrix.test.room.aRoomMember +import io.element.android.libraries.matrix.test.room.join.FakeJoinRoom import io.element.android.libraries.matrix.ui.model.toInviteSender import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.lambda.assert @@ -174,6 +177,59 @@ class JoinRoomPresenterTest { } } + @Test + fun `present - when room is joined with success, all the parameters are provided`() = runTest { + val aTrigger = JoinedRoom.Trigger.MobilePermalink + val joinRoomLambda = lambdaRecorder { _: RoomId, _: List, _: JoinedRoom.Trigger -> + Result.success(Unit) + } + val presenter = createJoinRoomPresenter( + trigger = aTrigger, + serverNames = A_SERVER_LIST, + joinRoomLambda = joinRoomLambda, + ) + presenter.test { + skipItems(1) + awaitItem().also { state -> + state.eventSink(JoinRoomEvents.JoinRoom) + } + awaitItem().also { state -> + assertThat(state.joinAction).isEqualTo(AsyncAction.Loading) + } + awaitItem().also { state -> + assertThat(state.joinAction).isEqualTo(AsyncAction.Success(Unit)) + } + joinRoomLambda.assertions() + .isCalledOnce() + .with(value(A_ROOM_ID), value(A_SERVER_LIST), value(aTrigger)) + } + } + + @Test + fun `present - when room is joined with error, it is possible to clear the error`() = runTest { + val presenter = createJoinRoomPresenter( + joinRoomLambda = { _, _, _ -> + Result.failure(AN_EXCEPTION) + }, + ) + presenter.test { + skipItems(1) + awaitItem().also { state -> + state.eventSink(JoinRoomEvents.JoinRoom) + } + awaitItem().also { state -> + assertThat(state.joinAction).isEqualTo(AsyncAction.Loading) + } + awaitItem().also { state -> + assertThat(state.joinAction).isEqualTo(AsyncAction.Failure(AN_EXCEPTION)) + state.eventSink(JoinRoomEvents.ClearError) + } + awaitItem().also { state -> + assertThat(state.joinAction).isEqualTo(AsyncAction.Uninitialized) + } + } + } + @Test fun `present - when room is left and public then join authorization is equal to canJoin`() = runTest { val roomInfo = aRoomInfo(currentUserMembership = CurrentUserMembership.LEFT, isPublic = true) @@ -310,7 +366,7 @@ class JoinRoomPresenterTest { @Test fun `present - when room is not known RoomPreview is loaded`() = runTest { val client = FakeMatrixClient( - getRoomPreviewResult = { + getRoomPreviewFromRoomIdResult = { _, _ -> Result.success( RoomPreview( roomId = A_ROOM_ID, @@ -355,7 +411,7 @@ class JoinRoomPresenterTest { @Test fun `present - when room is not known RoomPreview is loaded with error`() = runTest { val client = FakeMatrixClient( - getRoomPreviewResult = { + getRoomPreviewFromRoomIdResult = { _, _ -> Result.failure(AN_EXCEPTION) } ) @@ -393,7 +449,7 @@ class JoinRoomPresenterTest { @Test fun `present - when room is not known RoomPreview is loaded with error 403`() = runTest { val client = FakeMatrixClient( - getRoomPreviewResult = { + getRoomPreviewFromRoomIdResult = { _, _ -> Result.failure(Exception("403")) } ) @@ -415,7 +471,12 @@ class JoinRoomPresenterTest { private fun createJoinRoomPresenter( roomId: RoomId = A_ROOM_ID, roomDescription: Optional = Optional.empty(), + serverNames: List = emptyList(), + trigger: JoinedRoom.Trigger = JoinedRoom.Trigger.Invite, matrixClient: MatrixClient = FakeMatrixClient(), + joinRoomLambda: (RoomId, List, JoinedRoom.Trigger) -> Result = { _, _, _ -> + Result.success(Unit) + }, knockRoom: KnockRoom = FakeKnockRoom(), buildMeta: BuildMeta = aBuildMeta(applicationName = "AppName"), acceptDeclineInvitePresenter: Presenter = Presenter { anAcceptDeclineInviteState() } @@ -424,7 +485,10 @@ class JoinRoomPresenterTest { roomId = roomId, roomIdOrAlias = roomId.toRoomIdOrAlias(), roomDescription = roomDescription, + serverNames = serverNames, + trigger = trigger, matrixClient = matrixClient, + joinRoom = FakeJoinRoom(joinRoomLambda), knockRoom = knockRoom, buildMeta = buildMeta, acceptDeclineInvitePresenter = acceptDeclineInvitePresenter diff --git a/features/joinroom/impl/src/test/kotlin/io/element/android/features/joinroom/impl/JoinRoomViewTest.kt b/features/joinroom/impl/src/test/kotlin/io/element/android/features/joinroom/impl/JoinRoomViewTest.kt index b4bd788286..c0ae1c0a64 100644 --- a/features/joinroom/impl/src/test/kotlin/io/element/android/features/joinroom/impl/JoinRoomViewTest.kt +++ b/features/joinroom/impl/src/test/kotlin/io/element/android/features/joinroom/impl/JoinRoomViewTest.kt @@ -45,7 +45,7 @@ class JoinRoomViewTest { aJoinRoomState( eventSink = eventsRecorder, ), - onBackPressed = it + onBackClick = it ) rule.pressBack() } @@ -91,6 +91,34 @@ class JoinRoomViewTest { eventsRecorder.assertSingle(JoinRoomEvents.ClearError) } + @Test + fun `clicking on closing Join error emits the expected Event`() { + val eventsRecorder = EventsRecorder() + rule.setJoinRoomView( + aJoinRoomState( + contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.CanKnock), + joinAction = AsyncAction.Failure(Exception("Error")), + eventSink = eventsRecorder, + ), + ) + rule.clickOn(CommonStrings.action_ok) + eventsRecorder.assertSingle(JoinRoomEvents.ClearError) + } + + @Test + fun `when joining room is successful, the expected callback is invoked`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnce { + rule.setJoinRoomView( + aJoinRoomState( + joinAction = AsyncAction.Success(Unit), + eventSink = eventsRecorder, + ), + onJoinSuccess = it + ) + } + } + @Test fun `clicking on Accept invitation IsInvited room emits the expected Event`() { val eventsRecorder = EventsRecorder() @@ -139,7 +167,7 @@ class JoinRoomViewTest { contentState = aLoadedContentState(roomType = RoomType.Space), eventSink = eventsRecorder, ), - onBackPressed = it + onBackClick = it ) rule.clickOn(CommonStrings.action_go_back) } @@ -148,13 +176,15 @@ class JoinRoomViewTest { private fun AndroidComposeTestRule.setJoinRoomView( state: JoinRoomState, - onBackPressed: () -> Unit = EnsureNeverCalled(), + onBackClick: () -> Unit = EnsureNeverCalled(), + onJoinSuccess: () -> Unit = EnsureNeverCalled(), onKnockSuccess: () -> Unit = EnsureNeverCalled(), ) { setContent { JoinRoomView( state = state, - onBackPressed = onBackPressed, + onBackClick = onBackClick, + onJoinSuccess = onJoinSuccess, onKnockSuccess = onKnockSuccess, ) } diff --git a/features/leaveroom/api/src/main/kotlin/io/element/android/features/leaveroom/api/LeaveRoomView.kt b/features/leaveroom/api/src/main/kotlin/io/element/android/features/leaveroom/api/LeaveRoomView.kt index 5168ee9936..58bbd6b1ad 100644 --- a/features/leaveroom/api/src/main/kotlin/io/element/android/features/leaveroom/api/LeaveRoomView.kt +++ b/features/leaveroom/api/src/main/kotlin/io/element/android/features/leaveroom/api/LeaveRoomView.kt @@ -89,7 +89,7 @@ private fun LeaveRoomConfirmationDialog( title = stringResource(if (isDm) CommonStrings.action_leave_conversation else CommonStrings.action_leave_room), content = stringResource(text), submitText = stringResource(CommonStrings.action_leave), - onSubmitClicked = { eventSink(LeaveRoomEvent.LeaveRoom(roomId)) }, + onSubmitClick = { eventSink(LeaveRoomEvent.LeaveRoom(roomId)) }, onDismiss = { eventSink(LeaveRoomEvent.HideConfirmation) }, ) } diff --git a/features/leaveroom/api/src/main/res/values-es/translations.xml b/features/leaveroom/api/src/main/res/values-es/translations.xml index b996ec51f2..25c753201c 100644 --- a/features/leaveroom/api/src/main/res/values-es/translations.xml +++ b/features/leaveroom/api/src/main/res/values-es/translations.xml @@ -1,5 +1,6 @@ + "¿Estás seguro de que quieres salir de esta conversación? Esta conversación no es pública y no podrás volver a unirte sin una invitación." "¿Estás seguro de que quieres salir de esta sala? Eres la única persona aquí. Si te vas, nadie podrá unirse en el futuro, ni siquiera tú." "¿Estás seguro de que quieres abandonar esta sala? Esta sala no es pública y no podrás volver a entrar sin una invitación." "¿Seguro que quieres salir de la habitación?" diff --git a/features/leaveroom/api/src/main/res/values-ka/translations.xml b/features/leaveroom/api/src/main/res/values-ka/translations.xml new file mode 100644 index 0000000000..bb2c4e6ed0 --- /dev/null +++ b/features/leaveroom/api/src/main/res/values-ka/translations.xml @@ -0,0 +1,6 @@ + + + "დარწმუნებული ბრძანდებით, რომ ამ ოთახის დატოვება გსურთ? თქვენ აქ მარტო ხართ და ჩატის დატოვებისას აქ თქვენს ჩათვლით ვერავინ ვერ გაწევრიანდება." + "დარწმუნებული ბრძანდებით, რომ ამ ოთახის დატოვება გსურთ? ეს ოთახი არ არის საჯარო და მოწვევის გარეშე ვერ შეძლებთ ხელახლა გაწევრიანებას." + "დარწმუნებული ბრძანდებით, რომ ოთახის დატოვება გსურთ?" + diff --git a/features/leaveroom/api/src/main/res/values-pt-rBR/translations.xml b/features/leaveroom/api/src/main/res/values-pt-rBR/translations.xml new file mode 100644 index 0000000000..7547378598 --- /dev/null +++ b/features/leaveroom/api/src/main/res/values-pt-rBR/translations.xml @@ -0,0 +1,7 @@ + + + "Tens a certeza que queres sair desta conversa? Não é pública, logo não poderás voltar a participar sem um convite." + "Tens a certeza que queres sair desta sala? És o único participante. Se saíres, ninguém mais poderá entrar, incluindo tu." + "Tens a certeza que queres sair desta sala? Atenta que não é pública e portanto não poderás voltar a entrar sem um novo convite." + "Tens a certeza que queres sair da sala?" + diff --git a/features/leaveroom/api/src/main/res/values-zh/translations.xml b/features/leaveroom/api/src/main/res/values-zh/translations.xml new file mode 100644 index 0000000000..634c1f045a --- /dev/null +++ b/features/leaveroom/api/src/main/res/values-zh/translations.xml @@ -0,0 +1,7 @@ + + + "您确定要离开此对话吗?此对话不公开,未经邀请您将无法重新加入。" + "你确定要离开这个房间吗?这里只有你一个人,如果你走了,包括你在内的所有人都无法进入此房间。" + "你确定要离开这个房间吗?这个房间不是公开的,如果没有邀请,你将无法重新加入。" + "你确定要离开房间吗?" + diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/PermissionDeniedDialog.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/PermissionDeniedDialog.kt index 0a488ded85..ad8aafc385 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/PermissionDeniedDialog.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/PermissionDeniedDialog.kt @@ -29,7 +29,7 @@ internal fun PermissionDeniedDialog( ) { ConfirmationDialog( content = stringResource(CommonStrings.error_missing_location_auth_android, appName), - onSubmitClicked = onContinue, + onSubmitClick = onContinue, onDismiss = onDismiss, submitText = stringResource(CommonStrings.action_continue), cancelText = stringResource(CommonStrings.action_cancel), diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/PermissionRationaleDialog.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/PermissionRationaleDialog.kt index 4f4f19c6b3..0f44804977 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/PermissionRationaleDialog.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/common/PermissionRationaleDialog.kt @@ -29,7 +29,7 @@ internal fun PermissionRationaleDialog( ) { ConfirmationDialog( content = stringResource(CommonStrings.error_missing_location_rationale_android, appName), - onSubmitClicked = onContinue, + onSubmitClick = onContinue, onDismiss = onDismiss, submitText = stringResource(CommonStrings.action_continue), cancelText = stringResource(CommonStrings.action_cancel), diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationNode.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationNode.kt index 610f0b9079..3e02866cb7 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationNode.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationNode.kt @@ -54,7 +54,7 @@ class ShowLocationNode @AssistedInject constructor( ShowLocationView( state = presenter.present(), modifier = modifier, - onBackPressed = ::navigateUp + onBackClick = ::navigateUp ) } } diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationView.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationView.kt index 3370dbc751..0aca7eba3a 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationView.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationView.kt @@ -67,7 +67,7 @@ import org.maplibre.android.geometry.LatLng @Composable fun ShowLocationView( state: ShowLocationState, - onBackPressed: () -> Unit, + onBackClick: () -> Unit, modifier: Modifier = Modifier, ) { when (state.permissionDialog) { @@ -121,7 +121,7 @@ fun ShowLocationView( }, navigationIcon = { BackButton( - onClick = onBackPressed, + onClick = onBackClick, ) }, actions = { @@ -194,7 +194,7 @@ fun ShowLocationView( internal fun ShowLocationViewPreview(@PreviewParameter(ShowLocationStateProvider::class) state: ShowLocationState) = ElementPreview { ShowLocationView( state = state, - onBackPressed = {}, + onBackClick = {}, ) } diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/common/permissions/PermissionsPresenterFake.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/common/permissions/FakePermissionsPresenter.kt similarity index 95% rename from features/location/impl/src/test/kotlin/io/element/android/features/location/impl/common/permissions/PermissionsPresenterFake.kt rename to features/location/impl/src/test/kotlin/io/element/android/features/location/impl/common/permissions/FakePermissionsPresenter.kt index e79ac9e453..8bbaf5c428 100644 --- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/common/permissions/PermissionsPresenterFake.kt +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/common/permissions/FakePermissionsPresenter.kt @@ -18,7 +18,7 @@ package io.element.android.features.location.impl.common.permissions import androidx.compose.runtime.Composable -class PermissionsPresenterFake : PermissionsPresenter { +class FakePermissionsPresenter : PermissionsPresenter { val events = mutableListOf() private fun handleEvent(event: PermissionsEvents) { diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/send/SendLocationPresenterTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/send/SendLocationPresenterTest.kt index b6b469c44a..af4ac6c9c7 100644 --- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/send/SendLocationPresenterTest.kt +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/send/SendLocationPresenterTest.kt @@ -24,9 +24,9 @@ import im.vector.app.features.analytics.plan.Composer import io.element.android.features.location.api.Location import io.element.android.features.location.impl.aPermissionsState import io.element.android.features.location.impl.common.actions.FakeLocationActions +import io.element.android.features.location.impl.common.permissions.FakePermissionsPresenter import io.element.android.features.location.impl.common.permissions.PermissionsEvents import io.element.android.features.location.impl.common.permissions.PermissionsPresenter -import io.element.android.features.location.impl.common.permissions.PermissionsPresenterFake import io.element.android.features.location.impl.common.permissions.PermissionsState import io.element.android.features.messages.test.FakeMessageComposerContext import io.element.android.libraries.matrix.api.room.location.AssetType @@ -45,7 +45,7 @@ class SendLocationPresenterTest { @get:Rule val warmUpRule = WarmUpRule() - private val permissionsPresenterFake = PermissionsPresenterFake() + private val fakePermissionsPresenter = FakePermissionsPresenter() private val fakeMatrixRoom = FakeMatrixRoom() private val fakeAnalyticsService = FakeAnalyticsService() private val fakeMessageComposerContext = FakeMessageComposerContext() @@ -53,7 +53,7 @@ class SendLocationPresenterTest { private val fakeBuildMeta = aBuildMeta(applicationName = "app name") private val sendLocationPresenter: SendLocationPresenter = SendLocationPresenter( permissionsPresenterFactory = object : PermissionsPresenter.Factory { - override fun create(permissions: List): PermissionsPresenter = permissionsPresenterFake + override fun create(permissions: List): PermissionsPresenter = fakePermissionsPresenter }, room = fakeMatrixRoom, analyticsService = fakeAnalyticsService, @@ -64,7 +64,7 @@ class SendLocationPresenterTest { @Test fun `initial state with permissions granted`() = runTest { - permissionsPresenterFake.givenState( + fakePermissionsPresenter.givenState( aPermissionsState( permissions = PermissionsState.Permissions.AllGranted, shouldShowRationale = false, @@ -90,7 +90,7 @@ class SendLocationPresenterTest { @Test fun `initial state with permissions partially granted`() = runTest { - permissionsPresenterFake.givenState( + fakePermissionsPresenter.givenState( aPermissionsState( permissions = PermissionsState.Permissions.SomeGranted, shouldShowRationale = false, @@ -116,7 +116,7 @@ class SendLocationPresenterTest { @Test fun `initial state with permissions denied`() = runTest { - permissionsPresenterFake.givenState( + fakePermissionsPresenter.givenState( aPermissionsState( permissions = PermissionsState.Permissions.NoneGranted, shouldShowRationale = false, @@ -142,7 +142,7 @@ class SendLocationPresenterTest { @Test fun `initial state with permissions denied once`() = runTest { - permissionsPresenterFake.givenState( + fakePermissionsPresenter.givenState( aPermissionsState( permissions = PermissionsState.Permissions.NoneGranted, shouldShowRationale = true, @@ -168,7 +168,7 @@ class SendLocationPresenterTest { @Test fun `rationale dialog dismiss`() = runTest { - permissionsPresenterFake.givenState( + fakePermissionsPresenter.givenState( aPermissionsState( permissions = PermissionsState.Permissions.NoneGranted, shouldShowRationale = true, @@ -199,7 +199,7 @@ class SendLocationPresenterTest { @Test fun `rationale dialog continue`() = runTest { - permissionsPresenterFake.givenState( + fakePermissionsPresenter.givenState( aPermissionsState( permissions = PermissionsState.Permissions.NoneGranted, shouldShowRationale = true, @@ -221,13 +221,13 @@ class SendLocationPresenterTest { // Continue the dialog sends permission request to the permissions presenter myLocationState.eventSink(SendLocationEvents.RequestPermissions) - assertThat(permissionsPresenterFake.events.last()).isEqualTo(PermissionsEvents.RequestPermissions) + assertThat(fakePermissionsPresenter.events.last()).isEqualTo(PermissionsEvents.RequestPermissions) } } @Test fun `permission denied dialog dismiss`() = runTest { - permissionsPresenterFake.givenState( + fakePermissionsPresenter.givenState( aPermissionsState( permissions = PermissionsState.Permissions.NoneGranted, shouldShowRationale = false, @@ -258,7 +258,7 @@ class SendLocationPresenterTest { @Test fun `share sender location`() = runTest { - permissionsPresenterFake.givenState( + fakePermissionsPresenter.givenState( aPermissionsState( permissions = PermissionsState.Permissions.AllGranted, shouldShowRationale = false, @@ -314,7 +314,7 @@ class SendLocationPresenterTest { @Test fun `share pin location`() = runTest { - permissionsPresenterFake.givenState( + fakePermissionsPresenter.givenState( aPermissionsState( permissions = PermissionsState.Permissions.NoneGranted, shouldShowRationale = false, @@ -370,7 +370,7 @@ class SendLocationPresenterTest { @Test fun `composer context passes through analytics`() = runTest { - permissionsPresenterFake.givenState( + fakePermissionsPresenter.givenState( aPermissionsState( permissions = PermissionsState.Permissions.NoneGranted, shouldShowRationale = false, @@ -418,7 +418,7 @@ class SendLocationPresenterTest { @Test fun `open settings activity`() = runTest { - permissionsPresenterFake.givenState( + fakePermissionsPresenter.givenState( aPermissionsState( permissions = PermissionsState.Permissions.NoneGranted, shouldShowRationale = false, diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt index ff80a3935d..dab964b6e1 100644 --- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt @@ -23,9 +23,9 @@ import com.google.common.truth.Truth.assertThat import io.element.android.features.location.api.Location import io.element.android.features.location.impl.aPermissionsState import io.element.android.features.location.impl.common.actions.FakeLocationActions +import io.element.android.features.location.impl.common.permissions.FakePermissionsPresenter import io.element.android.features.location.impl.common.permissions.PermissionsEvents import io.element.android.features.location.impl.common.permissions.PermissionsPresenter -import io.element.android.features.location.impl.common.permissions.PermissionsPresenterFake import io.element.android.features.location.impl.common.permissions.PermissionsState import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.tests.testutils.WarmUpRule @@ -38,13 +38,13 @@ class ShowLocationPresenterTest { @get:Rule val warmUpRule = WarmUpRule() - private val permissionsPresenterFake = PermissionsPresenterFake() + private val fakePermissionsPresenter = FakePermissionsPresenter() private val fakeLocationActions = FakeLocationActions() private val fakeBuildMeta = aBuildMeta(applicationName = "app name") private val location = Location(1.23, 4.56, 7.8f) private val presenter = ShowLocationPresenter( permissionsPresenterFactory = object : PermissionsPresenter.Factory { - override fun create(permissions: List): PermissionsPresenter = permissionsPresenterFake + override fun create(permissions: List): PermissionsPresenter = fakePermissionsPresenter }, fakeLocationActions, fakeBuildMeta, @@ -54,7 +54,7 @@ class ShowLocationPresenterTest { @Test fun `emits initial state with no location permission`() = runTest { - permissionsPresenterFake.givenState( + fakePermissionsPresenter.givenState( aPermissionsState( permissions = PermissionsState.Permissions.NoneGranted, shouldShowRationale = false, @@ -74,7 +74,7 @@ class ShowLocationPresenterTest { @Test fun `emits initial state location permission denied once`() = runTest { - permissionsPresenterFake.givenState( + fakePermissionsPresenter.givenState( aPermissionsState( permissions = PermissionsState.Permissions.NoneGranted, shouldShowRationale = true, @@ -94,7 +94,7 @@ class ShowLocationPresenterTest { @Test fun `emits initial state with location permission`() = runTest { - permissionsPresenterFake.givenState(aPermissionsState(permissions = PermissionsState.Permissions.AllGranted)) + fakePermissionsPresenter.givenState(aPermissionsState(permissions = PermissionsState.Permissions.AllGranted)) moleculeFlow(RecompositionMode.Immediate) { presenter.present() @@ -109,7 +109,7 @@ class ShowLocationPresenterTest { @Test fun `emits initial state with partial location permission`() = runTest { - permissionsPresenterFake.givenState(aPermissionsState(permissions = PermissionsState.Permissions.SomeGranted)) + fakePermissionsPresenter.givenState(aPermissionsState(permissions = PermissionsState.Permissions.SomeGranted)) moleculeFlow(RecompositionMode.Immediate) { presenter.present() @@ -137,7 +137,7 @@ class ShowLocationPresenterTest { @Test fun `centers on user location`() = runTest { - permissionsPresenterFake.givenState(aPermissionsState(permissions = PermissionsState.Permissions.AllGranted)) + fakePermissionsPresenter.givenState(aPermissionsState(permissions = PermissionsState.Permissions.AllGranted)) moleculeFlow(RecompositionMode.Immediate) { presenter.present() @@ -165,7 +165,7 @@ class ShowLocationPresenterTest { @Test fun `rationale dialog dismiss`() = runTest { - permissionsPresenterFake.givenState( + fakePermissionsPresenter.givenState( aPermissionsState( permissions = PermissionsState.Permissions.NoneGranted, shouldShowRationale = true, @@ -196,7 +196,7 @@ class ShowLocationPresenterTest { @Test fun `rationale dialog continue`() = runTest { - permissionsPresenterFake.givenState( + fakePermissionsPresenter.givenState( aPermissionsState( permissions = PermissionsState.Permissions.NoneGranted, shouldShowRationale = true, @@ -218,13 +218,13 @@ class ShowLocationPresenterTest { // Continue the dialog sends permission request to the permissions presenter trackLocationState.eventSink(ShowLocationEvents.RequestPermissions) - assertThat(permissionsPresenterFake.events.last()).isEqualTo(PermissionsEvents.RequestPermissions) + assertThat(fakePermissionsPresenter.events.last()).isEqualTo(PermissionsEvents.RequestPermissions) } } @Test fun `permission denied dialog dismiss`() = runTest { - permissionsPresenterFake.givenState( + fakePermissionsPresenter.givenState( aPermissionsState( permissions = PermissionsState.Permissions.NoneGranted, shouldShowRationale = false, @@ -255,7 +255,7 @@ class ShowLocationPresenterTest { @Test fun `open settings activity`() = runTest { - permissionsPresenterFake.givenState( + fakePermissionsPresenter.givenState( aPermissionsState( permissions = PermissionsState.Permissions.NoneGranted, shouldShowRationale = false, diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationViewTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationViewTest.kt index 22377c3879..21cbd873e1 100644 --- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationViewTest.kt +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationViewTest.kt @@ -49,7 +49,7 @@ class ShowLocationViewTest { state = aShowLocationState( eventSink = eventsRecorder ), - onBackPressed = callback, + onBackClick = callback, ) rule.pressBack() } @@ -62,7 +62,7 @@ class ShowLocationViewTest { aShowLocationState( eventSink = eventsRecorder ), - onBackPressed = EnsureNeverCalled(), + onBackClick = EnsureNeverCalled(), ) val shareContentDescription = rule.activity.getString(CommonStrings.action_share) rule.onNodeWithContentDescription(shareContentDescription).performClick() @@ -76,7 +76,7 @@ class ShowLocationViewTest { aShowLocationState( eventSink = eventsRecorder ), - onBackPressed = EnsureNeverCalled(), + onBackClick = EnsureNeverCalled(), ) rule.onNodeWithTag(TestTags.floatingActionButton.value).performClick() eventsRecorder.assertSingle(ShowLocationEvents.TrackMyLocation(true)) @@ -90,7 +90,7 @@ class ShowLocationViewTest { permissionDialog = ShowLocationState.Dialog.PermissionDenied, eventSink = eventsRecorder ), - onBackPressed = EnsureNeverCalled(), + onBackClick = EnsureNeverCalled(), ) rule.clickOn(CommonStrings.action_continue) eventsRecorder.assertSingle(ShowLocationEvents.OpenAppSettings) @@ -104,7 +104,7 @@ class ShowLocationViewTest { permissionDialog = ShowLocationState.Dialog.PermissionDenied, eventSink = eventsRecorder ), - onBackPressed = EnsureNeverCalled(), + onBackClick = EnsureNeverCalled(), ) rule.clickOn(CommonStrings.action_cancel) eventsRecorder.assertSingle(ShowLocationEvents.DismissDialog) @@ -118,7 +118,7 @@ class ShowLocationViewTest { permissionDialog = ShowLocationState.Dialog.PermissionRationale, eventSink = eventsRecorder ), - onBackPressed = EnsureNeverCalled(), + onBackClick = EnsureNeverCalled(), ) rule.clickOn(CommonStrings.action_continue) eventsRecorder.assertSingle(ShowLocationEvents.RequestPermissions) @@ -132,7 +132,7 @@ class ShowLocationViewTest { permissionDialog = ShowLocationState.Dialog.PermissionRationale, eventSink = eventsRecorder ), - onBackPressed = EnsureNeverCalled(), + onBackClick = EnsureNeverCalled(), ) rule.clickOn(CommonStrings.action_cancel) eventsRecorder.assertSingle(ShowLocationEvents.DismissDialog) @@ -141,14 +141,14 @@ class ShowLocationViewTest { private fun AndroidComposeTestRule.setShowLocationView( state: ShowLocationState, - onBackPressed: () -> Unit = EnsureNeverCalled(), + onBackClick: () -> Unit = EnsureNeverCalled(), ) { setContent { // Simulate a LocalInspectionMode for MapLibreMap CompositionLocalProvider(LocalInspectionMode provides true) { ShowLocationView( state = state, - onBackPressed = onBackPressed, + onBackClick = onBackClick, ) } } diff --git a/features/lockscreen/api/src/main/kotlin/io/element/android/features/lockscreen/api/LockScreenEntryPoint.kt b/features/lockscreen/api/src/main/kotlin/io/element/android/features/lockscreen/api/LockScreenEntryPoint.kt index 6a2fb0c72d..f31fc5af4a 100644 --- a/features/lockscreen/api/src/main/kotlin/io/element/android/features/lockscreen/api/LockScreenEntryPoint.kt +++ b/features/lockscreen/api/src/main/kotlin/io/element/android/features/lockscreen/api/LockScreenEntryPoint.kt @@ -16,17 +16,19 @@ package io.element.android.features.lockscreen.api +import android.content.Context +import android.content.Intent 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 LockScreenEntryPoint : FeatureEntryPoint { - fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder + fun nodeBuilder(parentNode: Node, buildContext: BuildContext, navTarget: Target): NodeBuilder + fun pinUnlockIntent(context: Context): Intent interface NodeBuilder { fun callback(callback: Callback): NodeBuilder - fun target(target: Target): NodeBuilder fun build(): Node } @@ -37,6 +39,5 @@ interface LockScreenEntryPoint : FeatureEntryPoint { enum class Target { Settings, Setup, - Unlock } } diff --git a/features/lockscreen/impl/src/main/AndroidManifest.xml b/features/lockscreen/impl/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..083647d1c2 --- /dev/null +++ b/features/lockscreen/impl/src/main/AndroidManifest.xml @@ -0,0 +1,25 @@ + + + + + + + + + diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/DefaultLockScreenEntryPoint.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/DefaultLockScreenEntryPoint.kt index 5065cdc7f2..a6889a96e7 100644 --- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/DefaultLockScreenEntryPoint.kt +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/DefaultLockScreenEntryPoint.kt @@ -16,18 +16,20 @@ package io.element.android.features.lockscreen.impl +import android.content.Context +import android.content.Intent import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.node.Node import com.squareup.anvil.annotations.ContributesBinding import io.element.android.features.lockscreen.api.LockScreenEntryPoint +import io.element.android.features.lockscreen.impl.unlock.activity.PinUnlockActivity import io.element.android.libraries.architecture.createNode import io.element.android.libraries.di.AppScope import javax.inject.Inject @ContributesBinding(AppScope::class) class DefaultLockScreenEntryPoint @Inject constructor() : LockScreenEntryPoint { - override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): LockScreenEntryPoint.NodeBuilder { - var innerTarget: LockScreenEntryPoint.Target = LockScreenEntryPoint.Target.Unlock + override fun nodeBuilder(parentNode: Node, buildContext: BuildContext, navTarget: LockScreenEntryPoint.Target): LockScreenEntryPoint.NodeBuilder { val callbacks = mutableListOf() return object : LockScreenEntryPoint.NodeBuilder { @@ -36,15 +38,9 @@ class DefaultLockScreenEntryPoint @Inject constructor() : LockScreenEntryPoint { return this } - override fun target(target: LockScreenEntryPoint.Target): LockScreenEntryPoint.NodeBuilder { - innerTarget = target - return this - } - override fun build(): Node { val inputs = LockScreenFlowNode.Inputs( - when (innerTarget) { - LockScreenEntryPoint.Target.Unlock -> LockScreenFlowNode.NavTarget.Unlock + when (navTarget) { LockScreenEntryPoint.Target.Setup -> LockScreenFlowNode.NavTarget.Setup LockScreenEntryPoint.Target.Settings -> LockScreenFlowNode.NavTarget.Settings } @@ -54,4 +50,8 @@ class DefaultLockScreenEntryPoint @Inject constructor() : LockScreenEntryPoint { } } } + + override fun pinUnlockIntent(context: Context): Intent { + return PinUnlockActivity.newIntent(context) + } } diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/LockScreenFlowNode.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/LockScreenFlowNode.kt index 8dd75fc65d..fcbb0336a4 100644 --- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/LockScreenFlowNode.kt +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/LockScreenFlowNode.kt @@ -30,7 +30,6 @@ import io.element.android.anvilannotations.ContributesNode import io.element.android.features.lockscreen.api.LockScreenEntryPoint import io.element.android.features.lockscreen.impl.settings.LockScreenSettingsFlowNode import io.element.android.features.lockscreen.impl.setup.LockScreenSetupFlowNode -import io.element.android.features.lockscreen.impl.unlock.PinUnlockNode import io.element.android.libraries.architecture.BackstackView import io.element.android.libraries.architecture.BaseFlowNode import io.element.android.libraries.architecture.NodeInputs @@ -44,20 +43,17 @@ class LockScreenFlowNode @AssistedInject constructor( @Assisted plugins: List, ) : BaseFlowNode( backstack = BackStack( - initialElement = plugins.filterIsInstance(Inputs::class.java).first().initialNavTarget, + initialElement = plugins.filterIsInstance().first().initialNavTarget, savedStateMap = buildContext.savedStateMap, ), buildContext = buildContext, plugins = plugins, ) { data class Inputs( - val initialNavTarget: NavTarget = NavTarget.Unlock, + val initialNavTarget: NavTarget, ) : NodeInputs sealed interface NavTarget : Parcelable { - @Parcelize - data object Unlock : NavTarget - @Parcelize data object Setup : NavTarget @@ -75,10 +71,6 @@ class LockScreenFlowNode @AssistedInject constructor( override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { return when (navTarget) { - NavTarget.Unlock -> { - val inputs = PinUnlockNode.Inputs(isInAppUnlock = false) - createNode(buildContext, plugins = listOf(inputs)) - } NavTarget.Setup -> { val callback = OnSetupDoneCallback(plugins()) createNode(buildContext, plugins = listOf(callback)) diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsFlowNode.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsFlowNode.kt index 0890f38d1e..5cebdfbf01 100644 --- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsFlowNode.kt +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsFlowNode.kt @@ -103,20 +103,19 @@ class LockScreenSettingsFlowNode @AssistedInject constructor( override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { return when (navTarget) { NavTarget.Unlock -> { - val inputs = PinUnlockNode.Inputs(isInAppUnlock = true) val callback = object : PinUnlockNode.Callback { override fun onUnlock() { backstack.newRoot(NavTarget.Settings) } } - createNode(buildContext, plugins = listOf(inputs, callback)) + createNode(buildContext, plugins = listOf(callback)) } NavTarget.SetupPin -> { createNode(buildContext) } NavTarget.Settings -> { val callback = object : LockScreenSettingsNode.Callback { - override fun onChangePinClicked() { + override fun onChangePinClick() { backstack.push(NavTarget.SetupPin) } } diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsNode.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsNode.kt index edc50df50e..4f80c7fff0 100644 --- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsNode.kt +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsNode.kt @@ -34,11 +34,11 @@ class LockScreenSettingsNode @AssistedInject constructor( private val presenter: LockScreenSettingsPresenter, ) : Node(buildContext, plugins = plugins) { interface Callback : Plugin { - fun onChangePinClicked() + fun onChangePinClick() } - private fun onChangePinClicked() { - plugins().forEach { it.onChangePinClicked() } + private fun onChangePinClick() { + plugins().forEach { it.onChangePinClick() } } @Composable @@ -46,8 +46,8 @@ class LockScreenSettingsNode @AssistedInject constructor( val state = presenter.present() LockScreenSettingsView( state = state, - onBackPressed = this::navigateUp, - onChangePinClicked = this::onChangePinClicked, + onBackClick = this::navigateUp, + onChangePinClick = this::onChangePinClick, modifier = modifier, ) } diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsView.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsView.kt index 7c8634a078..375c5cde41 100644 --- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsView.kt +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsView.kt @@ -34,19 +34,19 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight @Composable fun LockScreenSettingsView( state: LockScreenSettingsState, - onChangePinClicked: () -> Unit, - onBackPressed: () -> Unit, + onChangePinClick: () -> Unit, + onBackClick: () -> Unit, modifier: Modifier = Modifier, ) { PreferencePage( title = stringResource(id = io.element.android.libraries.ui.strings.R.string.common_screen_lock), - onBackPressed = onBackPressed, + onBackClick = onBackClick, modifier = modifier ) { - PreferenceCategory(showDivider = false) { + PreferenceCategory(showTopDivider = false) { PreferenceText( title = stringResource(id = R.string.screen_app_lock_settings_change_pin), - onClick = onChangePinClicked + onClick = onChangePinClick ) PreferenceDivider() if (state.showRemovePinOption) { @@ -74,7 +74,7 @@ fun LockScreenSettingsView( ConfirmationDialog( title = stringResource(id = R.string.screen_app_lock_settings_remove_pin_alert_title), content = stringResource(id = R.string.screen_app_lock_settings_remove_pin_alert_message), - onSubmitClicked = { + onSubmitClick = { state.eventSink(LockScreenSettingsEvents.ConfirmRemovePin) }, onDismiss = { @@ -92,8 +92,8 @@ internal fun LockScreenSettingsViewPreview( ElementPreview { LockScreenSettingsView( state = state, - onChangePinClicked = {}, - onBackPressed = {}, + onChangePinClick = {}, + onBackClick = {}, ) } } diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/biometric/SetupBiometricView.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/biometric/SetupBiometricView.kt index 99001e5334..6d3325d480 100644 --- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/biometric/SetupBiometricView.kt +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/biometric/SetupBiometricView.kt @@ -49,8 +49,8 @@ fun SetupBiometricView( }, footer = { SetupBiometricFooter( - onAllowClicked = { state.eventSink(SetupBiometricEvents.AllowBiometric) }, - onSkipClicked = { state.eventSink(SetupBiometricEvents.UsePin) } + onAllowClick = { state.eventSink(SetupBiometricEvents.AllowBiometric) }, + onSkipClick = { state.eventSink(SetupBiometricEvents.UsePin) } ) }, ) @@ -68,18 +68,18 @@ private fun SetupBiometricHeader() { @Composable private fun SetupBiometricFooter( - onAllowClicked: () -> Unit, - onSkipClicked: () -> Unit, + onAllowClick: () -> Unit, + onSkipClick: () -> Unit, ) { ButtonColumnMolecule { val biometricAuth = stringResource(id = R.string.screen_app_lock_biometric_authentication) Button( text = stringResource(id = R.string.screen_app_lock_setup_biometric_unlock_allow_title, biometricAuth), - onClick = onAllowClicked + onClick = onAllowClick ) TextButton( text = stringResource(id = R.string.screen_app_lock_setup_biometric_unlock_skip), - onClick = onSkipClicked + onClick = onSkipClick ) } } diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinNode.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinNode.kt index ddd12f65df..b56b0daa86 100644 --- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinNode.kt +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinNode.kt @@ -37,7 +37,7 @@ class SetupPinNode @AssistedInject constructor( val state = presenter.present() SetupPinView( state = state, - onBackClicked = this::navigateUp, + onBackClick = this::navigateUp, modifier = modifier ) } diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinView.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinView.kt index a3dcab5a43..2a0e890470 100644 --- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinView.kt +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinView.kt @@ -52,7 +52,7 @@ import io.element.android.libraries.designsystem.theme.components.TopAppBar @Composable fun SetupPinView( state: SetupPinState, - onBackClicked: () -> Unit, + onBackClick: () -> Unit, modifier: Modifier = Modifier, ) { Scaffold( @@ -60,7 +60,7 @@ fun SetupPinView( topBar = { TopAppBar( navigationIcon = { - BackButton(onClick = onBackClicked) + BackButton(onClick = onBackClick) }, title = {} ) @@ -154,7 +154,7 @@ internal fun SetupPinViewPreview(@PreviewParameter(SetupPinStateProvider::class) ElementPreview { SetupPinView( state = state, - onBackClicked = {}, + onBackClick = {}, ) } } diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockNode.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockNode.kt index da6853a39c..f357869375 100644 --- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockNode.kt +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockNode.kt @@ -26,8 +26,6 @@ import com.bumble.appyx.core.plugin.plugins import dagger.assisted.Assisted import dagger.assisted.AssistedInject import io.element.android.anvilannotations.ContributesNode -import io.element.android.libraries.architecture.NodeInputs -import io.element.android.libraries.architecture.inputs import io.element.android.libraries.di.SessionScope @ContributesNode(SessionScope::class) @@ -40,12 +38,6 @@ class PinUnlockNode @AssistedInject constructor( fun onUnlock() } - data class Inputs( - val isInAppUnlock: Boolean - ) : NodeInputs - - private val inputs: Inputs = inputs() - private fun onUnlock() { plugins().forEach { it.onUnlock() @@ -62,7 +54,9 @@ class PinUnlockNode @AssistedInject constructor( } PinUnlockView( state = state, - isInAppUnlock = inputs.isInAppUnlock, + // UnlockNode is only used for in-app unlock, so we can safely set isInAppUnlock to true. + // It's set to false in PinUnlockActivity. + isInAppUnlock = true, modifier = modifier ) } diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockPresenter.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockPresenter.kt index aebf95aa37..db56b8c17b 100644 --- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockPresenter.kt +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockPresenter.kt @@ -29,11 +29,11 @@ import io.element.android.features.lockscreen.impl.biometric.BiometricUnlockMana import io.element.android.features.lockscreen.impl.pin.PinCodeManager import io.element.android.features.lockscreen.impl.pin.model.PinEntry import io.element.android.features.lockscreen.impl.unlock.keypad.PinKeypadModel +import io.element.android.features.lockscreen.impl.unlock.signout.SignOut 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.bool.orFalse -import io.element.android.libraries.matrix.api.MatrixClient import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import javax.inject.Inject @@ -41,7 +41,7 @@ import javax.inject.Inject class PinUnlockPresenter @Inject constructor( private val pinCodeManager: PinCodeManager, private val biometricUnlockManager: BiometricUnlockManager, - private val matrixClient: MatrixClient, + private val signOut: SignOut, private val coroutineScope: CoroutineScope, private val pinUnlockHelper: PinUnlockHelper, ) : Presenter { @@ -179,7 +179,7 @@ class PinUnlockPresenter @Inject constructor( private fun CoroutineScope.signOut(signOutAction: MutableState>) = launch { suspend { - matrixClient.logout(ignoreSdkError = true) + signOut() }.runCatchingUpdatingState(signOutAction) } } diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockView.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockView.kt index 467de77d2b..70b6a6c634 100644 --- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockView.kt +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockView.kt @@ -192,7 +192,7 @@ private fun SignOutPrompt( ConfirmationDialog( title = stringResource(id = R.string.screen_app_lock_signout_alert_title), content = stringResource(id = R.string.screen_app_lock_signout_alert_message), - onSubmitClicked = onSignOut, + onSubmitClick = onSignOut, onDismiss = onDismiss, ) } else { diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/activity/PinUnlockActivity.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/activity/PinUnlockActivity.kt new file mode 100644 index 0000000000..9c228b0736 --- /dev/null +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/activity/PinUnlockActivity.kt @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.lockscreen.impl.unlock.activity + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.activity.OnBackPressedCallback +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.lifecycleScope +import io.element.android.compound.theme.ElementTheme +import io.element.android.features.lockscreen.api.LockScreenLockState +import io.element.android.features.lockscreen.api.LockScreenService +import io.element.android.features.lockscreen.impl.unlock.PinUnlockPresenter +import io.element.android.features.lockscreen.impl.unlock.PinUnlockView +import io.element.android.features.lockscreen.impl.unlock.di.PinUnlockBindings +import io.element.android.libraries.architecture.bindings +import kotlinx.coroutines.launch +import javax.inject.Inject + +class PinUnlockActivity : AppCompatActivity() { + internal companion object { + fun newIntent(context: Context): Intent { + return Intent(context, PinUnlockActivity::class.java) + } + } + + @Inject lateinit var presenter: PinUnlockPresenter + @Inject lateinit var lockScreenService: LockScreenService + + override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() + super.onCreate(savedInstanceState) + bindings().inject(this) + setContent { + ElementTheme { + val state = presenter.present() + PinUnlockView(state = state, isInAppUnlock = false) + } + } + lifecycleScope.launch { + lockScreenService.lockState.collect { state -> + if (state == LockScreenLockState.Unlocked) { + finish() + } + } + } + val onBackPressedCallback = object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + moveTaskToBack(true) + } + } + onBackPressedDispatcher.addCallback(this, onBackPressedCallback) + } +} diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/di/PinUnlockBindings.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/di/PinUnlockBindings.kt new file mode 100644 index 0000000000..ddd62d2fb6 --- /dev/null +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/di/PinUnlockBindings.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.lockscreen.impl.unlock.di + +import com.squareup.anvil.annotations.ContributesTo +import io.element.android.features.lockscreen.impl.unlock.activity.PinUnlockActivity +import io.element.android.libraries.di.AppScope + +@ContributesTo(AppScope::class) +interface PinUnlockBindings { + fun inject(activity: PinUnlockActivity) +} diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/signout/DefaultSignOut.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/signout/DefaultSignOut.kt new file mode 100644 index 0000000000..2c541911e6 --- /dev/null +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/signout/DefaultSignOut.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.lockscreen.impl.unlock.signout + +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.matrix.api.MatrixClientProvider +import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService +import javax.inject.Inject + +@ContributesBinding(AppScope::class) +class DefaultSignOut @Inject constructor( + private val authenticationService: MatrixAuthenticationService, + private val matrixClientProvider: MatrixClientProvider, +) : SignOut { + override suspend fun invoke(): String? { + val currentSession = authenticationService.getLatestSessionId() + return if (currentSession != null) { + matrixClientProvider.getOrRestore(currentSession) + .getOrThrow() + .logout(ignoreSdkError = true) + } else { + error("No session to sign out") + } + } +} diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/signout/SignOut.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/signout/SignOut.kt new file mode 100644 index 0000000000..f4c91aece6 --- /dev/null +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/signout/SignOut.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.lockscreen.impl.unlock.signout + +interface SignOut { + suspend operator fun invoke(): String? +} diff --git a/features/lockscreen/impl/src/main/res/values-fr/translations.xml b/features/lockscreen/impl/src/main/res/values-fr/translations.xml index 94e65cc6f0..e086a88e61 100644 --- a/features/lockscreen/impl/src/main/res/values-fr/translations.xml +++ b/features/lockscreen/impl/src/main/res/values-fr/translations.xml @@ -1,6 +1,6 @@ - "authentification biométrique" + "l’authentification biométrique" "déverrouillage biométrique" "Déverrouiller avec la biométrie" "Code PIN oublié?" diff --git a/features/lockscreen/impl/src/main/res/values-ka/translations.xml b/features/lockscreen/impl/src/main/res/values-ka/translations.xml new file mode 100644 index 0000000000..26fd97b671 --- /dev/null +++ b/features/lockscreen/impl/src/main/res/values-ka/translations.xml @@ -0,0 +1,21 @@ + + + "დაგავიწყდათ PIN?" + "PIN კოდის შეცვლა" + "ბიომეტრიული განბლოკვის დაშვება" + "პინ კოდის წაშლა" + "დარწმუნებული ხართ, რომ გსურთ PIN-ის წაშლა?" + "გსურთ PIN-ის წაშლა?" + "დაადასტურეთ PIN" + "გასაგრძელებლად საჭიროა ხელახლა შესვლა და ახალი PIN-ის შექმნა" + "თქვენ ახლა გადიხართ…" + + "თქვენ გაქვთ %1$d მცდელობა განსაბლოკად" + "თქვენ გაქვთ %1$d მცდელობა განსაბლოკად" + + + "არასწორი PIN. თქვენ %1$d შანსი დაგრჩათ" + "არასწორი PIN. თქვენ %1$d შანსი დაგრჩათ" + + "გასვლა…" + diff --git a/features/lockscreen/impl/src/main/res/values-pt-rBR/translations.xml b/features/lockscreen/impl/src/main/res/values-pt-rBR/translations.xml new file mode 100644 index 0000000000..ab87d61955 --- /dev/null +++ b/features/lockscreen/impl/src/main/res/values-pt-rBR/translations.xml @@ -0,0 +1,37 @@ + + + "autenticação biométrica" + "desbloqueio biométrico" + "Desbloquear com biometria" + "Esqueceste-te do PIN?" + "Altera o código PIN" + "Permitir o desbloqueio biométrico" + "Remover PIN" + "Tens a certeza que queres remover o PIN?" + "Remover o PIN?" + "Permitir %1$s" + "Prefiro usar o PIN" + "Poupa tempo e utiliza %1$s para desbloquear a aplicação" + "Escolher PIN" + "Confirmar PIN" + "Não podes escolher este código PIN por razões de segurança" + "Escolhe um PIN diferente" + "Bloqueia a %1$s para dar mais segurança às tuas conversas. + +Escolhe algo memorável. Se te esqueceres deste PIN, a tua sessão será terminada." + "Insere o mesmo PIN duas vezes" + "Os PINs não coincidem" + "Terás de voltar a iniciar sessão e criar um novo PIN para continuar" + "Estás a terminar a sessão" + + "Tens %1$d tentativa de desbloqueio" + "Tens %1$d tentativas de desbloqueio" + + + "PIN incorreto. Tens mais %1$d tentativa" + "PIN incorreto. Tens mais %1$d tentativas" + + "Utilizar biometria" + "Utilizar PIN" + "A terminar sessão…" + diff --git a/features/lockscreen/impl/src/main/res/values-zh/translations.xml b/features/lockscreen/impl/src/main/res/values-zh/translations.xml new file mode 100644 index 0000000000..9d19208c6a --- /dev/null +++ b/features/lockscreen/impl/src/main/res/values-zh/translations.xml @@ -0,0 +1,35 @@ + + + "生物识别认证" + "生物识别解锁" + "使用生物识别解锁" + "忘记 PIN 码?" + "更改 PIN 码" + "允许生物识别解锁" + "移除 PIN 码" + "您确定要删除 PIN 码吗?" + "移除 PIN 码?" + "允许 %1$s" + "我宁愿使用 PIN 码" + "节省时间,用 %1$s 来解锁应用程序" + "选择 PIN 码" + "确认 PIN 码" + "出于安全原因,您不能选择这个 PIN 码" + "选择不同的 PIN 码" + "锁定 %1$s 以为聊天增加安全性。 + +选择好记的 PIN 码。如果忘掉了这个 PIN 码,就不得不登出应用。" + "请输入两次相同的 PIN 码" + "PIN 码不匹配" + "您需要重新登录并创建新的 PIN 才能继续" + "您正在登出" + + "还剩 %1$d 次解锁机会" + + + "PIN 码错误。还剩 %1$d 次机会" + + "使用生物识别" + "使用 PIN 码" + "正在登出…" + diff --git a/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/unlock/DefaultSignOutTest.kt b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/unlock/DefaultSignOutTest.kt new file mode 100644 index 0000000000..392693d9ef --- /dev/null +++ b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/unlock/DefaultSignOutTest.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.lockscreen.impl.unlock + +import com.google.common.truth.Truth.assertThat +import io.element.android.features.lockscreen.impl.unlock.signout.DefaultSignOut +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.tests.testutils.lambda.assert +import io.element.android.tests.testutils.lambda.lambdaRecorder +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class DefaultSignOutTest { + private val matrixClient = FakeMatrixClient() + private val authenticationService = FakeMatrixAuthenticationService() + private val matrixClientProvider = FakeMatrixClientProvider(getClient = { Result.success(matrixClient) }) + private val sut = DefaultSignOut(authenticationService, matrixClientProvider) + + @Test + fun `when no active session then it throws`() = runTest { + authenticationService.getLatestSessionIdLambda = { null } + val result = runCatching { sut.invoke() } + assertThat(result.isFailure).isTrue() + } + + @Test + fun `with one active session and successful logout on client`() = runTest { + val logoutLambda = lambdaRecorder { _: Boolean -> null } + authenticationService.getLatestSessionIdLambda = { matrixClient.sessionId } + matrixClient.logoutLambda = logoutLambda + val result = runCatching { sut.invoke() } + assertThat(result.isSuccess).isTrue() + assert(logoutLambda).isCalledOnce() + } + + @Test + fun `with one active session and and failed logout on client`() = runTest { + val logoutLambda = lambdaRecorder { _: Boolean -> error("Failed to logout") } + authenticationService.getLatestSessionIdLambda = { matrixClient.sessionId } + matrixClient.logoutLambda = logoutLambda + val result = runCatching { sut.invoke() } + assertThat(result.isFailure).isTrue() + assert(logoutLambda).isCalledOnce() + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/ProcessedEvent.kt b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/unlock/FakeSignOut.kt similarity index 59% rename from libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/ProcessedEvent.kt rename to features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/unlock/FakeSignOut.kt index 2e91ca3467..883a5bf97b 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/ProcessedEvent.kt +++ b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/unlock/FakeSignOut.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2021 New Vector Ltd + * Copyright (c) 2024 New Vector Ltd * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,18 +14,15 @@ * limitations under the License. */ -package io.element.android.libraries.push.impl.notifications +package io.element.android.features.lockscreen.impl.unlock -data class ProcessedEvent( - val type: Type, - val event: T -) { - enum class Type { - KEEP, - REMOVE +import io.element.android.features.lockscreen.impl.unlock.signout.SignOut +import io.element.android.tests.testutils.simulateLongTask + +class FakeSignOut( + var lambda: () -> String? = { null } +) : SignOut { + override suspend fun invoke(): String? = simulateLongTask { + lambda() } } - -fun List>.onlyKeptEvents() = mapNotNull { processedEvent -> - processedEvent.event.takeIf { processedEvent.type == ProcessedEvent.Type.KEEP } -} diff --git a/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockPresenterTest.kt b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockPresenterTest.kt index 6827055b2c..89d0e92ee2 100644 --- a/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockPresenterTest.kt +++ b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockPresenterTest.kt @@ -28,8 +28,10 @@ import io.element.android.features.lockscreen.impl.pin.PinCodeManager import io.element.android.features.lockscreen.impl.pin.model.PinEntry import io.element.android.features.lockscreen.impl.pin.model.assertText import io.element.android.features.lockscreen.impl.unlock.keypad.PinKeypadModel +import io.element.android.features.lockscreen.impl.unlock.signout.SignOut import io.element.android.libraries.architecture.AsyncData -import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.tests.testutils.lambda.assert +import io.element.android.tests.testutils.lambda.lambdaRecorder import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.test.runTest import org.junit.Test @@ -104,7 +106,9 @@ class PinUnlockPresenterTest { @Test fun `present - forgot pin flow`() = runTest { - val presenter = createPinUnlockPresenter(this) + val signOutLambda = lambdaRecorder { null } + val signOut = FakeSignOut(signOutLambda) + val presenter = createPinUnlockPresenter(this, signOut = signOut) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { @@ -131,6 +135,7 @@ class PinUnlockPresenterTest { awaitItem().also { state -> assertThat(state.signOutAction).isInstanceOf(AsyncData.Success::class.java) } + assert(signOutLambda).isCalledOnce().withNoParameter() } } @@ -142,6 +147,7 @@ class PinUnlockPresenterTest { scope: CoroutineScope, biometricUnlockManager: BiometricUnlockManager = FakeBiometricUnlockManager(), callback: PinCodeManager.Callback = DefaultPinCodeManagerCallback(), + signOut: SignOut = FakeSignOut(), ): PinUnlockPresenter { val pinCodeManager = aPinCodeManager().apply { addCallback(callback) @@ -150,7 +156,7 @@ class PinUnlockPresenterTest { return PinUnlockPresenter( pinCodeManager = pinCodeManager, biometricUnlockManager = biometricUnlockManager, - matrixClient = FakeMatrixClient(), + signOut = signOut, coroutineScope = scope, pinUnlockHelper = PinUnlockHelper(biometricUnlockManager, pinCodeManager), ) diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/LoginFlowNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/LoginFlowNode.kt index cd17a7daf3..6b7f9de6f4 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/LoginFlowNode.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/LoginFlowNode.kt @@ -163,7 +163,7 @@ class LoginFlowNode @AssistedInject constructor( backstack.singleTop(NavTarget.ConfirmAccountProvider) } - override fun onOtherClicked() { + override fun onOtherClick() { backstack.push(NavTarget.SearchAccountProvider) } } @@ -197,7 +197,7 @@ class LoginFlowNode @AssistedInject constructor( loginFormState = navTarget.loginFormState, ) val callback = object : WaitListNode.Callback { - override fun onCancelClicked() { + override fun onCancelClick() { navigateUp() } } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerView.kt index 9f71ccc7e5..6818caadb8 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerView.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerView.kt @@ -33,8 +33,8 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight @Composable fun ChangeServerView( state: ChangeServerState, - onLearnMoreClicked: () -> Unit, - onDone: () -> Unit, + onLearnMoreClick: () -> Unit, + onSuccess: () -> Unit, modifier: Modifier = Modifier, ) { val eventSink = state.eventSink @@ -53,8 +53,8 @@ fun ChangeServerView( is ChangeServerError.SlidingSyncAlert -> { SlidingSyncNotSupportedDialog( modifier = modifier, - onLearnMoreClicked = { - onLearnMoreClicked() + onLearnMoreClick = { + onLearnMoreClick() eventSink.invoke(ChangeServerEvents.ClearError) }, onDismiss = { @@ -66,9 +66,9 @@ fun ChangeServerView( } is AsyncData.Loading -> ProgressDialog() is AsyncData.Success -> { - val latestOnDone by rememberUpdatedState(onDone) + val latestOnSuccess by rememberUpdatedState(onSuccess) LaunchedEffect(state.changeServerAction) { - latestOnDone() + latestOnSuccess() } } AsyncData.Uninitialized -> Unit @@ -80,7 +80,7 @@ fun ChangeServerView( internal fun ChangeServerViewPreview(@PreviewParameter(ChangeServerStateProvider::class) state: ChangeServerState) = ElementPreview { ChangeServerView( state = state, - onLearnMoreClicked = {}, - onDone = {}, + onLearnMoreClick = {}, + onSuccess = {}, ) } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/dialogs/SlidingSyncNotSupportedDialog.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/dialogs/SlidingSyncNotSupportedDialog.kt index a151b34303..ff83283ff2 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/dialogs/SlidingSyncNotSupportedDialog.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/dialogs/SlidingSyncNotSupportedDialog.kt @@ -27,7 +27,7 @@ import io.element.android.libraries.ui.strings.CommonStrings @Composable internal fun SlidingSyncNotSupportedDialog( - onLearnMoreClicked: () -> Unit, + onLearnMoreClick: () -> Unit, onDismiss: () -> Unit, modifier: Modifier = Modifier, ) { @@ -35,8 +35,8 @@ internal fun SlidingSyncNotSupportedDialog( modifier = modifier, onDismiss = onDismiss, submitText = stringResource(CommonStrings.action_learn_more), - onSubmitClicked = onLearnMoreClicked, - onCancelClicked = onDismiss, + onSubmitClick = onLearnMoreClick, + onCancelClick = onDismiss, title = stringResource(CommonStrings.dialog_title_error), content = stringResource(R.string.screen_change_server_error_no_sliding_sync_message), ) @@ -46,7 +46,7 @@ internal fun SlidingSyncNotSupportedDialog( @Composable internal fun SlidingSyncNotSupportedDialogPreview() = ElementPreview { SlidingSyncNotSupportedDialog( - onLearnMoreClicked = {}, + onLearnMoreClick = {}, onDismiss = {}, ) } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/oidc/webview/OidcWebViewClient.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/oidc/webview/OidcWebViewClient.kt index 0da6e16ee0..a9cd576e6d 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/oidc/webview/OidcWebViewClient.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/oidc/webview/OidcWebViewClient.kt @@ -16,8 +16,6 @@ package io.element.android.features.login.impl.oidc.webview -import android.annotation.TargetApi -import android.os.Build import android.webkit.WebResourceRequest import android.webkit.WebView import android.webkit.WebViewClient @@ -25,7 +23,6 @@ import android.webkit.WebViewClient class OidcWebViewClient( private val eventListener: WebViewEventListener, ) : WebViewClient() { - @TargetApi(Build.VERSION_CODES.N) override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean { return shouldOverrideUrl(request.url.toString()) } @@ -36,7 +33,6 @@ class OidcWebViewClient( } private fun shouldOverrideUrl(url: String): Boolean { - // Timber.d("shouldOverrideUrl: $url") return eventListener.shouldOverrideUrlLoading(url) } } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderNode.kt index 66025a2f1e..e9dd021ae5 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderNode.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderNode.kt @@ -37,15 +37,15 @@ class ChangeAccountProviderNode @AssistedInject constructor( ) : Node(buildContext, plugins = plugins) { interface Callback : Plugin { fun onDone() - fun onOtherClicked() + fun onOtherClick() } private fun onDone() { plugins().forEach { it.onDone() } } - private fun onOtherClicked() { - plugins().forEach { it.onOtherClicked() } + private fun onOtherClick() { + plugins().forEach { it.onOtherClick() } } @Composable @@ -55,10 +55,10 @@ class ChangeAccountProviderNode @AssistedInject constructor( ChangeAccountProviderView( state = state, modifier = modifier, - onBackPressed = ::navigateUp, - onLearnMoreClicked = { openLearnMorePage(context) }, - onDone = ::onDone, - onOtherProviderClicked = ::onOtherClicked, + onBackClick = ::navigateUp, + onLearnMoreClick = { openLearnMorePage(context) }, + onSuccess = ::onDone, + onOtherProviderClick = ::onOtherClick, ) } } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderView.kt index 5daf4e8f1c..3d9b73bc87 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderView.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderView.kt @@ -55,10 +55,10 @@ import io.element.android.libraries.designsystem.theme.components.TopAppBar @Composable fun ChangeAccountProviderView( state: ChangeAccountProviderState, - onBackPressed: () -> Unit, - onLearnMoreClicked: () -> Unit, - onDone: () -> Unit, - onOtherProviderClicked: () -> Unit, + onBackClick: () -> Unit, + onLearnMoreClick: () -> Unit, + onSuccess: () -> Unit, + onOtherProviderClick: () -> Unit, modifier: Modifier = Modifier, ) { Scaffold( @@ -66,7 +66,7 @@ fun ChangeAccountProviderView( topBar = { TopAppBar( title = {}, - navigationIcon = { BackButton(onClick = onBackPressed) } + navigationIcon = { BackButton(onClick = onBackClick) } ) } ) { padding -> @@ -111,14 +111,14 @@ fun ChangeAccountProviderView( url = "", title = stringResource(id = R.string.screen_change_account_provider_other), ), - onClick = onOtherProviderClicked + onClick = onOtherProviderClick ) Spacer(Modifier.height(32.dp)) } ChangeServerView( state = state.changeServerState, - onLearnMoreClicked = onLearnMoreClicked, - onDone = onDone, + onLearnMoreClick = onLearnMoreClick, + onSuccess = onSuccess, ) } } @@ -129,9 +129,9 @@ fun ChangeAccountProviderView( internal fun ChangeAccountProviderViewPreview(@PreviewParameter(ChangeAccountProviderStateProvider::class) state: ChangeAccountProviderState) = ElementPreview { ChangeAccountProviderView( state = state, - onBackPressed = { }, - onLearnMoreClicked = { }, - onDone = { }, - onOtherProviderClicked = { }, + onBackClick = { }, + onLearnMoreClick = { }, + onSuccess = { }, + onOtherProviderClick = { }, ) } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderNode.kt index 8ba488d9f4..fe80d06e16 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderNode.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderNode.kt @@ -75,9 +75,9 @@ class ConfirmAccountProviderNode @AssistedInject constructor( state = state, modifier = modifier, onOidcDetails = ::onOidcDetails, - onLoginPasswordNeeded = ::onLoginPasswordNeeded, + onNeedLoginPassword = ::onLoginPasswordNeeded, onChange = ::onChangeAccountProvider, - onLearnMoreClicked = { openLearnMorePage(context) }, + onLearnMoreClick = { openLearnMorePage(context) }, ) } } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderView.kt index dc4e7f3b7b..f8a2f09e2c 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderView.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderView.kt @@ -49,8 +49,8 @@ import io.element.android.libraries.ui.strings.CommonStrings fun ConfirmAccountProviderView( state: ConfirmAccountProviderState, onOidcDetails: (OidcDetails) -> Unit, - onLoginPasswordNeeded: () -> Unit, - onLearnMoreClicked: () -> Unit, + onNeedLoginPassword: () -> Unit, + onLearnMoreClick: () -> Unit, onChange: () -> Unit, modifier: Modifier = Modifier, ) { @@ -118,8 +118,8 @@ fun ConfirmAccountProviderView( ) } is ChangeServerError.SlidingSyncAlert -> { - SlidingSyncNotSupportedDialog(onLearnMoreClicked = { - onLearnMoreClicked() + SlidingSyncNotSupportedDialog(onLearnMoreClick = { + onLearnMoreClick() eventSink(ConfirmAccountProviderEvents.ClearError) }, onDismiss = { eventSink(ConfirmAccountProviderEvents.ClearError) @@ -131,7 +131,7 @@ fun ConfirmAccountProviderView( is AsyncData.Success -> { when (val loginFlowState = state.loginFlow.data) { is LoginFlow.OidcFlow -> onOidcDetails(loginFlowState.oidcDetails) - LoginFlow.PasswordLogin -> onLoginPasswordNeeded() + LoginFlow.PasswordLogin -> onNeedLoginPassword() } } AsyncData.Uninitialized -> Unit @@ -147,8 +147,8 @@ internal fun ConfirmAccountProviderViewPreview( ConfirmAccountProviderView( state = state, onOidcDetails = {}, - onLoginPasswordNeeded = {}, - onLearnMoreClicked = {}, + onNeedLoginPassword = {}, + onLearnMoreClick = {}, onChange = {}, ) } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordNode.kt index f444c7a7dc..e0f611834a 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordNode.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordNode.kt @@ -47,7 +47,7 @@ class LoginPasswordNode @AssistedInject constructor( LoginPasswordView( state = state, modifier = modifier, - onBackPressed = ::navigateUp, + onBackClick = ::navigateUp, onWaitListError = ::onWaitListError, ) } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordView.kt index 51ed3fcf6f..55f4d9e028 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordView.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordView.kt @@ -80,7 +80,7 @@ import io.element.android.libraries.ui.strings.CommonStrings @Composable fun LoginPasswordView( state: LoginPasswordState, - onBackPressed: () -> Unit, + onBackClick: () -> Unit, onWaitListError: (LoginFormState) -> Unit, modifier: Modifier = Modifier, ) { @@ -103,7 +103,7 @@ fun LoginPasswordView( topBar = { TopAppBar( title = {}, - navigationIcon = { BackButton(onClick = onBackPressed) }, + navigationIcon = { BackButton(onClick = onBackClick) }, ) } ) { padding -> @@ -141,7 +141,7 @@ fun LoginPasswordView( // Submit Box( modifier = Modifier - .padding(horizontal = 16.dp) + .padding(horizontal = 16.dp) ) { ButtonColumnMolecule { Button( @@ -201,11 +201,14 @@ private fun LoginForm( .fillMaxWidth() .onTabOrEnterKeyFocusNext(focusManager) .testTag(TestTags.loginEmailUsername) - .autofill(autofillTypes = listOf(AutofillType.Username), onFill = { - val sanitized = it.sanitize() - loginFieldState = sanitized - eventSink(LoginPasswordEvents.SetLogin(sanitized)) - }), + .autofill( + autofillTypes = listOf(AutofillType.Username), + onFill = { + val sanitized = it.sanitize() + loginFieldState = sanitized + eventSink(LoginPasswordEvents.SetLogin(sanitized)) + } + ), placeholder = { Text(text = stringResource(CommonStrings.common_username)) }, @@ -247,11 +250,14 @@ private fun LoginForm( .fillMaxWidth() .onTabOrEnterKeyFocusNext(focusManager) .testTag(TestTags.loginPassword) - .autofill(autofillTypes = listOf(AutofillType.Password), onFill = { - val sanitized = it.sanitize() - passwordFieldState = sanitized - eventSink(LoginPasswordEvents.SetPassword(sanitized)) - }), + .autofill( + autofillTypes = listOf(AutofillType.Password), + onFill = { + val sanitized = it.sanitize() + passwordFieldState = sanitized + eventSink(LoginPasswordEvents.SetPassword(sanitized)) + } + ), onValueChange = { val sanitized = it.sanitize() passwordFieldState = sanitized @@ -304,7 +310,7 @@ private fun LoginErrorDialog(error: Throwable, onDismiss: () -> Unit) { internal fun LoginPasswordViewPreview(@PreviewParameter(LoginPasswordStateProvider::class) state: LoginPasswordState) = ElementPreview { LoginPasswordView( state = state, - onBackPressed = {}, + onBackClick = {}, onWaitListError = {}, ) } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderNode.kt index 834d0400da..ca432cc619 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderNode.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderNode.kt @@ -50,9 +50,9 @@ class SearchAccountProviderNode @AssistedInject constructor( SearchAccountProviderView( state = state, modifier = modifier, - onBackPressed = ::navigateUp, - onLearnMoreClicked = { openLearnMorePage(context) }, - onDone = ::onDone, + onBackClick = ::navigateUp, + onLearnMoreClick = { openLearnMorePage(context) }, + onSuccess = ::onDone, ) } } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderView.kt index 69b145ffd1..9cfa64ba98 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderView.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderView.kt @@ -77,9 +77,9 @@ import io.element.android.libraries.ui.strings.CommonStrings @Composable fun SearchAccountProviderView( state: SearchAccountProviderState, - onBackPressed: () -> Unit, - onLearnMoreClicked: () -> Unit, - onDone: () -> Unit, + onBackClick: () -> Unit, + onLearnMoreClick: () -> Unit, + onSuccess: () -> Unit, modifier: Modifier = Modifier, ) { val eventSink = state.eventSink @@ -88,7 +88,7 @@ fun SearchAccountProviderView( topBar = { TopAppBar( title = {}, - navigationIcon = { BackButton(onClick = onBackPressed) } + navigationIcon = { BackButton(onClick = onBackClick) } ) } ) { padding -> @@ -188,8 +188,8 @@ fun SearchAccountProviderView( } ChangeServerView( state = state.changeServerState, - onLearnMoreClicked = onLearnMoreClicked, - onDone = onDone, + onLearnMoreClick = onLearnMoreClick, + onSuccess = onSuccess, ) } } @@ -214,8 +214,8 @@ private fun HomeserverData.toAccountProvider(): AccountProvider { internal fun SearchAccountProviderViewPreview(@PreviewParameter(SearchAccountProviderStateProvider::class) state: SearchAccountProviderState) = ElementPreview { SearchAccountProviderView( state = state, - onBackPressed = {}, - onLearnMoreClicked = {}, - onDone = {}, + onBackClick = {}, + onLearnMoreClick = {}, + onSuccess = {}, ) } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/waitlistscreen/WaitListNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/waitlistscreen/WaitListNode.kt index e27d5f5608..237e7ed8ef 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/waitlistscreen/WaitListNode.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/waitlistscreen/WaitListNode.kt @@ -42,11 +42,11 @@ class WaitListNode @AssistedInject constructor( private val presenter = presenterFactory.create(inputs.loginFormState) interface Callback : Plugin { - fun onCancelClicked() + fun onCancelClick() } - private fun onCancelClicked() { - plugins().forEach { it.onCancelClicked() } + private fun onCancelClick() { + plugins().forEach { it.onCancelClick() } } @Composable @@ -54,7 +54,7 @@ class WaitListNode @AssistedInject constructor( val state = presenter.present() WaitListView( state = state, - onCancelClicked = ::onCancelClicked, + onCancelClick = ::onCancelClick, modifier = modifier ) } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/waitlistscreen/WaitListView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/waitlistscreen/WaitListView.kt index 7a47255b6c..e21be61b5c 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/waitlistscreen/WaitListView.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/waitlistscreen/WaitListView.kt @@ -48,7 +48,7 @@ import io.element.android.libraries.ui.strings.CommonStrings @Composable fun WaitListView( state: WaitListState, - onCancelClicked: () -> Unit, + onCancelClick: () -> Unit, modifier: Modifier = Modifier, ) { OnLifecycleEvent { _, event -> @@ -57,7 +57,7 @@ fun WaitListView( else -> Unit } } - WaitListContent(state, onCancelClicked, modifier) + WaitListContent(state, onCancelClick, modifier) } @Composable @@ -81,7 +81,7 @@ private fun WaitListError(state: WaitListState) { @Composable private fun WaitListContent( state: WaitListState, - onCancelClicked: () -> Unit, + onCancelClick: () -> Unit, modifier: Modifier = Modifier, ) { Box( @@ -109,7 +109,7 @@ private fun WaitListContent( title = title, subtitle = subtitle, ) { - OverallContent(state, onCancelClicked) + OverallContent(state, onCancelClick) } WaitListError(state) } @@ -118,14 +118,14 @@ private fun WaitListContent( @Composable private fun OverallContent( state: WaitListState, - onCancelClicked: () -> Unit, + onCancelClick: () -> Unit, ) { Box(modifier = Modifier.fillMaxSize()) { if (state.loginAction !is AsyncData.Success) { CompositionLocalProvider(LocalContentColor provides ElementTheme.colors.textOnSolidPrimary) { TextButton( text = stringResource(CommonStrings.action_cancel), - onClick = onCancelClicked, + onClick = onCancelClick, ) } } @@ -147,6 +147,6 @@ private fun OverallContent( internal fun WaitListViewPreview(@PreviewParameter(WaitListStateProvider::class) state: WaitListState) = ElementPreview { WaitListView( state = state, - onCancelClicked = {}, + onCancelClick = {}, ) } diff --git a/features/login/impl/src/main/res/values-es/translations.xml b/features/login/impl/src/main/res/values-es/translations.xml index 28e003edc6..31df1a3d04 100644 --- a/features/login/impl/src/main/res/values-es/translations.xml +++ b/features/login/impl/src/main/res/values-es/translations.xml @@ -14,6 +14,8 @@ "Usa un proveedor de cuenta diferente, como tu propio servidor privado o una cuenta de trabajo." "Cambiar el proveedor de la cuenta" "No hemos podido acceder a este servidor. Comprueba que has introducido correctamente la dirección del servidor. Si la dirección es correcta, ponte en contacto con el administrador del servidor para obtener más ayuda." + "Sliding sync no está disponible debido a un problema en el archivo well-known: +%1$s" "Este servidor no soporta sliding sync." "Dirección del homeserver" "Solo puedes conectarte a un servidor que soporte sliding sync. El administrador de tu servidor tendrá que configurarlo. %1$s" @@ -22,6 +24,7 @@ "Esta cuenta ha sido desactivada." "Usuario y/o contraseña incorrectos" "Este no es un id de usuario válido. Formato esperado: \'@user:homeserver.org\'" + "Este servidor está configurado para utilizar tokens de actualización. Estos no son compatibles cuando se utiliza el inicio de sesión basado en contraseña." "El servidor seleccionado no admite contraseñas ni inicio de sesión OIDC. Póngase en contacto con su administrador o elija otro homeserver." "Introduce tus datos" "Matrix es una red abierta para una comunicación segura y descentralizada." diff --git a/features/login/impl/src/main/res/values-ka/translations.xml b/features/login/impl/src/main/res/values-ka/translations.xml new file mode 100644 index 0000000000..2187fc9e95 --- /dev/null +++ b/features/login/impl/src/main/res/values-ka/translations.xml @@ -0,0 +1,42 @@ + + + "ანგარიშის მიმწოდებლის შეცვლა" + "სახლის სერვერის მისამართი" + "შეიყვანეთ საძიებო სიტყვა ან დომენის მისამართი." + "მოძებნეთ კომპანია, საზოგადოება ან კერძო სერვერი." + "ანგარიშის მომწოდებლის მოძებნა" + "აქ იქნება თქვენი საუბრები - ისევე, როგორც თქვენ ელ. ფოსტაში ინახება თქვენი ელ.წერილები." + "თქვენ აპირებთ შესვლას %s-ში" + "აქ იქნება თქვენი საუბრები - ისევე, როგორც თქვენ ელ. ფოსტაში ინახება თქვენი ელ.წერილები." + "თქვენ აპირებთ ანგარიშის შექმნას %s-ში" + "Matrix.org არის დიდი, უფასო სერვერი საჯარო Matrix ქსელში უსაფრთხო, დეცენტრალიზებული კომუნიკაციისთვის, რომელსაც მართავს Matrix.org ფონდი." + "სხვა" + "გამოიყენეთ სხვა ანგარიშის პროვაიდერი, როგორიცაა თქვენი პირადი სერვერი ან სამუშაო ანგარიში." + "შეცვალეთ ანგარიშის მომწოდებელი" + "ჩვენ ვერ მივაღწიეთ ამ სახლის სერვერს. გთხოვთ, შეამოწმოთ, რომ სწორად შეიყვანეთ სახლის სერვერის URL. თუ URL სწორია, დაუკავშირდით თქვენი სახლის სერვერის ადმინისტრატორს დამატებითი დახმარებისთვის." + "ამჟამად ეს სერვერი მხარს არ უჭერს \"sliding sync\"-ს." + "სახლის სერვერის URL" + "თქვენ შეგიძლიათ დაუკავშირდეთ მხოლოდ იმ სერვერს, რომელიც მხარს უჭერს \"sliding sync\"-ს. თქვენი სახლის სერვერის ადმინისტრატორს დასჭირდება მისი კონფიგურაცია.%1$s" + "რა არის თქვენი სერვერის მისამართი?" + "აირჩიეთ თქვენი სერვერი" + "ეს ანგარიში დეაქტივირებულია." + "არასწორი მომხმარებლის სახელი და/ან პაროლი" + "მოცემული მომხმარებლის იდენტიფიკატორი არასწორია. დასაშვები ფორმატი: ‘@user:homeserver.org’" + "მოცემული სახლის სერვერი მხარს არ უჭერს პაროლით ან OIDC-ით შესვლას. გთხოვთ, დაუკავშირდეთ თქვენს ადმინისტრატორს ან აარჩიეთ სხვა სახლის სერვერი." + "შეიყვანეთ თქვენი დეტალები" + "Matrix არის ღია ქსელი უსაფრთხო, დეცენტრალიზებული კომუნიკაციისთვის." + "კეთილი იყოს თქვენი მობრძანება!" + "შესვლა %1$s-ში" + "შეცვალეთ ანგარიშის მომწოდებელი" + "კერძო სერვერი Element-ის თანამშრომლებისთვის." + "Matrix არის ღია ქსელი უსაფრთხო, დეცენტრალიზებული კომუნიკაციისთვის." + "აქ იქნება თქვენი საუბრები - ისევე, როგორც თქვენ ელ. ფოსტაში ინახება თქვენი ელ.წერილები." + "თქვენ აპირებთ შესვლას %1$s-ში" + "თქვენ აპირებთ ანგარიშის შექმნას %1$s-ში" + "ახლა დიდი მოთხოვნაა %1$s-ზე %2$s-ში. დაბრუნდით რამდენიმე დღეში და სცადეთ ერთხელაც. + +მადლობა მოთმენისათვის!" + "კეთილი იყოს თქვენი მობრძანება %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 new file mode 100644 index 0000000000..09e8b8ba01 --- /dev/null +++ b/features/login/impl/src/main/res/values-pt-rBR/translations.xml @@ -0,0 +1,45 @@ + + + "Alterar operador de conta" + "Endereço do servidor" + "Insira um termo para pesquisa ou um endereço." + "Pesquisar por uma empresa, comunidade ou servidor privado." + "Encontrar um operador de conta" + "É aqui que as tuas conversas vão ficar — tal como num serviço de e-mail." + "Irás iniciar sessão em %s" + "É aqui que as tuas conversas vão ficar — tal como num serviço de e-mail." + "Irás criar uma conta em %s" + "O Matrix.org é um servidor grande e gratuito na rede pública Matrix para comunicação segura e descentralizada, gerido pela Fundação Matrix.org." + "Outro" + "Utiliza um operador de conta diferente, como o teu próprio servidor privado ou uma conta de trabalho." + "Alterar operador de conta" + "Não foi possível comunicar com este servidor. Por favor, verifica se introduziste o seu URL corretamente. Se sim, contacta o administrador para obteres mais ajuda." + "A sincronização deslizante (sliding sync) não está disponível devido a um problema no ficheiro \"well-known\": +%1$s" + "Este servidor não suporta sincronização deslizante (sliding sync)." + "URL do servidor" + "Só te podes ligar a um servidor existente que suporte a sincronização deslizante (sliding sync). O administrador do teu servidor terá de a configurar. %1$s" + "Qual é o endereço do teu servidor?" + "Seleciona o teu servidor" + "Esta conta foi desativada." + "Nome de utilizador ou senha incorretos" + "Identificador de utilizador inválido. Formato esperado: ‘@utilizador:servidor.org’" + "Este servidor está configurado para utilizar \"tokens\" de atualização. Estes não são suportados quando utilizas o início de sessão por senha." + "O servidor selecionado não suporta início de sessão por senha nem por OIDC. Por favor, contacta o teu administrador ou escolhe outro servidor." + "Insere o teus detalhes" + "A Matrix é uma rede aberta de comunicação descentralizada e segura." + "Bem-vindo(a) de volta!" + "Iniciar sessão em %1$s" + "Alterar operador de conta" + "Um servidor privado para funcionários da Element." + "A Matrix é uma rede aberta de comunicação descentralizada e segura." + "É aqui que as tuas conversas vão ficar — tal como num serviço de e-mail." + "Irás iniciar sessão em %1$s" + "Irás criar uma conta em %1$s" + "Há uma grande procura pela %1$s no %2$s, de momento. Volta à aplicação daqui a uns dias e tenta novamente. + +Obrigado!" + "Bem-vindo à %1$s!" + "Estás quase lá." + "Está dentro" + diff --git a/features/login/impl/src/main/res/values-ro/translations.xml b/features/login/impl/src/main/res/values-ro/translations.xml index 3f384a8764..8087fc7c44 100644 --- a/features/login/impl/src/main/res/values-ro/translations.xml +++ b/features/login/impl/src/main/res/values-ro/translations.xml @@ -14,6 +14,8 @@ "Utilizați un alt furnizor de cont, cum ar fi propriul server privat sau un cont de serviciu." "Schimbați furnizorul contului" "Nu am putut accesa acest homeserver. Te rugăm să verifici că ai introdus corect adresa URL a homeserver-ului. Dacă adresa URL este corectă, contactează administratorul homeserver-ului pentru ajutor suplimentar." + "Sliding sync nu este disponibil din cauza unei probleme în fișierul well-known: +%1$s" "Momentan acest server nu oferă suport pentru sliding sync." "Adresa URL a homeserver-ului" "Vă putețo conecta numai la un server existent care oferă suport pentru sliding sync. Administratorul homeserver-ului dumneavoastră va trebui să îl configureze. %1$s" @@ -22,6 +24,7 @@ "Acest cont a fost dezactivat." "Utilizator și/sau parolă incorecte" "Acesta nu este un identificator de utilizator valid. Format așteptat: „@user:homeserver.org”" + "Acest server este configurat pentru a utiliza token-uri de reîmprospătare. Acestea nu sunt acceptate atunci când utilizați autentificare bazată pe parolă." "Homeserver-ul selectat nu acceptă autentificarea prin parola sau OIDC. Te rugăm să contactezi administratorul sau să alegi un alt homeserver." "Introduceți detaliile" "Matrix este o rețea deschisă pentru o comunicare sigură și descentralizată." diff --git a/features/login/impl/src/main/res/values-sv/translations.xml b/features/login/impl/src/main/res/values-sv/translations.xml index 011552465a..800af345fd 100644 --- a/features/login/impl/src/main/res/values-sv/translations.xml +++ b/features/login/impl/src/main/res/values-sv/translations.xml @@ -14,6 +14,8 @@ "Använd en annan kontoleverantör, till exempel din egen privata server eller ett jobbkonto." "Byt kontoleverantör" "Vi kunde inte nå den här hemservern. Kontrollera att du har angett hemserverns URL korrekt. Om URL:en är korrekt kontaktar du administratören för hemservern för ytterligare hjälp." + "Sliding Sync är inte tillgängligt på grund av ett problem i well-known-filen: +%1$s" "Den här servern stöder för närvarande inte sliding sync." "Hemserverns URL" "Du kan bara ansluta till en befintlig server som stöder sliding sync. Din hemserveradministratör måste konfigurera det. %1$s" @@ -22,6 +24,7 @@ "Detta konto har avaktiverats." "Felaktigt användarnamn och/eller lösenord" "Detta är inte en giltig användaridentifierare. Förväntat format: \'@användare:hemserver.org\'" + "Den här servern är konfigurerad för att använda uppdateringstokens. Dessa stöds inte när du använder lösenordsbaserad inloggning." "Den valda hemservern stöder inte lösenord eller OIDC-inloggning. Kontakta administratören eller välj en annan hemserver." "Ange dina uppgifter" "Matrix är ett öppet nätverk för säker, decentraliserad kommunikation." diff --git a/features/login/impl/src/main/res/values-zh/translations.xml b/features/login/impl/src/main/res/values-zh/translations.xml new file mode 100644 index 0000000000..76bbabb021 --- /dev/null +++ b/features/login/impl/src/main/res/values-zh/translations.xml @@ -0,0 +1,45 @@ + + + "更改账户提供者" + "服务器地址" + "输入搜索词或域名地址。" + "搜索公司、社区或私人服务器。" + "查找账户提供者" + "这是您的对话将进行的地方,就像您使用电子邮件提供商来保存电子邮件一样。" + "您即将登录%s" + "这是您的对话将进行的地方,就像您使用电子邮件提供商来保存电子邮件一样。" + "您即将在 %s 上创建一个帐户" + "Matrix.org 由 Matrix.org 基金会运营,是用于安全、去中心化的通信的公共 Matrix 网络上的大型免费服务器。" + "其他" + "使用其他帐户提供者,例如您自己的私人服务器或工作帐户。" + "更改账户提供者" + "我们无法访问此主服务器。请检查您输入的主服务器网址是否正确。如果 URL 正确,请联系您的主服务器管理员寻求进一步帮助。" + "由于 Well Known 文件中的问题,Sliding Sync 不可用: +%1$s" + "该服务器目前不支持sliding sync。" + "主服务器网址" + "您只能连接到支持sliding sync的现有服务器。您的主服务器管理员需要对其进行配置。%1$s" + "您的服务器地址是什么?" + "选择服务器" + "该账户已被停用。" + "错误的用户名和/或密码" + "这不是合法的用户 ID。期望格式:‘@user:homeserver.org’。" + "此服务器使用刷新令牌。使用密码登录时不支持这些功能。" + "该服务器不支持密码登录和 OIDC 第三方账户登录。请联系服务器管理员,或选择别的服务器。" + "输入您的详细信息" + "Matrix 是一个用于安全、去中心化通信的开放网络。" + "欢迎回来!" + "登录到 %1$s" + "更改账户提供者" + "专为 Element 员工提供的私人服务器。" + "Matrix 是一个用于安全、去中心化通信的开放网络。" + "这是您的对话将进行的地方,就像您使用电子邮件提供商来保存电子邮件一样。" + "你即将登录 %1$s" + "你即将在 %1$s 上创建一个账户" + "目前 %1$s 上 %2$s 的负载很大。过几天再回来试试吧。 + +感谢您的耐心!" + "欢迎使用 %1$s" + "马上就好。" + "您已加入。" + diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenterTest.kt index f8518f6033..e6969921f3 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenterTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenterTest.kt @@ -25,7 +25,7 @@ import io.element.android.features.login.impl.accountprovider.AccountProviderDat import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.matrix.test.A_HOMESERVER import io.element.android.libraries.matrix.test.A_HOMESERVER_URL -import io.element.android.libraries.matrix.test.auth.FakeAuthenticationService +import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService import io.element.android.tests.testutils.WarmUpRule import kotlinx.coroutines.test.runTest import org.junit.Rule @@ -38,7 +38,7 @@ class ChangeServerPresenterTest { @Test fun `present - initial state`() = runTest { val presenter = ChangeServerPresenter( - FakeAuthenticationService(), + FakeMatrixAuthenticationService(), AccountProviderDataSource() ) moleculeFlow(RecompositionMode.Immediate) { @@ -51,7 +51,7 @@ class ChangeServerPresenterTest { @Test fun `present - change server ok`() = runTest { - val authenticationService = FakeAuthenticationService() + val authenticationService = FakeMatrixAuthenticationService() val presenter = ChangeServerPresenter( authenticationService, AccountProviderDataSource() @@ -72,7 +72,7 @@ class ChangeServerPresenterTest { @Test fun `present - change server error`() = runTest { - val authenticationService = FakeAuthenticationService() + val authenticationService = FakeMatrixAuthenticationService() val presenter = ChangeServerPresenter( authenticationService, AccountProviderDataSource() diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/oidc/webview/OidcPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/oidc/webview/OidcPresenterTest.kt index 34b497866a..38d0506dd8 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/oidc/webview/OidcPresenterTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/oidc/webview/OidcPresenterTest.kt @@ -26,7 +26,7 @@ import io.element.android.features.login.api.oidc.OidcAction import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.matrix.test.A_THROWABLE import io.element.android.libraries.matrix.test.auth.A_OIDC_DATA -import io.element.android.libraries.matrix.test.auth.FakeAuthenticationService +import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService import io.element.android.tests.testutils.WarmUpRule import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest @@ -41,7 +41,7 @@ class OidcPresenterTest { fun `present - initial state`() = runTest { val presenter = OidcPresenter( A_OIDC_DATA, - FakeAuthenticationService(), + FakeMatrixAuthenticationService(), ) moleculeFlow(RecompositionMode.Immediate) { presenter.present() @@ -56,7 +56,7 @@ class OidcPresenterTest { fun `present - go back`() = runTest { val presenter = OidcPresenter( A_OIDC_DATA, - FakeAuthenticationService(), + FakeMatrixAuthenticationService(), ) moleculeFlow(RecompositionMode.Immediate) { presenter.present() @@ -72,7 +72,7 @@ class OidcPresenterTest { @Test fun `present - go back with failure`() = runTest { - val authenticationService = FakeAuthenticationService() + val authenticationService = FakeMatrixAuthenticationService() val presenter = OidcPresenter( A_OIDC_DATA, authenticationService, @@ -95,7 +95,7 @@ class OidcPresenterTest { fun `present - user cancels from webview`() = runTest { val presenter = OidcPresenter( A_OIDC_DATA, - FakeAuthenticationService(), + FakeMatrixAuthenticationService(), ) moleculeFlow(RecompositionMode.Immediate) { presenter.present() @@ -113,7 +113,7 @@ class OidcPresenterTest { fun `present - login success`() = runTest { val presenter = OidcPresenter( A_OIDC_DATA, - FakeAuthenticationService(), + FakeMatrixAuthenticationService(), ) moleculeFlow(RecompositionMode.Immediate) { presenter.present() @@ -128,7 +128,7 @@ class OidcPresenterTest { @Test fun `present - login error`() = runTest { - val authenticationService = FakeAuthenticationService() + val authenticationService = FakeMatrixAuthenticationService() val presenter = OidcPresenter( A_OIDC_DATA, authenticationService, diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderPresenterTest.kt index 04680ede17..f7696652d0 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderPresenterTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderPresenterTest.kt @@ -23,7 +23,7 @@ import com.google.common.truth.Truth.assertThat import io.element.android.features.login.impl.accountprovider.AccountProvider import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource import io.element.android.features.login.impl.changeserver.ChangeServerPresenter -import io.element.android.libraries.matrix.test.auth.FakeAuthenticationService +import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService import io.element.android.tests.testutils.WarmUpRule import kotlinx.coroutines.test.runTest import org.junit.Rule @@ -36,7 +36,7 @@ class ChangeAccountProviderPresenterTest { @Test fun `present - initial state`() = runTest { val changeServerPresenter = ChangeServerPresenter( - FakeAuthenticationService(), + FakeMatrixAuthenticationService(), AccountProviderDataSource() ) val presenter = ChangeAccountProviderPresenter( diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt index 57f18d7eb4..5a02797bdb 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt @@ -30,7 +30,7 @@ import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService import io.element.android.libraries.matrix.test.A_HOMESERVER import io.element.android.libraries.matrix.test.A_HOMESERVER_OIDC import io.element.android.libraries.matrix.test.A_THROWABLE -import io.element.android.libraries.matrix.test.auth.FakeAuthenticationService +import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.waitForPredicate import kotlinx.coroutines.test.runTest @@ -57,7 +57,7 @@ class ConfirmAccountProviderPresenterTest { @Test fun `present - continue password login`() = runTest { - val authenticationService = FakeAuthenticationService() + val authenticationService = FakeMatrixAuthenticationService() val presenter = createConfirmAccountProviderPresenter( matrixAuthenticationService = authenticationService, ) @@ -79,7 +79,7 @@ class ConfirmAccountProviderPresenterTest { @Test fun `present - continue oidc`() = runTest { - val authenticationService = FakeAuthenticationService() + val authenticationService = FakeMatrixAuthenticationService() val presenter = createConfirmAccountProviderPresenter( matrixAuthenticationService = authenticationService, ) @@ -101,7 +101,7 @@ class ConfirmAccountProviderPresenterTest { @Test fun `present - oidc - cancel with failure`() = runTest { - val authenticationService = FakeAuthenticationService() + val authenticationService = FakeMatrixAuthenticationService() val defaultOidcActionFlow = DefaultOidcActionFlow() val presenter = createConfirmAccountProviderPresenter( matrixAuthenticationService = authenticationService, @@ -129,7 +129,7 @@ class ConfirmAccountProviderPresenterTest { @Test fun `present - oidc - cancel with success`() = runTest { - val authenticationService = FakeAuthenticationService() + val authenticationService = FakeMatrixAuthenticationService() val defaultOidcActionFlow = DefaultOidcActionFlow() val presenter = createConfirmAccountProviderPresenter( matrixAuthenticationService = authenticationService, @@ -156,7 +156,7 @@ class ConfirmAccountProviderPresenterTest { @Test fun `present - oidc - success with failure`() = runTest { - val authenticationService = FakeAuthenticationService() + val authenticationService = FakeMatrixAuthenticationService() val defaultOidcActionFlow = DefaultOidcActionFlow() val presenter = createConfirmAccountProviderPresenter( matrixAuthenticationService = authenticationService, @@ -186,7 +186,7 @@ class ConfirmAccountProviderPresenterTest { @Test fun `present - oidc - success with success`() = runTest { - val authenticationService = FakeAuthenticationService() + val authenticationService = FakeMatrixAuthenticationService() val defaultOidcActionFlow = DefaultOidcActionFlow() val defaultLoginUserStory = DefaultLoginUserStory().apply { setLoginFlowIsDone(false) @@ -219,7 +219,7 @@ class ConfirmAccountProviderPresenterTest { @Test fun `present - submit fails`() = runTest { - val authenticationService = FakeAuthenticationService() + val authenticationService = FakeMatrixAuthenticationService() val presenter = createConfirmAccountProviderPresenter( matrixAuthenticationService = authenticationService, ) @@ -238,7 +238,7 @@ class ConfirmAccountProviderPresenterTest { @Test fun `present - clear error`() = runTest { - val authenticationService = FakeAuthenticationService() + val authenticationService = FakeMatrixAuthenticationService() val presenter = createConfirmAccountProviderPresenter( matrixAuthenticationService = authenticationService, ) @@ -267,7 +267,7 @@ class ConfirmAccountProviderPresenterTest { private fun createConfirmAccountProviderPresenter( params: ConfirmAccountProviderPresenter.Params = ConfirmAccountProviderPresenter.Params(isAccountCreation = false), accountProviderDataSource: AccountProviderDataSource = AccountProviderDataSource(), - matrixAuthenticationService: MatrixAuthenticationService = FakeAuthenticationService(), + matrixAuthenticationService: MatrixAuthenticationService = FakeMatrixAuthenticationService(), defaultOidcActionFlow: DefaultOidcActionFlow = DefaultOidcActionFlow(), defaultLoginUserStory: DefaultLoginUserStory = DefaultLoginUserStory(), ) = ConfirmAccountProviderPresenter( diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordPresenterTest.kt index 4658df0ab9..db73c25687 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordPresenterTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordPresenterTest.kt @@ -30,7 +30,7 @@ import io.element.android.libraries.matrix.test.A_PASSWORD import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_THROWABLE import io.element.android.libraries.matrix.test.A_USER_NAME -import io.element.android.libraries.matrix.test.auth.FakeAuthenticationService +import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService import io.element.android.tests.testutils.WarmUpRule import kotlinx.coroutines.test.runTest import org.junit.Rule @@ -42,7 +42,7 @@ class LoginPasswordPresenterTest { @Test fun `present - initial state`() = runTest { - val authenticationService = FakeAuthenticationService() + val authenticationService = FakeMatrixAuthenticationService() val accountProviderDataSource = AccountProviderDataSource() val loginUserStory = DefaultLoginUserStory() val presenter = LoginPasswordPresenter( @@ -63,7 +63,7 @@ class LoginPasswordPresenterTest { @Test fun `present - enter login and password`() = runTest { - val authenticationService = FakeAuthenticationService() + val authenticationService = FakeMatrixAuthenticationService() val accountProviderDataSource = AccountProviderDataSource() val loginUserStory = DefaultLoginUserStory() val presenter = LoginPasswordPresenter( @@ -89,7 +89,7 @@ class LoginPasswordPresenterTest { @Test fun `present - submit`() = runTest { - val authenticationService = FakeAuthenticationService() + val authenticationService = FakeMatrixAuthenticationService() val accountProviderDataSource = AccountProviderDataSource() val loginUserStory = DefaultLoginUserStory().apply { setLoginFlowIsDone(false) } val presenter = LoginPasswordPresenter( @@ -118,7 +118,7 @@ class LoginPasswordPresenterTest { @Test fun `present - submit with error`() = runTest { - val authenticationService = FakeAuthenticationService() + val authenticationService = FakeMatrixAuthenticationService() val accountProviderDataSource = AccountProviderDataSource() val loginUserStory = DefaultLoginUserStory() val presenter = LoginPasswordPresenter( @@ -146,7 +146,7 @@ class LoginPasswordPresenterTest { @Test fun `present - clear error`() = runTest { - val authenticationService = FakeAuthenticationService() + val authenticationService = FakeMatrixAuthenticationService() val accountProviderDataSource = AccountProviderDataSource() val loginUserStory = DefaultLoginUserStory() val presenter = LoginPasswordPresenter( diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderPresenterTest.kt index 28aa1090a9..5288746583 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderPresenterTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderPresenterTest.kt @@ -29,7 +29,7 @@ import io.element.android.features.login.impl.resolver.network.WellKnownBaseConf import io.element.android.features.login.impl.resolver.network.WellKnownSlidingSyncConfig import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.matrix.test.A_HOMESERVER_URL -import io.element.android.libraries.matrix.test.auth.FakeAuthenticationService +import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.testCoroutineDispatchers import kotlinx.coroutines.test.runTest @@ -44,7 +44,7 @@ class SearchAccountProviderPresenterTest { fun `present - initial state`() = runTest { val fakeWellknownRequest = FakeWellknownRequest() val changeServerPresenter = ChangeServerPresenter( - FakeAuthenticationService(), + FakeMatrixAuthenticationService(), AccountProviderDataSource() ) val presenter = SearchAccountProviderPresenter( @@ -64,7 +64,7 @@ class SearchAccountProviderPresenterTest { fun `present - enter text no result`() = runTest { val fakeWellknownRequest = FakeWellknownRequest() val changeServerPresenter = ChangeServerPresenter( - FakeAuthenticationService(), + FakeMatrixAuthenticationService(), AccountProviderDataSource() ) val presenter = SearchAccountProviderPresenter( @@ -88,7 +88,7 @@ class SearchAccountProviderPresenterTest { fun `present - enter valid url no wellknown`() = runTest { val fakeWellknownRequest = FakeWellknownRequest() val changeServerPresenter = ChangeServerPresenter( - FakeAuthenticationService(), + FakeMatrixAuthenticationService(), AccountProviderDataSource() ) val presenter = SearchAccountProviderPresenter( @@ -123,7 +123,7 @@ class SearchAccountProviderPresenterTest { ) ) val changeServerPresenter = ChangeServerPresenter( - FakeAuthenticationService(), + FakeMatrixAuthenticationService(), AccountProviderDataSource() ) val presenter = SearchAccountProviderPresenter( @@ -158,7 +158,7 @@ class SearchAccountProviderPresenterTest { ) ) val changeServerPresenter = ChangeServerPresenter( - FakeAuthenticationService(), + FakeMatrixAuthenticationService(), AccountProviderDataSource() ) val presenter = SearchAccountProviderPresenter( diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/waitlistscreen/WaitListPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/waitlistscreen/WaitListPresenterTest.kt index f88f47ae26..4f751a5c9e 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/waitlistscreen/WaitListPresenterTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/waitlistscreen/WaitListPresenterTest.kt @@ -28,7 +28,7 @@ import io.element.android.libraries.matrix.test.A_HOMESERVER import io.element.android.libraries.matrix.test.A_HOMESERVER_URL import io.element.android.libraries.matrix.test.A_THROWABLE import io.element.android.libraries.matrix.test.A_USER_ID -import io.element.android.libraries.matrix.test.auth.FakeAuthenticationService +import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.tests.testutils.WarmUpRule import kotlinx.coroutines.test.runTest @@ -41,7 +41,7 @@ class WaitListPresenterTest { @Test fun `present - initial state`() = runTest { - val authenticationService = FakeAuthenticationService().apply { + val authenticationService = FakeMatrixAuthenticationService().apply { givenHomeserver(A_HOMESERVER) } val loginUserStory = DefaultLoginUserStory() @@ -63,7 +63,7 @@ class WaitListPresenterTest { @Test fun `present - attempt login with error`() = runTest { - val authenticationService = FakeAuthenticationService().apply { + val authenticationService = FakeMatrixAuthenticationService().apply { givenLoginError(A_THROWABLE) } val loginUserStory = DefaultLoginUserStory() @@ -94,7 +94,7 @@ class WaitListPresenterTest { @Test fun `present - attempt login with success`() = runTest { - val authenticationService = FakeAuthenticationService() + val authenticationService = FakeMatrixAuthenticationService() val loginUserStory = DefaultLoginUserStory().apply { setLoginFlowIsDone(false) } val presenter = WaitListPresenter( LoginFormState.Default, diff --git a/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/LogoutEntryPoint.kt b/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/LogoutEntryPoint.kt index b750a47e6d..89ccfb227e 100644 --- a/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/LogoutEntryPoint.kt +++ b/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/LogoutEntryPoint.kt @@ -30,6 +30,6 @@ interface LogoutEntryPoint : FeatureEntryPoint { } interface Callback : Plugin { - fun onChangeRecoveryKeyClicked() + fun onChangeRecoveryKeyClick() } } diff --git a/features/logout/impl/build.gradle.kts b/features/logout/impl/build.gradle.kts index 699d6c93b8..1383384c84 100644 --- a/features/logout/impl/build.gradle.kts +++ b/features/logout/impl/build.gradle.kts @@ -55,7 +55,6 @@ dependencies { testImplementation(libs.test.truth) testImplementation(libs.test.turbine) testImplementation(libs.test.robolectric) - testImplementation(libs.test.junitext) testImplementation(libs.androidx.compose.ui.test.junit) testReleaseImplementation(libs.androidx.compose.ui.test.manifest) testImplementation(projects.libraries.matrix.test) diff --git a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutNode.kt b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutNode.kt index 14c7199416..4f5bab10b9 100644 --- a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutNode.kt +++ b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutNode.kt @@ -38,8 +38,8 @@ class LogoutNode @AssistedInject constructor( @Assisted plugins: List, private val presenter: LogoutPresenter, ) : Node(buildContext, plugins = plugins) { - private fun onChangeRecoveryKeyClicked() { - plugins().forEach { it.onChangeRecoveryKeyClicked() } + private fun onChangeRecoveryKeyClick() { + plugins().forEach { it.onChangeRecoveryKeyClick() } } private fun onSuccessLogout(activity: Activity, url: String?) { @@ -55,9 +55,9 @@ class LogoutNode @AssistedInject constructor( val activity = LocalContext.current as Activity LogoutView( state = state, - onChangeRecoveryKeyClicked = ::onChangeRecoveryKeyClicked, + onChangeRecoveryKeyClick = ::onChangeRecoveryKeyClick, onSuccessLogout = { onSuccessLogout(activity, it) }, - onBackClicked = ::navigateUp, + onBackClick = ::navigateUp, modifier = modifier, ) } diff --git a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutView.kt b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutView.kt index 19160236ac..9ccd7de985 100644 --- a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutView.kt +++ b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutView.kt @@ -51,37 +51,38 @@ import io.element.android.libraries.ui.strings.CommonStrings @Composable fun LogoutView( state: LogoutState, - onChangeRecoveryKeyClicked: () -> Unit, - onBackClicked: () -> Unit, + onChangeRecoveryKeyClick: () -> Unit, + onBackClick: () -> Unit, onSuccessLogout: (logoutUrlResult: String?) -> Unit, modifier: Modifier = Modifier, ) { val eventSink = state.eventSink FlowStepPage( - onBackClicked = onBackClicked, + onBackClick = onBackClick, title = title(state), subTitle = subtitle(state), iconVector = CompoundIcons.KeySolid(), modifier = modifier, - content = { Content(state) }, buttons = { Buttons( state = state, - onChangeRecoveryKeyClicked = onChangeRecoveryKeyClicked, - onLogoutClicked = { + onChangeRecoveryKeyClick = onChangeRecoveryKeyClick, + onLogoutClick = { eventSink(LogoutEvents.Logout(ignoreSdkError = false)) } ) }, - ) + ) { + Content(state) + } LogoutActionDialog( state.logoutAction, - onConfirmClicked = { + onConfirmClick = { eventSink(LogoutEvents.Logout(ignoreSdkError = false)) }, - onForceLogoutClicked = { + onForceLogoutClick = { eventSink(LogoutEvents.Logout(ignoreSdkError = true)) }, onDismissDialog = { @@ -124,15 +125,15 @@ private fun subtitle(state: LogoutState): String? { @Composable private fun ColumnScope.Buttons( state: LogoutState, - onLogoutClicked: () -> Unit, - onChangeRecoveryKeyClicked: () -> Unit, + onLogoutClick: () -> Unit, + onChangeRecoveryKeyClick: () -> Unit, ) { val logoutAction = state.logoutAction if (state.isLastDevice) { OutlinedButton( text = stringResource(id = CommonStrings.common_settings), modifier = Modifier.fillMaxWidth(), - onClick = onChangeRecoveryKeyClicked, + onClick = onChangeRecoveryKeyClick, ) } val signOutSubmitRes = when { @@ -147,7 +148,7 @@ private fun ColumnScope.Buttons( modifier = Modifier .fillMaxWidth() .testTag(TestTags.signOut), - onClick = onLogoutClicked, + onClick = onLogoutClick, ) } @@ -183,8 +184,8 @@ internal fun LogoutViewPreview( ) = ElementPreview { LogoutView( state, - onChangeRecoveryKeyClicked = {}, + onChangeRecoveryKeyClick = {}, onSuccessLogout = {}, - onBackClicked = {}, + onBackClick = {}, ) } diff --git a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/direct/DefaultDirectLogoutView.kt b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/direct/DefaultDirectLogoutView.kt index 0cc5cfe476..b0c6c4b70e 100644 --- a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/direct/DefaultDirectLogoutView.kt +++ b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/direct/DefaultDirectLogoutView.kt @@ -39,10 +39,10 @@ class DefaultDirectLogoutView @Inject constructor() : DirectLogoutView { val eventSink = state.eventSink LogoutActionDialog( state.logoutAction, - onConfirmClicked = { + onConfirmClick = { eventSink(DirectLogoutEvents.Logout(ignoreSdkError = false)) }, - onForceLogoutClicked = { + onForceLogoutClick = { eventSink(DirectLogoutEvents.Logout(ignoreSdkError = true)) }, onDismissDialog = { diff --git a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/ui/LogoutActionDialog.kt b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/ui/LogoutActionDialog.kt index 18e2809034..36fff3afee 100644 --- a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/ui/LogoutActionDialog.kt +++ b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/ui/LogoutActionDialog.kt @@ -30,8 +30,8 @@ import io.element.android.libraries.ui.strings.CommonStrings @Composable fun LogoutActionDialog( state: AsyncAction, - onConfirmClicked: () -> Unit, - onForceLogoutClicked: () -> Unit, + onConfirmClick: () -> Unit, + onForceLogoutClick: () -> Unit, onDismissDialog: () -> Unit, onSuccessLogout: (String?) -> Unit, ) { @@ -40,7 +40,7 @@ fun LogoutActionDialog( Unit AsyncAction.Confirming -> LogoutConfirmationDialog( - onSubmitClicked = onConfirmClicked, + onSubmitClick = onConfirmClick, onDismiss = onDismissDialog ) is AsyncAction.Loading -> @@ -50,7 +50,7 @@ fun LogoutActionDialog( title = stringResource(id = CommonStrings.dialog_title_error), content = stringResource(id = CommonStrings.error_unknown), retryText = stringResource(id = CommonStrings.action_signout_anyway), - onRetry = onForceLogoutClicked, + onRetry = onForceLogoutClick, onDismiss = onDismissDialog, ) is AsyncAction.Success -> { diff --git a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/ui/LogoutConfirmationDialog.kt b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/ui/LogoutConfirmationDialog.kt index caf04a2752..ea2a9656e0 100644 --- a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/ui/LogoutConfirmationDialog.kt +++ b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/ui/LogoutConfirmationDialog.kt @@ -24,14 +24,14 @@ import io.element.android.libraries.ui.strings.CommonStrings @Composable fun LogoutConfirmationDialog( - onSubmitClicked: () -> Unit, + onSubmitClick: () -> Unit, onDismiss: () -> Unit, ) { ConfirmationDialog( title = stringResource(id = CommonStrings.action_signout), content = stringResource(id = R.string.screen_signout_confirmation_dialog_content), submitText = stringResource(id = CommonStrings.action_signout), - onSubmitClicked = onSubmitClicked, + onSubmitClick = onSubmitClick, onDismiss = onDismiss, ) } diff --git a/features/logout/impl/src/main/res/values-ka/translations.xml b/features/logout/impl/src/main/res/values-ka/translations.xml new file mode 100644 index 0000000000..2226fd240f --- /dev/null +++ b/features/logout/impl/src/main/res/values-ka/translations.xml @@ -0,0 +1,8 @@ + + + "დარწმუნებული ხართ, რომ გსურთ გამოსვლა?" + "გამოსვლა" + "გამოსვლა" + "გასვლა…" + "გამოსვლა" + diff --git a/features/logout/impl/src/main/res/values-pt-rBR/translations.xml b/features/logout/impl/src/main/res/values-pt-rBR/translations.xml new file mode 100644 index 0000000000..b8a7161c21 --- /dev/null +++ b/features/logout/impl/src/main/res/values-pt-rBR/translations.xml @@ -0,0 +1,18 @@ + + + "Tens a certeza que queres terminar a sessão?" + "Terminar sessão" + "Terminar sessão" + "A terminar sessão…" + "Estás prestes a terminar a tua última sessão. Se continuares, perderás o acesso às tuas mensagens cifradas." + "Desativaste a cópia de segurança" + "As tuas chaves ainda estavam a ser guardadas quando ficaste desligado. Volta a ligar-te para que as tuas chaves possam ser guardadas antes de encerrares a sessão." + "As tuas chaves ainda estão a ser guardadas" + "Por favor, aguarda a conclusão desta operação antes de terminares a sessão." + "As tuas chaves ainda estão a ser guardadas" + "Terminar sessão" + "Estás prestes a terminar a tua última sessão. Se continuares, perderás o acesso às tuas mensagens cifradas." + "Recuperação não configurada" + "Estás prestes a terminar a tua última sessão. Se continuares, poderás perder o acesso às tuas mensagens cifradas." + "Guardaste a tua chave de recuperação?" + diff --git a/features/logout/impl/src/main/res/values-zh/translations.xml b/features/logout/impl/src/main/res/values-zh/translations.xml new file mode 100644 index 0000000000..be1d740130 --- /dev/null +++ b/features/logout/impl/src/main/res/values-zh/translations.xml @@ -0,0 +1,18 @@ + + + "确定要登出吗?" + "登出" + "登出" + "正在登出…" + "您即将登出最后一个会话。如果现在登出,你将无法访问加密的消息。" + "您已关闭备份" + "当你离线时,你的密钥仍在备份中。重新连接,以便在登出之前备份密钥。" + "您的密钥仍在备份中" + "请等待此操作完成后再登出。" + "您的密钥仍在备份中" + "登出" + "您即将登出最后一个会话。如果现在登出,你将无法访问加密的消息。" + "未设置恢复" + "您即将登出最后一个会话。如果现在登出,你将无法访问加密的消息。" + "您保存了恢复密钥吗?" + diff --git a/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/LogoutPresenterTest.kt b/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/LogoutPresenterTest.kt index 9531ea8886..70b346ba8d 100644 --- a/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/LogoutPresenterTest.kt +++ b/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/LogoutPresenterTest.kt @@ -144,7 +144,9 @@ class LogoutPresenterTest { @Test fun `present - logout with error then cancel`() = runTest { val matrixClient = FakeMatrixClient().apply { - givenLogoutError(A_THROWABLE) + logoutLambda = { _ -> + throw A_THROWABLE + } } val presenter = createLogoutPresenter( matrixClient, @@ -170,7 +172,13 @@ class LogoutPresenterTest { @Test fun `present - logout with error then force`() = runTest { val matrixClient = FakeMatrixClient().apply { - givenLogoutError(A_THROWABLE) + logoutLambda = { ignoreSdkError -> + if (!ignoreSdkError) { + throw A_THROWABLE + } else { + null + } + } } val presenter = createLogoutPresenter( matrixClient, diff --git a/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/LogoutViewTest.kt b/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/LogoutViewTest.kt index 8ebe6c175b..95b87886d7 100644 --- a/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/LogoutViewTest.kt +++ b/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/LogoutViewTest.kt @@ -73,7 +73,7 @@ class LogoutViewTest { aLogoutState( eventSink = eventsRecorder ), - onBackClicked = callback, + onBackClick = callback, ) rule.pressBack() } @@ -129,7 +129,7 @@ class LogoutViewTest { isLastDevice = true, eventSink = eventsRecorder ), - onChangeRecoveryKeyClicked = callback, + onChangeRecoveryKeyClick = callback, ) rule.clickOn(CommonStrings.common_settings) } @@ -138,15 +138,15 @@ class LogoutViewTest { private fun AndroidComposeTestRule.setLogoutView( state: LogoutState, - onChangeRecoveryKeyClicked: () -> Unit = EnsureNeverCalled(), - onBackClicked: () -> Unit = EnsureNeverCalled(), + onChangeRecoveryKeyClick: () -> Unit = EnsureNeverCalled(), + onBackClick: () -> Unit = EnsureNeverCalled(), onSuccessLogout: (logoutUrlResult: String?) -> Unit = EnsureNeverCalledWithParam() ) { setContent { LogoutView( state = state, - onChangeRecoveryKeyClicked = onChangeRecoveryKeyClicked, - onBackClicked = onBackClicked, + onChangeRecoveryKeyClick = onChangeRecoveryKeyClick, + onBackClick = onBackClick, onSuccessLogout = onSuccessLogout, ) } diff --git a/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/direct/DefaultDirectLogoutPresenterTest.kt b/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/direct/DefaultDirectLogoutPresenterTest.kt index bf3df93731..14d340570c 100644 --- a/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/direct/DefaultDirectLogoutPresenterTest.kt +++ b/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/direct/DefaultDirectLogoutPresenterTest.kt @@ -125,7 +125,9 @@ class DefaultDirectLogoutPresenterTest { @Test fun `present - logout with error then cancel`() = runTest { val matrixClient = FakeMatrixClient().apply { - givenLogoutError(A_THROWABLE) + logoutLambda = { _ -> + throw A_THROWABLE + } } val presenter = createDefaultDirectLogoutPresenter( matrixClient, @@ -151,7 +153,13 @@ class DefaultDirectLogoutPresenterTest { @Test fun `present - logout with error then force`() = runTest { val matrixClient = FakeMatrixClient().apply { - givenLogoutError(A_THROWABLE) + logoutLambda = { ignoreSdkError -> + if (!ignoreSdkError) { + throw A_THROWABLE + } else { + null + } + } } val presenter = createDefaultDirectLogoutPresenter( matrixClient, diff --git a/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/MessagesEntryPoint.kt b/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/MessagesEntryPoint.kt index 15d6e5fd95..6bd1045c12 100644 --- a/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/MessagesEntryPoint.kt +++ b/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/MessagesEntryPoint.kt @@ -39,9 +39,9 @@ interface MessagesEntryPoint : FeatureEntryPoint { ) interface Callback : Plugin { - fun onRoomDetailsClicked() - fun onUserDataClicked(userId: UserId) - fun onPermalinkClicked(data: PermalinkData) + fun onRoomDetailsClick() + fun onUserDataClick(userId: UserId) + fun onPermalinkClick(data: PermalinkData) fun onForwardedToSingleRoom(roomId: RoomId) } } diff --git a/features/messages/impl/build.gradle.kts b/features/messages/impl/build.gradle.kts index 2de95dff48..df0c4dff7a 100644 --- a/features/messages/impl/build.gradle.kts +++ b/features/messages/impl/build.gradle.kts @@ -99,7 +99,6 @@ dependencies { testImplementation(projects.libraries.mediaviewer.test) testImplementation(projects.libraries.testtags) testImplementation(libs.test.mockk) - testImplementation(libs.test.junitext) testImplementation(libs.test.robolectric) testImplementation(projects.features.poll.test) testImplementation(projects.features.poll.impl) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/ExpandableBottomSheetScaffold.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/ExpandableBottomSheetScaffold.kt index 28ef69226b..3d61d43063 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/ExpandableBottomSheetScaffold.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/ExpandableBottomSheetScaffold.kt @@ -58,6 +58,7 @@ import kotlin.math.roundToInt * @param modifier The modifier for the layout. * @param sheetContentKey The key for the sheet content. If the key changes, the sheet will be remeasured. */ +@Suppress("ContentTrailingLambda") @OptIn(ExperimentalMaterial3Api::class) @Composable internal fun ExpandableBottomSheetScaffold( diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt index ca35ac7fee..8ea3075af3 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt @@ -139,31 +139,31 @@ class MessagesFlowNode @AssistedInject constructor( return when (navTarget) { is NavTarget.Messages -> { val callback = object : MessagesNode.Callback { - override fun onRoomDetailsClicked() { - callback?.onRoomDetailsClicked() + override fun onRoomDetailsClick() { + callback?.onRoomDetailsClick() } - override fun onEventClicked(event: TimelineItem.Event): Boolean { - return processEventClicked(event) + override fun onEventClick(event: TimelineItem.Event): Boolean { + return processEventClick(event) } override fun onPreviewAttachments(attachments: ImmutableList) { backstack.push(NavTarget.AttachmentPreview(attachments.first())) } - override fun onUserDataClicked(userId: UserId) { - callback?.onUserDataClicked(userId) + override fun onUserDataClick(userId: UserId) { + callback?.onUserDataClick(userId) } - override fun onPermalinkClicked(data: PermalinkData) { - callback?.onPermalinkClicked(data) + override fun onPermalinkClick(data: PermalinkData) { + callback?.onPermalinkClick(data) } - override fun onShowEventDebugInfoClicked(eventId: EventId?, debugInfo: TimelineItemDebugInfo) { + override fun onShowEventDebugInfoClick(eventId: EventId?, debugInfo: TimelineItemDebugInfo) { backstack.push(NavTarget.EventDebugInfo(eventId, debugInfo)) } - override fun onForwardEventClicked(eventId: EventId) { + override fun onForwardEventClick(eventId: EventId) { backstack.push(NavTarget.ForwardEvent(eventId)) } @@ -171,19 +171,19 @@ class MessagesFlowNode @AssistedInject constructor( backstack.push(NavTarget.ReportMessage(eventId, senderId)) } - override fun onSendLocationClicked() { + override fun onSendLocationClick() { backstack.push(NavTarget.SendLocation) } - override fun onCreatePollClicked() { + override fun onCreatePollClick() { backstack.push(NavTarget.CreatePoll) } - override fun onEditPollClicked(eventId: EventId) { + override fun onEditPollClick(eventId: EventId) { backstack.push(NavTarget.EditPoll(eventId)) } - override fun onJoinCallClicked(roomId: RoomId) { + override fun onJoinCallClick(roomId: RoomId) { val inputs = CallType.RoomCall( sessionId = matrixClient.sessionId, roomId = roomId, @@ -250,7 +250,7 @@ class MessagesFlowNode @AssistedInject constructor( } } - private fun processEventClicked(event: TimelineItem.Event): Boolean { + private fun processEventClick(event: TimelineItem.Event): Boolean { return when (event.content) { is TimelineItemImageContent -> { val navTarget = NavTarget.MediaViewer( diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNavigator.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNavigator.kt index cda8b7ddcd..6f1b09a7ff 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNavigator.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNavigator.kt @@ -21,8 +21,8 @@ import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo interface MessagesNavigator { - fun onShowEventDebugInfoClicked(eventId: EventId?, debugInfo: TimelineItemDebugInfo) - fun onForwardEventClicked(eventId: EventId) - fun onReportContentClicked(eventId: EventId, senderId: UserId) - fun onEditPollClicked(eventId: EventId) + fun onShowEventDebugInfoClick(eventId: EventId?, debugInfo: TimelineItemDebugInfo) + fun onForwardEventClick(eventId: EventId) + fun onReportContentClick(eventId: EventId, senderId: UserId) + fun onEditPollClick(eventId: EventId) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt index f3c3462cd8..e542dfc563 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt @@ -47,6 +47,7 @@ import io.element.android.libraries.architecture.inputs import io.element.android.libraries.core.bool.orFalse import io.element.android.libraries.di.ApplicationContext import io.element.android.libraries.di.RoomScope +import io.element.android.libraries.matrix.api.analytics.toAnalyticsViewRoom import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.UserId @@ -57,7 +58,6 @@ import io.element.android.libraries.matrix.api.room.alias.matches import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo import io.element.android.libraries.mediaplayer.api.MediaPlayer import io.element.android.services.analytics.api.AnalyticsService -import io.element.android.services.analytics.api.extensions.toAnalyticsViewRoom import kotlinx.collections.immutable.ImmutableList @ContributesNode(RoomScope::class) @@ -82,18 +82,18 @@ class MessagesNode @AssistedInject constructor( private val inputs = inputs() interface Callback : Plugin { - fun onRoomDetailsClicked() - fun onEventClicked(event: TimelineItem.Event): Boolean + fun onRoomDetailsClick() + fun onEventClick(event: TimelineItem.Event): Boolean fun onPreviewAttachments(attachments: ImmutableList) - fun onUserDataClicked(userId: UserId) - fun onPermalinkClicked(data: PermalinkData) - fun onShowEventDebugInfoClicked(eventId: EventId?, debugInfo: TimelineItemDebugInfo) - fun onForwardEventClicked(eventId: EventId) + fun onUserDataClick(userId: UserId) + fun onPermalinkClick(data: PermalinkData) + fun onShowEventDebugInfoClick(eventId: EventId?, debugInfo: TimelineItemDebugInfo) + fun onForwardEventClick(eventId: EventId) fun onReportMessage(eventId: EventId, senderId: UserId) - fun onSendLocationClicked() - fun onCreatePollClicked() - fun onEditPollClicked(eventId: EventId) - fun onJoinCallClicked(roomId: RoomId) + fun onSendLocationClick() + fun onCreatePollClick() + fun onEditPollClick(eventId: EventId) + fun onJoinCallClick(roomId: RoomId) } override fun onBuilt() { @@ -109,23 +109,23 @@ class MessagesNode @AssistedInject constructor( ) } - private fun onRoomDetailsClicked() { - callback?.onRoomDetailsClicked() + private fun onRoomDetailsClick() { + callback?.onRoomDetailsClick() } - private fun onEventClicked(event: TimelineItem.Event): Boolean { - return callback?.onEventClicked(event).orFalse() + private fun onEventClick(event: TimelineItem.Event): Boolean { + return callback?.onEventClick(event).orFalse() } private fun onPreviewAttachments(attachments: ImmutableList) { callback?.onPreviewAttachments(attachments) } - private fun onUserDataClicked(userId: UserId) { - callback?.onUserDataClicked(userId) + private fun onUserDataClick(userId: UserId) { + callback?.onUserDataClick(userId) } - private fun onLinkClicked( + private fun onLinkClick( context: Context, url: String, eventSink: (TimelineEvents) -> Unit, @@ -134,10 +134,10 @@ class MessagesNode @AssistedInject constructor( is PermalinkData.UserLink -> { // Open the room member profile, it will fallback to // the user profile if the user is not in the room - callback?.onUserDataClicked(permalink.userId) + callback?.onUserDataClick(permalink.userId) } is PermalinkData.RoomLink -> { - handleRoomLinkClicked(permalink, eventSink) + handleRoomLinkClick(permalink, eventSink) } is PermalinkData.FallbackLink, is PermalinkData.RoomEmailInviteLink -> { @@ -146,7 +146,7 @@ class MessagesNode @AssistedInject constructor( } } - private fun handleRoomLinkClicked(roomLink: PermalinkData.RoomLink, eventSink: (TimelineEvents) -> Unit) { + private fun handleRoomLinkClick(roomLink: PermalinkData.RoomLink, eventSink: (TimelineEvents) -> Unit) { if (room.matches(roomLink.roomIdOrAlias)) { val eventId = roomLink.eventId if (eventId != null) { @@ -156,36 +156,36 @@ class MessagesNode @AssistedInject constructor( context.toast("Already viewing this room!") } } else { - callback?.onPermalinkClicked(roomLink) + callback?.onPermalinkClick(roomLink) } } - override fun onShowEventDebugInfoClicked(eventId: EventId?, debugInfo: TimelineItemDebugInfo) { - callback?.onShowEventDebugInfoClicked(eventId, debugInfo) + override fun onShowEventDebugInfoClick(eventId: EventId?, debugInfo: TimelineItemDebugInfo) { + callback?.onShowEventDebugInfoClick(eventId, debugInfo) } - override fun onForwardEventClicked(eventId: EventId) { - callback?.onForwardEventClicked(eventId) + override fun onForwardEventClick(eventId: EventId) { + callback?.onForwardEventClick(eventId) } - override fun onReportContentClicked(eventId: EventId, senderId: UserId) { + override fun onReportContentClick(eventId: EventId, senderId: UserId) { callback?.onReportMessage(eventId, senderId) } - override fun onEditPollClicked(eventId: EventId) { - callback?.onEditPollClicked(eventId) + override fun onEditPollClick(eventId: EventId) { + callback?.onEditPollClick(eventId) } - private fun onSendLocationClicked() { - callback?.onSendLocationClicked() + private fun onSendLocationClick() { + callback?.onSendLocationClick() } - private fun onCreatePollClicked() { - callback?.onCreatePollClicked() + private fun onCreatePollClick() { + callback?.onCreatePollClick() } - private fun onJoinCallClicked() { - callback?.onJoinCallClicked(room.roomId) + private fun onJoinCallClick() { + callback?.onJoinCallClick(room.roomId) } @Composable @@ -197,15 +197,15 @@ class MessagesNode @AssistedInject constructor( val state = presenter.present() MessagesView( state = state, - onBackPressed = this::navigateUp, - onRoomDetailsClicked = this::onRoomDetailsClicked, - onEventClicked = this::onEventClicked, + onBackClick = this::navigateUp, + onRoomDetailsClick = this::onRoomDetailsClick, + onEventClick = this::onEventClick, onPreviewAttachments = this::onPreviewAttachments, - onUserDataClicked = this::onUserDataClicked, - onLinkClicked = { onLinkClicked(context, it, state.timelineState.eventSink) }, - onSendLocationClicked = this::onSendLocationClicked, - onCreatePollClicked = this::onCreatePollClicked, - onJoinCallClicked = this::onJoinCallClicked, + onUserDataClick = this::onUserDataClick, + onLinkClick = { onLinkClick(context, it, state.timelineState.eventSink) }, + onSendLocationClick = this::onSendLocationClick, + onCreatePollClick = this::onCreatePollClick, + onJoinCallClick = this::onJoinCallClick, modifier = modifier, ) 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 e5127ea865..69ae082620 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 @@ -31,6 +31,7 @@ import androidx.compose.runtime.setValue import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject +import io.element.android.appconfig.MessageComposerConfig 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.ActionListPresenter @@ -66,7 +67,6 @@ import io.element.android.features.messages.impl.utils.messagesummary.MessageSum import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerPresenter import io.element.android.features.networkmonitor.api.NetworkMonitor import io.element.android.features.networkmonitor.api.NetworkStatus -import io.element.android.features.preferences.api.store.AppPreferencesStore import io.element.android.libraries.androidutils.clipboard.ClipboardHelper import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.Presenter @@ -113,7 +113,6 @@ class MessagesPresenter @AssistedInject constructor( private val messageSummaryFormatter: MessageSummaryFormatter, private val dispatchers: CoroutineDispatchers, private val clipboardHelper: ClipboardHelper, - private val appPreferencesStore: AppPreferencesStore, private val featureFlagsService: FeatureFlagService, private val htmlConverterProvider: HtmlConverterProvider, @Assisted private val navigator: MessagesNavigator, @@ -171,17 +170,15 @@ class MessagesPresenter @AssistedInject constructor( val inviteProgress = remember { mutableStateOf>(AsyncData.Uninitialized) } var showReinvitePrompt by remember { mutableStateOf(false) } - LaunchedEffect(hasDismissedInviteDialog, composerState.hasFocus, syncUpdateFlow.value) { + LaunchedEffect(hasDismissedInviteDialog, composerState.textEditorState.hasFocus(), syncUpdateFlow.value) { withContext(dispatchers.io) { - showReinvitePrompt = !hasDismissedInviteDialog && composerState.hasFocus && room.isDirect && room.activeMemberCount == 1L + showReinvitePrompt = !hasDismissedInviteDialog && composerState.textEditorState.hasFocus() && room.isDirect && room.activeMemberCount == 1L } } val networkConnectionStatus by networkMonitor.connectivity.collectAsState() val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState() - val enableTextFormatting by appPreferencesStore.isRichTextEditorEnabledFlow().collectAsState(initial = true) - var enableVoiceMessages by remember { mutableStateOf(false) } LaunchedEffect(featureFlagsService) { enableVoiceMessages = featureFlagsService.isFeatureEnabled(FeatureFlags.VoiceMessages) @@ -194,7 +191,7 @@ class MessagesPresenter @AssistedInject constructor( action = event.action, targetEvent = event.event, composerState = composerState, - enableTextFormatting = enableTextFormatting, + enableTextFormatting = composerState.showTextFormatting, timelineState = timelineState, ) } @@ -239,7 +236,7 @@ class MessagesPresenter @AssistedInject constructor( snackbarMessage = snackbarMessage, showReinvitePrompt = showReinvitePrompt, inviteProgress = inviteProgress.value, - enableTextFormatting = enableTextFormatting, + enableTextFormatting = MessageComposerConfig.ENABLE_RICH_TEXT_EDITING, enableVoiceMessages = enableVoiceMessages, appName = buildMeta.applicationName, callState = callState, @@ -327,7 +324,7 @@ class MessagesPresenter @AssistedInject constructor( when (targetEvent.content) { is TimelineItemPollContent -> { if (targetEvent.eventId == null) return - navigator.onEditPollClicked(targetEvent.eventId) + navigator.onEditPollClick(targetEvent.eventId) } else -> { val composerMode = MessageComposerMode.Edit( @@ -410,24 +407,24 @@ class MessagesPresenter @AssistedInject constructor( } private fun handleShowDebugInfoAction(event: TimelineItem.Event) { - navigator.onShowEventDebugInfoClicked(event.eventId, event.debugInfo) + navigator.onShowEventDebugInfoClick(event.eventId, event.debugInfo) } private fun handleForwardAction(event: TimelineItem.Event) { if (event.eventId == null) return - navigator.onForwardEventClicked(event.eventId) + navigator.onForwardEventClick(event.eventId) } private fun handleReportAction(event: TimelineItem.Event) { if (event.eventId == null) return - navigator.onReportContentClicked(event.eventId, event.senderId) + navigator.onReportContentClick(event.eventId, event.senderId) } private fun handleEndPollAction( event: TimelineItem.Event, timelineState: TimelineState, ) { - event.eventId?.let { timelineState.eventSink(TimelineEvents.PollEndClicked(it)) } + event.eventId?.let { timelineState.eventSink(TimelineEvents.EndPoll(it)) } } private suspend fun handleCopyLink(event: TimelineItem.Event) { 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 acd7e86d58..fd00eba04b 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 @@ -45,6 +45,7 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.textcomposer.aRichTextEditorState import io.element.android.libraries.textcomposer.model.MessageComposerMode +import io.element.android.libraries.textcomposer.model.TextEditorState import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentSetOf @@ -99,9 +100,9 @@ fun aMessagesState( userHasPermissionToRedactOther: Boolean = false, userHasPermissionToSendReaction: Boolean = true, composerState: MessageComposerState = aMessageComposerState( - richTextEditorState = aRichTextEditorState(initialText = "Hello", initialFocus = true), - isFullScreen = false, - mode = MessageComposerMode.Normal, + textEditorState = TextEditorState.Rich(aRichTextEditorState(initialText = "Hello", initialFocus = true)), + isFullScreen = false, + mode = MessageComposerMode.Normal, ), voiceMessageComposerState: VoiceMessageComposerState = aVoiceMessageComposerState(), timelineState: TimelineState = aTimelineState( 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 597fd4b8c9..13f6ccf4a2 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 @@ -115,15 +115,15 @@ import androidx.compose.material3.Button as Material3Button @Composable fun MessagesView( state: MessagesState, - onBackPressed: () -> Unit, - onRoomDetailsClicked: () -> Unit, - onEventClicked: (event: TimelineItem.Event) -> Boolean, - onUserDataClicked: (UserId) -> Unit, - onLinkClicked: (String) -> Unit, + onBackClick: () -> Unit, + onRoomDetailsClick: () -> Unit, + onEventClick: (event: TimelineItem.Event) -> Boolean, + onUserDataClick: (UserId) -> Unit, + onLinkClick: (String) -> Unit, onPreviewAttachments: (ImmutableList) -> Unit, - onSendLocationClicked: () -> Unit, - onCreatePollClicked: () -> Unit, - onJoinCallClicked: () -> Unit, + onSendLocationClick: () -> Unit, + onCreatePollClick: () -> Unit, + onJoinCallClick: () -> Unit, modifier: Modifier = Modifier, forceJumpToBottomVisibility: Boolean = false ) { @@ -144,15 +144,15 @@ fun MessagesView( // This is needed because the composer is inside an AndroidView that can't be affected by the FocusManager in Compose val localView = LocalView.current - fun onMessageClicked(event: TimelineItem.Event) { - Timber.v("OnMessageClicked= ${event.id}") - val hideKeyboard = onEventClicked(event) + fun onMessageClick(event: TimelineItem.Event) { + Timber.v("onMessageClick= ${event.id}") + val hideKeyboard = onEventClick(event) if (hideKeyboard) { localView.hideKeyboard() } } - fun onMessageLongClicked(event: TimelineItem.Event) { + fun onMessageLongClick(event: TimelineItem.Event) { Timber.v("OnMessageLongClicked= ${event.id}") localView.hideKeyboard() state.actionListState.eventSink( @@ -170,17 +170,17 @@ fun MessagesView( state.eventSink(MessagesEvents.HandleAction(action, event)) } - fun onEmojiReactionClicked(emoji: String, event: TimelineItem.Event) { + fun onEmojiReactionClick(emoji: String, event: TimelineItem.Event) { if (event.eventId == null) return state.eventSink(MessagesEvents.ToggleReaction(emoji, event.eventId)) } - fun onEmojiReactionLongClicked(emoji: String, event: TimelineItem.Event) { + fun onEmojiReactionLongClick(emoji: String, event: TimelineItem.Event) { if (event.eventId == null) return state.reactionSummaryState.eventSink(ReactionSummaryEvents.ShowReactionSummary(event.eventId, event.reactionsState.reactions, emoji)) } - fun onMoreReactionsClicked(event: TimelineItem.Event) { + fun onMoreReactionsClick(event: TimelineItem.Event) { state.customReactionState.eventSink(CustomReactionEvents.ShowCustomReactionSheet(event)) } @@ -194,14 +194,14 @@ fun MessagesView( roomName = state.roomName.dataOrNull(), roomAvatar = state.roomAvatar.dataOrNull(), callState = state.callState, - onBackPressed = { + onBackClick = { // Since the textfield is now based on an Android view, this is no longer done automatically. // We need to hide the keyboard when navigating out of this screen. localView.hideKeyboard() - onBackPressed() + onBackClick() }, - onRoomDetailsClicked = onRoomDetailsClicked, - onJoinCallClicked = onJoinCallClicked, + onRoomDetailsClick = onRoomDetailsClick, + onJoinCallClick = onJoinCallClick, ) } }, @@ -211,23 +211,23 @@ fun MessagesView( modifier = Modifier .padding(padding) .consumeWindowInsets(padding), - onMessageClicked = ::onMessageClicked, - onMessageLongClicked = ::onMessageLongClicked, - onUserDataClicked = onUserDataClicked, - onLinkClicked = onLinkClicked, - onTimestampClicked = { event -> + onMessageClick = ::onMessageClick, + onMessageLongClick = ::onMessageLongClick, + onUserDataClick = onUserDataClick, + onLinkClick = onLinkClick, + onTimestampClick = { event -> if (event.localSendState is LocalEventSendState.SendingFailed) { state.retrySendMenuState.eventSink(RetrySendMenuEvents.EventSelected(event)) } }, - onReactionClicked = ::onEmojiReactionClicked, - onReactionLongClicked = ::onEmojiReactionLongClicked, - onMoreReactionsClicked = ::onMoreReactionsClicked, + onReactionClick = ::onEmojiReactionClick, + onReactionLongClick = ::onEmojiReactionLongClick, + onMoreReactionsClick = ::onMoreReactionsClick, onReadReceiptClick = { event -> state.readReceiptBottomSheetState.eventSink(ReadReceiptBottomSheetEvents.EventSelected(event)) }, - onSendLocationClicked = onSendLocationClicked, - onCreatePollClicked = onCreatePollClicked, + onSendLocationClick = onSendLocationClick, + onCreatePollClick = onCreatePollClick, onSwipeToReply = { targetEvent -> state.eventSink(MessagesEvents.HandleAction(TimelineItemAction.Reply, targetEvent)) }, @@ -244,17 +244,17 @@ fun MessagesView( ActionListView( state = state.actionListState, - onActionSelected = ::onActionSelected, - onCustomReactionClicked = { event -> + onSelectAction = ::onActionSelected, + onCustomReactionClick = { event -> if (event.eventId == null) return@ActionListView state.customReactionState.eventSink(CustomReactionEvents.ShowCustomReactionSheet(event)) }, - onEmojiReactionClicked = ::onEmojiReactionClicked, + onEmojiReactionClick = ::onEmojiReactionClick, ) CustomReactionBottomSheet( state = state.customReactionState, - onEmojiSelected = { eventId, emoji -> + onSelectEmoji = { eventId, emoji -> state.eventSink(MessagesEvents.ToggleReaction(emoji.unicode, eventId)) } ) @@ -263,7 +263,7 @@ fun MessagesView( RetrySendMessageMenu(state = state.retrySendMenuState) ReadReceiptBottomSheet( state = state.readReceiptBottomSheetState, - onUserDataClicked = onUserDataClicked, + onUserDataClick = onUserDataClick, ) ReinviteDialog(state = state) } @@ -276,7 +276,7 @@ private fun ReinviteDialog(state: MessagesState) { content = stringResource(id = R.string.screen_room_invite_again_alert_message), cancelText = stringResource(id = CommonStrings.action_cancel), submitText = stringResource(id = CommonStrings.action_invite), - onSubmitClicked = { state.eventSink(MessagesEvents.InviteDialogDismissed(InviteDialogAction.Invite)) }, + onSubmitClick = { state.eventSink(MessagesEvents.InviteDialogDismissed(InviteDialogAction.Invite)) }, onDismiss = { state.eventSink(MessagesEvents.InviteDialogDismissed(InviteDialogAction.Cancel)) } ) } @@ -313,17 +313,17 @@ private fun AttachmentStateView( @Composable private fun MessagesViewContent( state: MessagesState, - onMessageClicked: (TimelineItem.Event) -> Unit, - onUserDataClicked: (UserId) -> Unit, - onLinkClicked: (String) -> Unit, - onReactionClicked: (key: String, TimelineItem.Event) -> Unit, - onReactionLongClicked: (key: String, TimelineItem.Event) -> Unit, - onMoreReactionsClicked: (TimelineItem.Event) -> Unit, + onMessageClick: (TimelineItem.Event) -> Unit, + onUserDataClick: (UserId) -> Unit, + onLinkClick: (String) -> Unit, + onReactionClick: (key: String, TimelineItem.Event) -> Unit, + onReactionLongClick: (key: String, TimelineItem.Event) -> Unit, + onMoreReactionsClick: (TimelineItem.Event) -> Unit, onReadReceiptClick: (TimelineItem.Event) -> Unit, - onMessageLongClicked: (TimelineItem.Event) -> Unit, - onTimestampClicked: (TimelineItem.Event) -> Unit, - onSendLocationClicked: () -> Unit, - onCreatePollClicked: () -> Unit, + onMessageLongClick: (TimelineItem.Event) -> Unit, + onTimestampClick: (TimelineItem.Event) -> Unit, + onSendLocationClick: () -> Unit, + onCreatePollClick: () -> Unit, forceJumpToBottomVisibility: Boolean, modifier: Modifier = Modifier, onSwipeToReply: (TimelineItem.Event) -> Unit, @@ -336,8 +336,8 @@ private fun MessagesViewContent( ) { AttachmentsBottomSheet( state = state.composerState, - onSendLocationClicked = onSendLocationClicked, - onCreatePollClicked = onCreatePollClicked, + onSendLocationClick = onSendLocationClick, + onCreatePollClick = onCreatePollClick, enableTextFormatting = state.enableTextFormatting, ) @@ -362,7 +362,7 @@ private fun MessagesViewContent( // Any state change that should trigger a height size should be added to the list of remembered values here. val sheetResizeContentKey = remember { mutableIntStateOf(0) } LaunchedEffect( - state.composerState.richTextEditorState.lineCount, + state.composerState.textEditorState.lineCount, state.composerState.showTextFormatting, ) { sheetResizeContentKey.intValue = Random.nextInt() @@ -384,15 +384,15 @@ private fun MessagesViewContent( TimelineView( state = state.timelineState, typingNotificationState = state.typingNotificationState, - onUserDataClicked = onUserDataClicked, - onLinkClicked = onLinkClicked, - onMessageClicked = onMessageClicked, - onMessageLongClicked = onMessageLongClicked, - onTimestampClicked = onTimestampClicked, + onUserDataClick = onUserDataClick, + onLinkClick = onLinkClick, + onMessageClick = onMessageClick, + onMessageLongClick = onMessageLongClick, + onTimestampClick = onTimestampClick, onSwipeToReply = onSwipeToReply, - onReactionClicked = onReactionClicked, - onReactionLongClicked = onReactionLongClicked, - onMoreReactionsClicked = onMoreReactionsClicked, + onReactionClick = onReactionClick, + onReactionLongClick = onReactionLongClick, + onMoreReactionsClick = onMoreReactionsClick, onReadReceiptClick = onReadReceiptClick, modifier = Modifier.padding(paddingValues), forceJumpToBottomVisibility = forceJumpToBottomVisibility, @@ -431,7 +431,7 @@ private fun MessagesViewComposerBottomSheetContents( roomName = state.roomName.dataOrNull(), roomAvatarData = state.roomAvatar.dataOrNull(), memberSuggestions = state.composerState.memberSuggestions, - onSuggestionSelected = { + onSelectSuggestion = { state.composerState.eventSink(MessageComposerEvents.InsertMention(it)) } ) @@ -439,7 +439,6 @@ private fun MessagesViewComposerBottomSheetContents( state = state.composerState, voiceMessageState = state.voiceMessageComposerState, subcomposing = subcomposing, - enableTextFormatting = state.enableTextFormatting, enableVoiceMessages = state.enableVoiceMessages, modifier = Modifier.fillMaxWidth(), ) @@ -455,16 +454,16 @@ private fun MessagesViewTopBar( roomName: String?, roomAvatar: AvatarData?, callState: RoomCallState, - onRoomDetailsClicked: () -> Unit, - onJoinCallClicked: () -> Unit, - onBackPressed: () -> Unit, + onRoomDetailsClick: () -> Unit, + onJoinCallClick: () -> Unit, + onBackClick: () -> Unit, ) { TopAppBar( navigationIcon = { - BackButton(onClick = onBackPressed) + BackButton(onClick = onBackClick) }, title = { - val titleModifier = Modifier.clickable { onRoomDetailsClicked() } + val titleModifier = Modifier.clickable { onRoomDetailsClick() } if (roomName != null && roomAvatar != null) { RoomAvatarAndNameRow( roomName = roomName, @@ -480,9 +479,9 @@ private fun MessagesViewTopBar( }, actions = { if (callState == RoomCallState.ONGOING) { - JoinCallMenuItem(onJoinCallClicked = onJoinCallClicked) + JoinCallMenuItem(onJoinCallClick = onJoinCallClick) } else { - IconButton(onClick = onJoinCallClicked, enabled = callState != RoomCallState.DISABLED) { + IconButton(onClick = onJoinCallClick, enabled = callState != RoomCallState.DISABLED) { Icon( imageVector = CompoundIcons.VideoCallSolid(), contentDescription = stringResource(CommonStrings.a11y_start_call), @@ -497,10 +496,10 @@ private fun MessagesViewTopBar( @Composable private fun JoinCallMenuItem( - onJoinCallClicked: () -> Unit, + onJoinCallClick: () -> Unit, ) { Material3Button( - onClick = onJoinCallClicked, + onClick = onJoinCallClick, colors = ButtonDefaults.buttonColors( contentColor = ElementTheme.colors.bgCanvasDefault, containerColor = ElementTheme.colors.iconAccentTertiary @@ -568,15 +567,15 @@ private fun CantSendMessageBanner() { internal fun MessagesViewPreview(@PreviewParameter(MessagesStateProvider::class) state: MessagesState) = ElementPreview { MessagesView( state = state, - onBackPressed = {}, - onRoomDetailsClicked = {}, - onEventClicked = { false }, + onBackClick = {}, + onRoomDetailsClick = {}, + onEventClick = { false }, onPreviewAttachments = {}, - onUserDataClicked = {}, - onLinkClicked = {}, - onSendLocationClicked = {}, - onCreatePollClicked = {}, - onJoinCallClicked = {}, + onUserDataClick = {}, + onLinkClick = {}, + onSendLocationClick = {}, + onCreatePollClick = {}, + onJoinCallClick = {}, forceJumpToBottomVisibility = true, ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt index 2fe6a0cd2b..cf366c1fba 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt @@ -94,38 +94,38 @@ import kotlinx.collections.immutable.ImmutableList @Composable fun ActionListView( state: ActionListState, - onActionSelected: (action: TimelineItemAction, TimelineItem.Event) -> Unit, - onEmojiReactionClicked: (String, TimelineItem.Event) -> Unit, - onCustomReactionClicked: (TimelineItem.Event) -> Unit, + onSelectAction: (action: TimelineItemAction, TimelineItem.Event) -> Unit, + onEmojiReactionClick: (String, TimelineItem.Event) -> Unit, + onCustomReactionClick: (TimelineItem.Event) -> Unit, modifier: Modifier = Modifier, ) { val sheetState = rememberModalBottomSheetState() val coroutineScope = rememberCoroutineScope() val targetItem = (state.target as? ActionListState.Target.Success)?.event - fun onItemActionClicked( + fun onItemActionClick( itemAction: TimelineItemAction ) { if (targetItem == null) return sheetState.hide(coroutineScope) { state.eventSink(ActionListEvents.Clear) - onActionSelected(itemAction, targetItem) + onSelectAction(itemAction, targetItem) } } - fun onEmojiReactionClicked(emoji: String) { + fun onEmojiReactionClick(emoji: String) { if (targetItem == null) return sheetState.hide(coroutineScope) { state.eventSink(ActionListEvents.Clear) - onEmojiReactionClicked(emoji, targetItem) + onEmojiReactionClick(emoji, targetItem) } } - fun onCustomReactionClicked() { + fun onCustomReactionClick() { if (targetItem == null) return sheetState.hide(coroutineScope) { state.eventSink(ActionListEvents.Clear) - onCustomReactionClicked(targetItem) + onCustomReactionClick(targetItem) } } @@ -141,9 +141,9 @@ fun ActionListView( ) { SheetContent( state = state, - onActionClicked = ::onItemActionClicked, - onEmojiReactionClicked = ::onEmojiReactionClicked, - onCustomReactionClicked = ::onCustomReactionClicked, + onActionClick = ::onItemActionClick, + onEmojiReactionClick = ::onEmojiReactionClick, + onCustomReactionClick = ::onCustomReactionClick, modifier = Modifier .navigationBarsPadding() .imePadding() @@ -155,9 +155,9 @@ fun ActionListView( @Composable private fun SheetContent( state: ActionListState, - onActionClicked: (TimelineItemAction) -> Unit, - onEmojiReactionClicked: (String) -> Unit, - onCustomReactionClicked: () -> Unit, + onActionClick: (TimelineItemAction) -> Unit, + onEmojiReactionClick: (String) -> Unit, + onCustomReactionClick: () -> Unit, modifier: Modifier = Modifier, ) { when (val target = state.target) { @@ -188,8 +188,8 @@ private fun SheetContent( item { EmojiReactionsRow( highlightedEmojis = target.event.reactionsState.highlightedKeys, - onEmojiReactionClicked = onEmojiReactionClicked, - onCustomReactionClicked = onCustomReactionClicked, + onEmojiReactionClick = onEmojiReactionClick, + onCustomReactionClick = onCustomReactionClick, modifier = Modifier.fillMaxWidth(), ) HorizontalDivider() @@ -200,7 +200,7 @@ private fun SheetContent( ) { action -> ListItem( modifier = Modifier.clickable { - onActionClicked(action) + onActionClick(action) }, headlineContent = { Text(text = stringResource(id = action.titleRes)) @@ -292,8 +292,8 @@ private val emojiRippleRadius = 24.dp @Composable private fun EmojiReactionsRow( highlightedEmojis: ImmutableList, - onEmojiReactionClicked: (String) -> Unit, - onCustomReactionClicked: () -> Unit, + onEmojiReactionClick: (String) -> Unit, + onCustomReactionClick: () -> Unit, modifier: Modifier = Modifier, ) { Row( @@ -310,7 +310,7 @@ private fun EmojiReactionsRow( ) for (emoji in defaultEmojis) { val isHighlighted = highlightedEmojis.contains(emoji) - EmojiButton(emoji, isHighlighted, onEmojiReactionClicked) + EmojiButton(emoji, isHighlighted, onEmojiReactionClick) } Box( modifier = Modifier @@ -325,7 +325,7 @@ private fun EmojiReactionsRow( .size(24.dp) .clickable( enabled = true, - onClick = onCustomReactionClicked, + onClick = onCustomReactionClick, indication = rememberRipple(bounded = false, radius = emojiRippleRadius), interactionSource = remember { MutableInteractionSource() } ) @@ -338,7 +338,7 @@ private fun EmojiReactionsRow( private fun EmojiButton( emoji: String, isHighlighted: Boolean, - onClicked: (String) -> Unit, + onClick: (String) -> Unit, ) { val backgroundColor = if (isHighlighted) { ElementTheme.colors.bgActionPrimaryRest @@ -365,7 +365,7 @@ private fun EmojiButton( modifier = Modifier .clickable( enabled = true, - onClick = { onClicked(emoji) }, + onClick = { onClick(emoji) }, indication = rememberRipple(bounded = false, radius = emojiRippleRadius), interactionSource = remember { MutableInteractionSource() } ) @@ -380,8 +380,8 @@ internal fun SheetContentPreview( ) = ElementPreview { SheetContent( state = state, - onActionClicked = {}, - onEmojiReactionClicked = {}, - onCustomReactionClicked = {}, + onActionClick = {}, + onEmojiReactionClick = {}, + onCustomReactionClick = {}, ) } 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 e440a8a9cb..dc13758bd1 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 @@ -73,22 +73,22 @@ fun AttachmentsPreviewView( Scaffold(modifier) { AttachmentPreviewContent( attachment = state.attachment, - onSendClicked = ::postSendAttachment, + onSendClick = ::postSendAttachment, onDismiss = onDismiss ) } AttachmentSendStateView( sendActionState = state.sendActionState, - onDismissClicked = ::postClearSendState, - onRetryClicked = ::postSendAttachment + onDismissClick = ::postClearSendState, + onRetryClick = ::postSendAttachment ) } @Composable private fun AttachmentSendStateView( sendActionState: SendActionState, - onDismissClicked: () -> Unit, - onRetryClicked: () -> Unit + onDismissClick: () -> Unit, + onRetryClick: () -> Unit ) { when (sendActionState) { is SendActionState.Sending -> { @@ -99,14 +99,14 @@ private fun AttachmentSendStateView( }, text = stringResource(id = CommonStrings.common_sending), isCancellable = true, - onDismissRequest = onDismissClicked, + onDismissRequest = onDismissClick, ) } is SendActionState.Failure -> { RetryDialog( content = stringResource(sendAttachmentError(sendActionState.error)), - onDismiss = onDismissClicked, - onRetry = onRetryClicked + onDismiss = onDismissClick, + onRetry = onRetryClick ) } else -> Unit @@ -116,7 +116,7 @@ private fun AttachmentSendStateView( @Composable private fun AttachmentPreviewContent( attachment: Attachment, - onSendClicked: () -> Unit, + onSendClick: () -> Unit, onDismiss: () -> Unit, ) { Box( @@ -146,8 +146,8 @@ private fun AttachmentPreviewContent( } } AttachmentsPreviewBottomActions( - onCancelClicked = onDismiss, - onSendClicked = onSendClicked, + onCancelClick = onDismiss, + onSendClick = onSendClick, modifier = Modifier .fillMaxWidth() .background(Color.Black.copy(alpha = 0.7f)) @@ -159,13 +159,13 @@ private fun AttachmentPreviewContent( @Composable private fun AttachmentsPreviewBottomActions( - onCancelClicked: () -> Unit, - onSendClicked: () -> Unit, + onCancelClick: () -> Unit, + onSendClick: () -> Unit, modifier: Modifier = Modifier ) { ButtonRowMolecule(modifier = modifier) { - TextButton(stringResource(id = CommonStrings.action_cancel), onClick = onCancelClicked) - TextButton(stringResource(id = CommonStrings.action_send), onClick = onSendClicked) + TextButton(stringResource(id = CommonStrings.action_cancel), onClick = onCancelClick) + TextButton(stringResource(id = CommonStrings.action_send), onClick = onSendClick) } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesNode.kt index 4f58cfa274..04d562abde 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesNode.kt @@ -94,12 +94,12 @@ class ForwardMessagesNode @AssistedInject constructor( val state = presenter.present() ForwardMessagesView( state = state, - onForwardingSucceeded = ::onSucceeded, + onForwardSuccess = ::onForwardSuccess, ) } } - private fun onSucceeded(roomIds: ImmutableList) { + private fun onForwardSuccess(roomIds: ImmutableList) { navigateUp() if (roomIds.size == 1) { val targetRoomId = roomIds.first() diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesView.kt index 089046544b..030b137c04 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/forward/ForwardMessagesView.kt @@ -30,11 +30,11 @@ import kotlinx.collections.immutable.ImmutableList @Composable fun ForwardMessagesView( state: ForwardMessagesState, - onForwardingSucceeded: (ImmutableList) -> Unit, + onForwardSuccess: (ImmutableList) -> Unit, modifier: Modifier = Modifier, ) { if (state.forwardingSucceeded != null) { - onForwardingSucceeded(state.forwardingSucceeded) + onForwardSuccess(state.forwardingSucceeded) return } @@ -64,6 +64,6 @@ private fun ForwardingErrorDialog(onDismiss: () -> Unit, modifier: Modifier = Mo internal fun ForwardMessagesViewPreview(@PreviewParameter(ForwardMessagesStateProvider::class) state: ForwardMessagesState) = ElementPreview { ForwardMessagesView( state = state, - onForwardingSucceeded = {} + onForwardSuccess = {} ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mentions/MentionSuggestionsPickerView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mentions/MentionSuggestionsPickerView.kt index cf48ea1478..9af2407657 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mentions/MentionSuggestionsPickerView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mentions/MentionSuggestionsPickerView.kt @@ -43,6 +43,7 @@ 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.RoomMember import io.element.android.libraries.matrix.api.room.RoomMembershipState +import io.element.android.libraries.textcomposer.mentions.ResolvedMentionSuggestion import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -51,8 +52,8 @@ fun MentionSuggestionsPickerView( roomId: RoomId, roomName: String?, roomAvatarData: AvatarData?, - memberSuggestions: ImmutableList, - onSuggestionSelected: (MentionSuggestion) -> Unit, + memberSuggestions: ImmutableList, + onSelectSuggestion: (ResolvedMentionSuggestion) -> Unit, modifier: Modifier = Modifier, ) { LazyColumn( @@ -62,8 +63,8 @@ fun MentionSuggestionsPickerView( memberSuggestions, key = { suggestion -> when (suggestion) { - is MentionSuggestion.Room -> "@room" - is MentionSuggestion.Member -> suggestion.roomMember.userId.value + is ResolvedMentionSuggestion.AtRoom -> "@room" + is ResolvedMentionSuggestion.Member -> suggestion.roomMember.userId.value } } ) { @@ -73,7 +74,7 @@ fun MentionSuggestionsPickerView( roomId = roomId.value, roomName = roomName, roomAvatar = roomAvatarData, - onSuggestionSelected = onSuggestionSelected, + onSelectSuggestion = onSelectSuggestion, modifier = Modifier.fillMaxWidth() ) HorizontalDivider(modifier = Modifier.fillMaxWidth()) @@ -84,18 +85,18 @@ fun MentionSuggestionsPickerView( @Composable private fun RoomMemberSuggestionItemView( - memberSuggestion: MentionSuggestion, + memberSuggestion: ResolvedMentionSuggestion, roomId: String, roomName: String?, roomAvatar: AvatarData?, - onSuggestionSelected: (MentionSuggestion) -> Unit, + onSelectSuggestion: (ResolvedMentionSuggestion) -> Unit, modifier: Modifier = Modifier, ) { - Row(modifier = modifier.clickable { onSuggestionSelected(memberSuggestion) }, horizontalArrangement = Arrangement.spacedBy(16.dp)) { + Row(modifier = modifier.clickable { onSelectSuggestion(memberSuggestion) }, horizontalArrangement = Arrangement.spacedBy(16.dp)) { val avatarSize = AvatarSize.TimelineRoom val avatarData = when (memberSuggestion) { - is MentionSuggestion.Room -> roomAvatar?.copy(size = avatarSize) ?: AvatarData(roomId, roomName, null, avatarSize) - is MentionSuggestion.Member -> AvatarData( + is ResolvedMentionSuggestion.AtRoom -> roomAvatar?.copy(size = avatarSize) ?: AvatarData(roomId, roomName, null, avatarSize) + is ResolvedMentionSuggestion.Member -> AvatarData( memberSuggestion.roomMember.userId.value, memberSuggestion.roomMember.displayName, memberSuggestion.roomMember.avatarUrl, @@ -103,13 +104,13 @@ private fun RoomMemberSuggestionItemView( ) } val title = when (memberSuggestion) { - is MentionSuggestion.Room -> stringResource(R.string.screen_room_mentions_at_room_title) - is MentionSuggestion.Member -> memberSuggestion.roomMember.displayName + is ResolvedMentionSuggestion.AtRoom -> stringResource(R.string.screen_room_mentions_at_room_title) + is ResolvedMentionSuggestion.Member -> memberSuggestion.roomMember.displayName } val subtitle = when (memberSuggestion) { - is MentionSuggestion.Room -> "@room" - is MentionSuggestion.Member -> memberSuggestion.roomMember.userId.value + is ResolvedMentionSuggestion.AtRoom -> "@room" + is ResolvedMentionSuggestion.Member -> memberSuggestion.roomMember.userId.value } Avatar(avatarData = avatarData, modifier = Modifier.padding(start = 16.dp, top = 12.dp, bottom = 12.dp)) @@ -159,11 +160,11 @@ internal fun MentionSuggestionsPickerViewPreview() { roomName = "Room", roomAvatarData = null, memberSuggestions = persistentListOf( - MentionSuggestion.Room, - MentionSuggestion.Member(roomMember), - MentionSuggestion.Member(roomMember.copy(userId = UserId("@bob:server.org"), displayName = "Bob")), + ResolvedMentionSuggestion.AtRoom, + ResolvedMentionSuggestion.Member(roomMember), + ResolvedMentionSuggestion.Member(roomMember.copy(userId = UserId("@bob:server.org"), displayName = "Bob")), ), - onSuggestionSelected = {} + onSelectSuggestion = {} ) } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mentions/MentionSuggestionsProcessor.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mentions/MentionSuggestionsProcessor.kt index 696f0fe93e..f0e89c1148 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mentions/MentionSuggestionsProcessor.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mentions/MentionSuggestionsProcessor.kt @@ -22,6 +22,7 @@ import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState 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.roomMembers +import io.element.android.libraries.textcomposer.mentions.ResolvedMentionSuggestion import io.element.android.libraries.textcomposer.model.Suggestion import io.element.android.libraries.textcomposer.model.SuggestionType @@ -45,7 +46,7 @@ object MentionSuggestionsProcessor { roomMembersState: MatrixRoomMembersState, currentUserId: UserId, canSendRoomMention: suspend () -> Boolean, - ): List { + ): List { val members = roomMembersState.roomMembers() return when { members.isNullOrEmpty() || suggestion == null -> { @@ -78,7 +79,7 @@ object MentionSuggestionsProcessor { roomMembers: List?, currentUserId: UserId, canSendRoomMention: Boolean, - ): List { + ): List { return if (roomMembers.isNullOrEmpty()) { emptyList() } else { @@ -96,10 +97,10 @@ object MentionSuggestionsProcessor { .filterUpTo(MAX_BATCH_ITEMS) { member -> isJoinedMemberAndNotSelf(member) && memberMatchesQuery(member, query) } - .map(MentionSuggestion::Member) + .map(ResolvedMentionSuggestion::Member) if ("room".contains(query) && canSendRoomMention) { - listOf(MentionSuggestion.Room) + matchingMembers + listOf(ResolvedMentionSuggestion.AtRoom) + matchingMembers } else { matchingMembers } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/AttachmentsBottomSheet.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/AttachmentsBottomSheet.kt index c2ed969a92..8ba8ceec01 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/AttachmentsBottomSheet.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/AttachmentsBottomSheet.kt @@ -48,8 +48,8 @@ import io.element.android.libraries.designsystem.theme.components.Text @Composable internal fun AttachmentsBottomSheet( state: MessageComposerState, - onSendLocationClicked: () -> Unit, - onCreatePollClicked: () -> Unit, + onSendLocationClick: () -> Unit, + onCreatePollClick: () -> Unit, enableTextFormatting: Boolean, modifier: Modifier = Modifier, ) { @@ -87,8 +87,8 @@ internal fun AttachmentsBottomSheet( AttachmentSourcePickerMenu( state = state, enableTextFormatting = enableTextFormatting, - onSendLocationClicked = onSendLocationClicked, - onCreatePollClicked = onCreatePollClicked, + onSendLocationClick = onSendLocationClick, + onCreatePollClick = onCreatePollClick, ) } } @@ -97,8 +97,8 @@ internal fun AttachmentsBottomSheet( @Composable private fun AttachmentSourcePickerMenu( state: MessageComposerState, - onSendLocationClicked: () -> Unit, - onCreatePollClicked: () -> Unit, + onSendLocationClick: () -> Unit, + onCreatePollClick: () -> Unit, enableTextFormatting: Boolean, ) { Column( @@ -106,18 +106,6 @@ private fun AttachmentSourcePickerMenu( .navigationBarsPadding() .imePadding() ) { - ListItem( - modifier = Modifier.clickable { state.eventSink(MessageComposerEvents.PickAttachmentSource.FromGallery) }, - leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Image())), - headlineContent = { Text(stringResource(R.string.screen_room_attachment_source_gallery)) }, - style = ListItemStyle.Primary, - ) - ListItem( - modifier = Modifier.clickable { state.eventSink(MessageComposerEvents.PickAttachmentSource.FromFiles) }, - leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Attachment())), - headlineContent = { Text(stringResource(R.string.screen_room_attachment_source_files)) }, - style = ListItemStyle.Primary, - ) ListItem( modifier = Modifier.clickable { state.eventSink(MessageComposerEvents.PickAttachmentSource.PhotoFromCamera) }, leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.TakePhoto())), @@ -130,11 +118,23 @@ private fun AttachmentSourcePickerMenu( headlineContent = { Text(stringResource(R.string.screen_room_attachment_source_camera_video)) }, style = ListItemStyle.Primary, ) + ListItem( + modifier = Modifier.clickable { state.eventSink(MessageComposerEvents.PickAttachmentSource.FromGallery) }, + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Image())), + headlineContent = { Text(stringResource(R.string.screen_room_attachment_source_gallery)) }, + style = ListItemStyle.Primary, + ) + ListItem( + modifier = Modifier.clickable { state.eventSink(MessageComposerEvents.PickAttachmentSource.FromFiles) }, + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Attachment())), + headlineContent = { Text(stringResource(R.string.screen_room_attachment_source_files)) }, + style = ListItemStyle.Primary, + ) if (state.canShareLocation) { ListItem( modifier = Modifier.clickable { state.eventSink(MessageComposerEvents.PickAttachmentSource.Location) - onSendLocationClicked() + onSendLocationClick() }, leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.LocationPin())), headlineContent = { Text(stringResource(R.string.screen_room_attachment_source_location)) }, @@ -145,7 +145,7 @@ private fun AttachmentSourcePickerMenu( ListItem( modifier = Modifier.clickable { state.eventSink(MessageComposerEvents.PickAttachmentSource.Poll) - onCreatePollClicked() + onCreatePollClick() }, leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Polls())), headlineContent = { Text(stringResource(R.string.screen_room_attachment_source_poll)) }, @@ -170,8 +170,8 @@ internal fun AttachmentSourcePickerMenuPreview() = ElementPreview { state = aMessageComposerState( canShareLocation = true, ), - onSendLocationClicked = {}, - onCreatePollClicked = {}, + onSendLocationClick = {}, + onCreatePollClick = {}, enableTextFormatting = true, ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerEvents.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerEvents.kt index 9ddf1f7aae..19ca038bd2 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerEvents.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerEvents.kt @@ -18,15 +18,14 @@ package io.element.android.features.messages.impl.messagecomposer import android.net.Uri import androidx.compose.runtime.Immutable -import io.element.android.features.messages.impl.mentions.MentionSuggestion -import io.element.android.libraries.textcomposer.model.Message +import io.element.android.libraries.textcomposer.mentions.ResolvedMentionSuggestion import io.element.android.libraries.textcomposer.model.MessageComposerMode import io.element.android.libraries.textcomposer.model.Suggestion @Immutable sealed interface MessageComposerEvents { data object ToggleFullScreenState : MessageComposerEvents - data class SendMessage(val message: Message) : MessageComposerEvents + data object SendMessage : MessageComposerEvents data class SendUri(val uri: Uri) : MessageComposerEvents data object CloseSpecialMode : MessageComposerEvents data class SetMode(val composerMode: MessageComposerMode) : MessageComposerEvents @@ -45,5 +44,5 @@ sealed interface MessageComposerEvents { data class Error(val error: Throwable) : MessageComposerEvents data class TypingNotice(val isTyping: Boolean) : MessageComposerEvents data class SuggestionReceived(val suggestion: Suggestion?) : MessageComposerEvents - data class InsertMention(val mention: MentionSuggestion) : MessageComposerEvents + data class InsertMention(val mention: ResolvedMentionSuggestion) : MessageComposerEvents } 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 30bdadcbe5..929dfe024a 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 @@ -19,6 +19,7 @@ package io.element.android.features.messages.impl.messagecomposer import android.Manifest import android.annotation.SuppressLint import android.net.Uri +import androidx.annotation.VisibleForTesting import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -29,6 +30,7 @@ import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.media3.common.MimeTypes @@ -36,7 +38,6 @@ import androidx.media3.common.util.UnstableApi import im.vector.app.features.analytics.plan.Composer import io.element.android.features.messages.impl.attachments.Attachment import io.element.android.features.messages.impl.attachments.preview.error.sendAttachmentError -import io.element.android.features.messages.impl.mentions.MentionSuggestion import io.element.android.features.messages.impl.mentions.MentionSuggestionsProcessor import io.element.android.features.messages.impl.timeline.TimelineController import io.element.android.features.preferences.api.store.SessionPreferencesStore @@ -59,17 +60,21 @@ import io.element.android.libraries.mediaupload.api.MediaSender import io.element.android.libraries.mediaviewer.api.local.LocalMediaFactory import io.element.android.libraries.permissions.api.PermissionsEvents import io.element.android.libraries.permissions.api.PermissionsPresenter +import io.element.android.libraries.textcomposer.mentions.ResolvedMentionSuggestion +import io.element.android.libraries.textcomposer.mentions.rememberMentionSpanProvider import io.element.android.libraries.textcomposer.model.Message import io.element.android.libraries.textcomposer.model.MessageComposerMode import io.element.android.libraries.textcomposer.model.Suggestion +import io.element.android.libraries.textcomposer.model.TextEditorState +import io.element.android.libraries.textcomposer.model.rememberMarkdownTextEditorState import io.element.android.services.analytics.api.AnalyticsService -import io.element.android.wysiwyg.compose.RichTextEditorState import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.combine @@ -108,12 +113,27 @@ class MessageComposerPresenter @Inject constructor( private val suggestionSearchTrigger = MutableStateFlow(null) + // Used to disable some UI related elements in tests + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal var isTesting: Boolean = false + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal var showTextFormatting: Boolean by mutableStateOf(false) + @OptIn(FlowPreview::class) @SuppressLint("UnsafeOptInUsageError") @Composable override fun present(): MessageComposerState { val localCoroutineScope = rememberCoroutineScope() + // Initially disabled so we don't set focus and text twice + var applyFormattingModeChanges by remember { mutableStateOf(false) } + val richTextEditorState = richTextEditorStateFactory.remember() + if (isTesting) { + richTextEditorState.isReadyToProcessActions = true + } + val markdownTextEditorState = rememberMarkdownTextEditorState(initialText = null, initialFocus = false) + var isMentionsEnabled by remember { mutableStateOf(false) } LaunchedEffect(Unit) { isMentionsEnabled = featureFlagService.isFeatureEnabled(FeatureFlags.Mentions) @@ -149,18 +169,20 @@ class MessageComposerPresenter @Inject constructor( val isFullScreen = rememberSaveable { mutableStateOf(false) } - val richTextEditorState = richTextEditorStateFactory.create() val ongoingSendAttachmentJob = remember { mutableStateOf(null) } var showAttachmentSourcePicker: Boolean by remember { mutableStateOf(false) } - var showTextFormatting: Boolean by remember { mutableStateOf(false) } val sendTypingNotifications by sessionPreferencesStore.isSendTypingNotificationsEnabled().collectAsState(initial = true) LaunchedEffect(messageComposerContext.composerMode) { when (val modeValue = messageComposerContext.composerMode) { is MessageComposerMode.Edit -> - richTextEditorState.setHtml(modeValue.defaultContent) + if (showTextFormatting) { + richTextEditorState.setHtml(modeValue.defaultContent) + } else { + markdownTextEditorState.text.update(modeValue.defaultContent, true) + } else -> Unit } } @@ -188,7 +210,7 @@ class MessageComposerPresenter @Inject constructor( } } - val memberSuggestions = remember { mutableStateListOf() } + val memberSuggestions = remember { mutableStateListOf() } LaunchedEffect(isMentionsEnabled) { if (!isMentionsEnabled) return@LaunchedEffect val currentUserId = currentSessionIdHolder.current @@ -229,22 +251,69 @@ class MessageComposerPresenter @Inject constructor( } } + val textEditorState by rememberUpdatedState( + if (showTextFormatting) { + TextEditorState.Rich(richTextEditorState) + } else { + TextEditorState.Markdown(markdownTextEditorState) + } + ) + + LaunchedEffect(showTextFormatting) { + if (!applyFormattingModeChanges) { + applyFormattingModeChanges = true + return@LaunchedEffect + } + if (showTextFormatting) { + val markdown = markdownTextEditorState.getMessageMarkdown(permalinkBuilder) + richTextEditorState.setMarkdown(markdown) + richTextEditorState.requestFocus() + } else { + val markdown = richTextEditorState.messageMarkdown + markdownTextEditorState.text.update(markdown, true) + // Give some time for the focus of the previous editor to be cleared + delay(100) + markdownTextEditorState.requestFocusAction() + } + } + + val mentionSpanProvider = if (isTesting) { + null + } else { + rememberMentionSpanProvider( + currentUserId = room.sessionId, + permalinkParser = permalinkParser, + ) + } + fun handleEvents(event: MessageComposerEvents) { when (event) { MessageComposerEvents.ToggleFullScreenState -> isFullScreen.value = !isFullScreen.value MessageComposerEvents.CloseSpecialMode -> { if (messageComposerContext.composerMode is MessageComposerMode.Edit) { localCoroutineScope.launch { - richTextEditorState.setHtml("") + textEditorState.reset() } } messageComposerContext.composerMode = MessageComposerMode.Normal } - is MessageComposerEvents.SendMessage -> appCoroutineScope.sendMessage( - message = event.message, - updateComposerMode = { messageComposerContext.composerMode = it }, - richTextEditorState = richTextEditorState, - ) + is MessageComposerEvents.SendMessage -> { + val html = if (showTextFormatting) { + richTextEditorState.messageHtml + } else { + null + } + val markdown = if (showTextFormatting) { + richTextEditorState.messageMarkdown + } else { + markdownTextEditorState.getMessageMarkdown(permalinkBuilder) + } + appCoroutineScope.sendMessage( + message = Message(html = html, markdown = markdown), + updateComposerMode = { messageComposerContext.composerMode = it }, + textEditorState = textEditorState, + ) + } is MessageComposerEvents.SendUri -> appCoroutineScope.sendAttachment( attachment = Attachment.Media( localMedia = localMediaFactory.createFromUri( @@ -335,15 +404,26 @@ class MessageComposerPresenter @Inject constructor( } is MessageComposerEvents.InsertMention -> { localCoroutineScope.launch { - when (val mention = event.mention) { - is MentionSuggestion.Room -> { - richTextEditorState.insertAtRoomMentionAtSuggestion() + if (showTextFormatting) { + when (val mention = event.mention) { + is ResolvedMentionSuggestion.AtRoom -> { + richTextEditorState.insertAtRoomMentionAtSuggestion() + } + is ResolvedMentionSuggestion.Member -> { + val text = mention.roomMember.displayName?.prependIndent("@") ?: mention.roomMember.userId.value + val link = permalinkBuilder.permalinkForUser(mention.roomMember.userId).getOrNull() ?: return@launch + richTextEditorState.insertMentionAtSuggestion(text = text, link = link) + } } - is MentionSuggestion.Member -> { - val text = mention.roomMember.displayName?.prependIndent("@") ?: mention.roomMember.userId.value - val link = permalinkBuilder.permalinkForUser(mention.roomMember.userId).getOrNull() ?: return@launch - richTextEditorState.insertMentionAtSuggestion(text = text, link = link) + } else if (markdownTextEditorState.currentMentionSuggestion != null) { + mentionSpanProvider?.let { + markdownTextEditorState.insertMention( + mention = event.mention, + mentionSpanProvider = it, + permalinkBuilder = permalinkBuilder, + ) } + suggestionSearchTrigger.value = null } } } @@ -351,7 +431,7 @@ class MessageComposerPresenter @Inject constructor( } return MessageComposerState( - richTextEditorState = richTextEditorState, + textEditorState = textEditorState, permalinkParser = permalinkParser, isFullScreen = isFullScreen.value, mode = messageComposerContext.composerMode, @@ -369,21 +449,26 @@ class MessageComposerPresenter @Inject constructor( private fun CoroutineScope.sendMessage( message: Message, updateComposerMode: (newComposerMode: MessageComposerMode) -> Unit, - richTextEditorState: RichTextEditorState, + textEditorState: TextEditorState, ) = launch { val capturedMode = messageComposerContext.composerMode - val mentions = richTextEditorState.mentionsState?.let { state -> - buildList { - if (state.hasAtRoomMention) { - add(Mention.AtRoom) - } - for (userId in state.userIds) { - add(Mention.User(UserId(userId))) - } + val mentions = when (textEditorState) { + is TextEditorState.Rich -> { + textEditorState.richTextEditorState.mentionsState?.let { state -> + buildList { + if (state.hasAtRoomMention) { + add(Mention.AtRoom) + } + for (userId in state.userIds) { + add(Mention.User(UserId(userId))) + } + } + }.orEmpty() } - }.orEmpty() + is TextEditorState.Markdown -> textEditorState.state.getMentions() + } // Reset composer right away - richTextEditorState.setHtml("") + textEditorState.reset() updateComposerMode(MessageComposerMode.Normal) when (capturedMode) { is MessageComposerMode.Normal -> room.sendMessage(body = message.markdown, htmlBody = message.html, mentions = mentions) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt index 194ce1914c..4ac69ed3d6 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt @@ -19,16 +19,16 @@ package io.element.android.features.messages.impl.messagecomposer import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable import io.element.android.features.messages.impl.attachments.Attachment -import io.element.android.features.messages.impl.mentions.MentionSuggestion import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.permalink.PermalinkParser +import io.element.android.libraries.textcomposer.mentions.ResolvedMentionSuggestion import io.element.android.libraries.textcomposer.model.MessageComposerMode -import io.element.android.wysiwyg.compose.RichTextEditorState +import io.element.android.libraries.textcomposer.model.TextEditorState import kotlinx.collections.immutable.ImmutableList @Stable data class MessageComposerState( - val richTextEditorState: RichTextEditorState, + val textEditorState: TextEditorState, val permalinkParser: PermalinkParser, val isFullScreen: Boolean, val mode: MessageComposerMode, @@ -37,12 +37,10 @@ data class MessageComposerState( val canShareLocation: Boolean, val canCreatePoll: Boolean, val attachmentsState: AttachmentsState, - val memberSuggestions: ImmutableList, + val memberSuggestions: ImmutableList, val currentUserId: UserId, val eventSink: (MessageComposerEvents) -> Unit, -) { - val hasFocus: Boolean = richTextEditorState.hasFocus -} +) @Immutable sealed interface AttachmentsState { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt index 340f7328f3..7ec47f4ef5 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt @@ -17,13 +17,13 @@ package io.element.android.features.messages.impl.messagecomposer import androidx.compose.ui.tooling.preview.PreviewParameterProvider -import io.element.android.features.messages.impl.mentions.MentionSuggestion import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.permalink.PermalinkData import io.element.android.libraries.matrix.api.permalink.PermalinkParser import io.element.android.libraries.textcomposer.aRichTextEditorState +import io.element.android.libraries.textcomposer.mentions.ResolvedMentionSuggestion import io.element.android.libraries.textcomposer.model.MessageComposerMode -import io.element.android.wysiwyg.compose.RichTextEditorState +import io.element.android.libraries.textcomposer.model.TextEditorState import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -35,7 +35,7 @@ open class MessageComposerStateProvider : PreviewParameterProvider = persistentListOf(), + memberSuggestions: ImmutableList = persistentListOf(), ) = MessageComposerState( - richTextEditorState = richTextEditorState, + textEditorState = textEditorState, permalinkParser = object : PermalinkParser { override fun parse(uriString: String): PermalinkData = TODO() }, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt index e68cf29844..cd2e93e45f 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt @@ -33,7 +33,6 @@ import io.element.android.features.messages.impl.voicemessages.composer.aVoiceMe import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.textcomposer.TextComposer -import io.element.android.libraries.textcomposer.model.Message import io.element.android.libraries.textcomposer.model.Suggestion import io.element.android.libraries.textcomposer.model.VoiceMessagePlayerEvent import io.element.android.libraries.textcomposer.model.VoiceMessageRecorderEvent @@ -44,13 +43,12 @@ internal fun MessageComposerView( state: MessageComposerState, voiceMessageState: VoiceMessageComposerState, subcomposing: Boolean, - enableTextFormatting: Boolean, enableVoiceMessages: Boolean, modifier: Modifier = Modifier, ) { val view = LocalView.current - fun sendMessage(message: Message) { - state.eventSink(MessageComposerEvents.SendMessage(message)) + fun sendMessage() { + state.eventSink(MessageComposerEvents.SendMessage) } fun sendUri(uri: Uri) { @@ -85,7 +83,7 @@ internal fun MessageComposerView( val coroutineScope = rememberCoroutineScope() fun onRequestFocus() { coroutineScope.launch { - state.richTextEditorState.requestFocus() + state.textEditorState.requestFocus() } } @@ -107,7 +105,7 @@ internal fun MessageComposerView( TextComposer( modifier = modifier, - state = state.richTextEditorState, + state = state.textEditorState, voiceMessageState = voiceMessageState.voiceMessageState, permalinkParser = state.permalinkParser, subcomposing = subcomposing, @@ -118,17 +116,16 @@ internal fun MessageComposerView( onResetComposerMode = ::onCloseSpecialMode, onAddAttachment = ::onAddAttachment, onDismissTextFormatting = ::onDismissTextFormatting, - enableTextFormatting = enableTextFormatting, enableVoiceMessages = enableVoiceMessages, onVoiceRecorderEvent = onVoiceRecorderEvent, onVoicePlayerEvent = onVoicePlayerEvent, onSendVoiceMessage = onSendVoiceMessage, onDeleteVoiceMessage = onDeleteVoiceMessage, - onSuggestionReceived = ::onSuggestionReceived, + onReceiveSuggestion = ::onSuggestionReceived, onError = ::onError, onTyping = ::onTyping, currentUserId = state.currentUserId, - onRichContentSelected = ::sendUri, + onSelectRichContent = ::sendUri, ) } @@ -142,7 +139,6 @@ internal fun MessageComposerViewPreview( modifier = Modifier.height(IntrinsicSize.Min), state = state, voiceMessageState = aVoiceMessageComposerState(), - enableTextFormatting = true, enableVoiceMessages = true, subcomposing = false, ) @@ -150,7 +146,6 @@ internal fun MessageComposerViewPreview( modifier = Modifier.height(200.dp), state = state, voiceMessageState = aVoiceMessageComposerState(), - enableTextFormatting = true, enableVoiceMessages = true, subcomposing = false, ) @@ -167,7 +162,6 @@ internal fun MessageComposerViewVoicePreview( modifier = Modifier.height(IntrinsicSize.Min), state = aMessageComposerState(), voiceMessageState = state, - enableTextFormatting = true, enableVoiceMessages = true, subcomposing = false, ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/RichTextEditorStateFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/RichTextEditorStateFactory.kt index 52fff81c31..4ce09e800d 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/RichTextEditorStateFactory.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/RichTextEditorStateFactory.kt @@ -25,13 +25,13 @@ import javax.inject.Inject interface RichTextEditorStateFactory { @Composable - fun create(): RichTextEditorState + fun remember(): RichTextEditorState } @ContributesBinding(AppScope::class) class DefaultRichTextEditorStateFactory @Inject constructor() : RichTextEditorStateFactory { @Composable - override fun create(): RichTextEditorState { + override fun remember(): RichTextEditorState { return rememberRichTextEditorState() } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/report/ReportMessageNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/report/ReportMessageNode.kt index a1157e2830..910de10ff2 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/report/ReportMessageNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/report/ReportMessageNode.kt @@ -52,7 +52,7 @@ class ReportMessageNode @AssistedInject constructor( val state = presenter.present() ReportMessageView( state = state, - onBackClicked = ::navigateUp, + onBackClick = ::navigateUp, modifier = modifier ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/report/ReportMessageView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/report/ReportMessageView.kt index 2791ee632c..720c65dfc1 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/report/ReportMessageView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/report/ReportMessageView.kt @@ -58,7 +58,7 @@ import io.element.android.libraries.ui.strings.CommonStrings @Composable fun ReportMessageView( state: ReportMessageState, - onBackClicked: () -> Unit, + onBackClick: () -> Unit, modifier: Modifier = Modifier, ) { val focusManager = LocalFocusManager.current @@ -66,7 +66,7 @@ fun ReportMessageView( AsyncActionView( async = state.result, progressDialog = {}, - onSuccess = { onBackClicked() }, + onSuccess = { onBackClick() }, errorMessage = { stringResource(CommonStrings.error_unknown) }, onErrorDismiss = { state.eventSink(ReportMessageEvents.ClearError) } ) @@ -81,7 +81,7 @@ fun ReportMessageView( ) }, navigationIcon = { - BackButton(onClick = onBackClicked) + BackButton(onClick = onBackClick) } ) }, @@ -160,7 +160,7 @@ fun ReportMessageView( @Composable internal fun ReportMessageViewPreview(@PreviewParameter(ReportMessageStateProvider::class) state: ReportMessageState) = ElementPreview { ReportMessageView( - onBackClicked = {}, + onBackClick = {}, state = state, ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvents.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvents.kt index 8a5d3cd275..65600f48cf 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvents.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineEvents.kt @@ -37,16 +37,16 @@ sealed interface TimelineEvents { */ sealed interface TimelineItemPollEvents : EventFromTimelineItem - data class PollAnswerSelected( + data class SelectPollAnswer( val pollStartId: EventId, val answerId: String ) : TimelineItemPollEvents - data class PollEndClicked( + data class EndPoll( val pollStartId: EventId, ) : TimelineItemPollEvents - data class PollEditClicked( + data class EditPoll( val pollStartId: EventId, ) : TimelineItemPollEvents } 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 d6a02959c0..fca11f6d8e 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 @@ -122,19 +122,19 @@ class TimelinePresenter @AssistedInject constructor( newEventState.value = NewEventState.None } } - is TimelineEvents.PollAnswerSelected -> appScope.launch { + is TimelineEvents.SelectPollAnswer -> appScope.launch { sendPollResponseAction.execute( pollStartId = event.pollStartId, answerId = event.answerId ) } - is TimelineEvents.PollEndClicked -> appScope.launch { + is TimelineEvents.EndPoll -> appScope.launch { endPollAction.execute( pollStartId = event.pollStartId, ) } - is TimelineEvents.PollEditClicked -> { - navigator.onEditPollClicked(event.pollStartId) + is TimelineEvents.EditPoll -> { + navigator.onEditPollClick(event.pollStartId) } is TimelineEvents.FocusOnEvent -> localScope.launch { focusedEventId.value = event.eventId 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 3483c27f85..46761d15cb 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 @@ -79,15 +79,15 @@ import kotlin.math.abs fun TimelineView( state: TimelineState, typingNotificationState: TypingNotificationState, - onUserDataClicked: (UserId) -> Unit, - onLinkClicked: (String) -> Unit, - onMessageClicked: (TimelineItem.Event) -> Unit, - onMessageLongClicked: (TimelineItem.Event) -> Unit, - onTimestampClicked: (TimelineItem.Event) -> Unit, + onUserDataClick: (UserId) -> Unit, + onLinkClick: (String) -> Unit, + onMessageClick: (TimelineItem.Event) -> Unit, + onMessageLongClick: (TimelineItem.Event) -> Unit, + onTimestampClick: (TimelineItem.Event) -> Unit, onSwipeToReply: (TimelineItem.Event) -> Unit, - onReactionClicked: (emoji: String, TimelineItem.Event) -> Unit, - onReactionLongClicked: (emoji: String, TimelineItem.Event) -> Unit, - onMoreReactionsClicked: (TimelineItem.Event) -> Unit, + onReactionClick: (emoji: String, TimelineItem.Event) -> Unit, + onReactionLongClick: (emoji: String, TimelineItem.Event) -> Unit, + onMoreReactionsClick: (TimelineItem.Event) -> Unit, onReadReceiptClick: (TimelineItem.Event) -> Unit, modifier: Modifier = Modifier, forceJumpToBottomVisibility: Boolean = false @@ -96,7 +96,7 @@ fun TimelineView( state.eventSink(TimelineEvents.ClearFocusRequestState) } - fun onScrollFinishedAt(firstVisibleIndex: Int) { + fun onScrollFinishAt(firstVisibleIndex: Int) { state.eventSink(TimelineEvents.OnScrollFinished(firstVisibleIndex)) } @@ -108,7 +108,7 @@ fun TimelineView( accessibilityManager.isTouchExplorationEnabled.not() } - fun inReplyToClicked(eventId: EventId) { + fun inReplyToClick(eventId: EventId) { state.eventSink(TimelineEvents.FocusOnEvent(eventId)) } @@ -138,16 +138,16 @@ fun TimelineView( isLastOutgoingMessage = (timelineItem as? TimelineItem.Event)?.isMine == true && state.timelineItems.first().identifier() == timelineItem.identifier(), focusedEventId = state.focusedEventId, - onClick = onMessageClicked, - onLongClick = onMessageLongClicked, - onUserDataClick = onUserDataClicked, - onLinkClicked = onLinkClicked, - inReplyToClick = ::inReplyToClicked, - onReactionClick = onReactionClicked, - onReactionLongClick = onReactionLongClicked, - onMoreReactionsClick = onMoreReactionsClicked, + onClick = onMessageClick, + onLongClick = onMessageLongClick, + onUserDataClick = onUserDataClick, + onLinkClick = onLinkClick, + inReplyToClick = ::inReplyToClick, + onReactionClick = onReactionClick, + onReactionLongClick = onReactionLongClick, + onMoreReactionsClick = onMoreReactionsClick, onReadReceiptClick = onReadReceiptClick, - onTimestampClicked = onTimestampClicked, + onTimestampClick = onTimestampClick, eventSink = state.eventSink, onSwipeToReply = onSwipeToReply, ) @@ -166,7 +166,7 @@ fun TimelineView( newEventState = state.newEventState, isLive = state.isLive, focusRequestState = state.focusRequestState, - onScrollFinishedAt = ::onScrollFinishedAt, + onScrollFinishAt = ::onScrollFinishAt, onClearFocusRequestState = ::clearFocusRequestState, onJumpToLive = { state.eventSink(TimelineEvents.JumpToLive) }, ) @@ -183,7 +183,7 @@ private fun BoxScope.TimelineScrollHelper( forceJumpToBottomVisibility: Boolean, focusRequestState: FocusRequestState, onClearFocusRequestState: () -> Unit, - onScrollFinishedAt: (Int) -> Unit, + onScrollFinishAt: (Int) -> Unit, onJumpToLive: () -> Unit, ) { val coroutineScope = rememberCoroutineScope() @@ -231,11 +231,11 @@ private fun BoxScope.TimelineScrollHelper( } } - val latestOnScrollFinishedAt by rememberUpdatedState(onScrollFinishedAt) + val latestOnScrollFinishAt by rememberUpdatedState(onScrollFinishAt) LaunchedEffect(isScrollFinished, hasAnyEvent) { if (isScrollFinished && hasAnyEvent) { // Notify the parent composable about the first visible item index when scrolling finishes - latestOnScrollFinishedAt(lazyListState.firstVisibleItemIndex) + latestOnScrollFinishAt(lazyListState.firstVisibleItemIndex) } } @@ -295,15 +295,15 @@ internal fun TimelineViewPreview( focusedEventIndex = 0, ), typingNotificationState = aTypingNotificationState(), - onUserDataClicked = {}, - onLinkClicked = {}, - onMessageClicked = {}, - onMessageLongClicked = {}, - onTimestampClicked = {}, + onUserDataClick = {}, + onLinkClick = {}, + onMessageClick = {}, + onMessageLongClick = {}, + onTimestampClick = {}, onSwipeToReply = {}, - onReactionClicked = { _, _ -> }, - onReactionLongClicked = { _, _ -> }, - onMoreReactionsClicked = {}, + onReactionClick = { _, _ -> }, + onReactionLongClick = { _, _ -> }, + onMoreReactionsClick = {}, onReadReceiptClick = {}, forceJumpToBottomVisibility = true, ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/ATimelineItemEventRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/ATimelineItemEventRow.kt index 92b12b2dc8..b6e6646c1e 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/ATimelineItemEventRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/ATimelineItemEventRow.kt @@ -38,13 +38,13 @@ internal fun ATimelineItemEventRow( onClick = {}, onLongClick = {}, onUserDataClick = {}, - onLinkClicked = {}, + onLinkClick = {}, inReplyToClick = {}, onReactionClick = { _, _ -> }, onReactionLongClick = { _, _ -> }, onMoreReactionsClick = {}, onReadReceiptClick = {}, onSwipeToReply = {}, - onTimestampClicked = {}, + onTimestampClick = {}, eventSink = {}, ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt index 87dd60b8a7..993d872852 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt @@ -127,10 +127,10 @@ fun TimelineItemEventRow( isHighlighted: Boolean, onClick: () -> Unit, onLongClick: () -> Unit, - onLinkClicked: (String) -> Unit, + onLinkClick: (String) -> Unit, onUserDataClick: (UserId) -> Unit, inReplyToClick: (EventId) -> Unit, - onTimestampClicked: (TimelineItem.Event) -> Unit, + onTimestampClick: (TimelineItem.Event) -> Unit, onReactionClick: (emoji: String, eventId: TimelineItem.Event) -> Unit, onReactionLongClick: (emoji: String, eventId: TimelineItem.Event) -> Unit, onMoreReactionsClick: (eventId: TimelineItem.Event) -> Unit, @@ -142,11 +142,11 @@ fun TimelineItemEventRow( val coroutineScope = rememberCoroutineScope() val interactionSource = remember { MutableInteractionSource() } - fun onUserDataClicked() { + fun onUserDataClick() { onUserDataClick(event.senderId) } - fun inReplyToClicked() { + fun inReplyToClick() { val inReplyToEventId = event.inReplyTo?.eventId() ?: return inReplyToClick(inReplyToEventId) } @@ -190,13 +190,13 @@ fun TimelineItemEventRow( interactionSource = interactionSource, onClick = onClick, onLongClick = onLongClick, - onTimestampClicked = onTimestampClicked, - inReplyToClicked = ::inReplyToClicked, - onUserDataClicked = ::onUserDataClicked, - onReactionClicked = { emoji -> onReactionClick(emoji, event) }, - onReactionLongClicked = { emoji -> onReactionLongClick(emoji, event) }, - onMoreReactionsClicked = { onMoreReactionsClick(event) }, - onLinkClicked = onLinkClicked, + onTimestampClick = onTimestampClick, + inReplyToClick = ::inReplyToClick, + onUserDataClick = ::onUserDataClick, + onReactionClick = { emoji -> onReactionClick(emoji, event) }, + onReactionLongClick = { emoji -> onReactionLongClick(emoji, event) }, + onMoreReactionsClick = { onMoreReactionsClick(event) }, + onLinkClick = onLinkClick, eventSink = eventSink, ) } @@ -209,13 +209,13 @@ fun TimelineItemEventRow( interactionSource = interactionSource, onClick = onClick, onLongClick = onLongClick, - onTimestampClicked = onTimestampClicked, - inReplyToClicked = ::inReplyToClicked, - onUserDataClicked = ::onUserDataClicked, - onReactionClicked = { emoji -> onReactionClick(emoji, event) }, - onReactionLongClicked = { emoji -> onReactionLongClick(emoji, event) }, - onMoreReactionsClicked = { onMoreReactionsClick(event) }, - onLinkClicked = onLinkClicked, + onTimestampClick = onTimestampClick, + inReplyToClick = ::inReplyToClick, + onUserDataClick = ::onUserDataClick, + onReactionClick = { emoji -> onReactionClick(emoji, event) }, + onReactionLongClick = { emoji -> onReactionLongClick(emoji, event) }, + onMoreReactionsClick = { onMoreReactionsClick(event) }, + onLinkClick = onLinkClick, eventSink = eventSink, ) } @@ -227,7 +227,7 @@ fun TimelineItemEventRow( receipts = event.readReceiptState.receipts, ), renderReadReceipts = renderReadReceipts, - onReadReceiptsClicked = { onReadReceiptClick(event) }, + onReadReceiptsClick = { onReadReceiptClick(event) }, modifier = Modifier.padding(top = 4.dp), ) } @@ -265,13 +265,13 @@ private fun TimelineItemEventRowContent( interactionSource: MutableInteractionSource, onClick: () -> Unit, onLongClick: () -> Unit, - onTimestampClicked: (TimelineItem.Event) -> Unit, - inReplyToClicked: () -> Unit, - onUserDataClicked: () -> Unit, - onReactionClicked: (emoji: String) -> Unit, - onReactionLongClicked: (emoji: String) -> Unit, - onMoreReactionsClicked: (event: TimelineItem.Event) -> Unit, - onLinkClicked: (String) -> Unit, + onTimestampClick: (TimelineItem.Event) -> Unit, + inReplyToClick: () -> Unit, + onUserDataClick: () -> Unit, + onReactionClick: (emoji: String) -> Unit, + onReactionLongClick: (emoji: String) -> Unit, + onMoreReactionsClick: (event: TimelineItem.Event) -> Unit, + onLinkClick: (String) -> Unit, eventSink: (TimelineEvents.EventFromTimelineItem) -> Unit, modifier: Modifier = Modifier, ) { @@ -306,7 +306,7 @@ private fun TimelineItemEventRowContent( } .padding(horizontal = 16.dp) .zIndex(1f) - .clickable(onClick = onUserDataClicked) + .clickable(onClick = onUserDataClick) // This is redundant when using talkback .clearAndSetSemantics { invisibleToUser() @@ -336,11 +336,11 @@ private fun TimelineItemEventRowContent( MessageEventBubbleContent( event = event, onMessageLongClick = onLongClick, - inReplyToClick = inReplyToClicked, - onTimestampClicked = { - onTimestampClicked(event) + inReplyToClick = inReplyToClick, + onTimestampClick = { + onTimestampClick(event) }, - onLinkClicked = onLinkClicked, + onLinkClick = onLinkClick, eventSink = eventSink, ) } @@ -351,9 +351,9 @@ private fun TimelineItemEventRowContent( reactionsState = event.reactionsState, userCanSendReaction = timelineRoomInfo.userHasPermissionToSendReaction, isOutgoing = event.isMine, - onReactionClicked = onReactionClicked, - onReactionLongClicked = onReactionLongClicked, - onMoreReactionsClicked = { onMoreReactionsClicked(event) }, + onReactionClick = onReactionClick, + onReactionLongClick = onReactionLongClick, + onMoreReactionsClick = { onMoreReactionsClick(event) }, modifier = Modifier .constrainAs(reactions) { top.linkTo(message.bottom, margin = (-4).dp) @@ -419,8 +419,8 @@ private fun MessageEventBubbleContent( event: TimelineItem.Event, onMessageLongClick: () -> Unit, inReplyToClick: () -> Unit, - onTimestampClicked: () -> Unit, - onLinkClicked: (String) -> Unit, + onTimestampClick: () -> Unit, + onLinkClick: (String) -> Unit, eventSink: (TimelineEvents.EventFromTimelineItem) -> Unit, @SuppressLint("ModifierParameter") // need to rename this modifier to prevent linter false positives @@ -460,7 +460,7 @@ private fun MessageEventBubbleContent( timestampPosition: TimestampPosition, modifier: Modifier = Modifier, canShrinkContent: Boolean = false, - content: @Composable (onContentLayoutChanged: (ContentAvoidingLayoutData) -> Unit) -> Unit, + content: @Composable (onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit) -> Unit, ) { when (timestampPosition) { TimestampPosition.Overlay -> @@ -468,7 +468,7 @@ private fun MessageEventBubbleContent( content {} TimelineEventTimestampView( event = event, - onClick = onTimestampClicked, + onClick = onTimestampClick, onLongClick = ::onTimestampLongClick, modifier = Modifier // Outer padding @@ -486,11 +486,11 @@ private fun MessageEventBubbleContent( spacing = (-4).dp, overlayOffset = DpOffset(0.dp, -1.dp), shrinkContent = canShrinkContent, - content = { content(this::onContentLayoutChanged) }, + content = { content(this::onContentLayoutChange) }, overlay = { TimelineEventTimestampView( event = event, - onClick = onTimestampClicked, + onClick = onTimestampClick, onLongClick = ::onTimestampLongClick, modifier = Modifier .padding(horizontal = 8.dp, vertical = 4.dp) @@ -502,7 +502,7 @@ private fun MessageEventBubbleContent( content {} TimelineEventTimestampView( event = event, - onClick = onTimestampClicked, + onClick = onTimestampClick, onLongClick = ::onTimestampLongClick, modifier = Modifier .align(Alignment.End) @@ -553,12 +553,12 @@ private fun MessageEventBubbleContent( timestampPosition = timestampPosition, canShrinkContent = canShrinkContent, modifier = timestampLayoutModifier, - ) { onContentLayoutChanged -> + ) { onContentLayoutChange -> TimelineItemEventContentView( content = event.content, - onLinkClicked = onLinkClicked, + onLinkClick = onLinkClick, eventSink = eventSink, - onContentLayoutChanged = onContentLayoutChanged, + onContentLayoutChange = onContentLayoutChange, modifier = contentModifier ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemGroupedEventsRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemGroupedEventsRow.kt index 2dcc0c1b55..6f9f8146a2 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemGroupedEventsRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemGroupedEventsRow.kt @@ -48,8 +48,8 @@ fun TimelineItemGroupedEventsRow( onLongClick: (TimelineItem.Event) -> Unit, inReplyToClick: (EventId) -> Unit, onUserDataClick: (UserId) -> Unit, - onLinkClicked: (String) -> Unit, - onTimestampClicked: (TimelineItem.Event) -> Unit, + onLinkClick: (String) -> Unit, + onTimestampClick: (TimelineItem.Event) -> Unit, onReactionClick: (key: String, TimelineItem.Event) -> Unit, onReactionLongClick: (key: String, TimelineItem.Event) -> Unit, onMoreReactionsClick: (TimelineItem.Event) -> Unit, @@ -75,8 +75,8 @@ fun TimelineItemGroupedEventsRow( onLongClick = onLongClick, inReplyToClick = inReplyToClick, onUserDataClick = onUserDataClick, - onLinkClicked = onLinkClicked, - onTimestampClicked = onTimestampClicked, + onLinkClick = onLinkClick, + onTimestampClick = onTimestampClick, onReactionClick = onReactionClick, onReactionLongClick = onReactionLongClick, onMoreReactionsClick = onMoreReactionsClick, @@ -99,8 +99,8 @@ private fun TimelineItemGroupedEventsRowContent( onLongClick: (TimelineItem.Event) -> Unit, inReplyToClick: (EventId) -> Unit, onUserDataClick: (UserId) -> Unit, - onLinkClicked: (String) -> Unit, - onTimestampClicked: (TimelineItem.Event) -> Unit, + onLinkClick: (String) -> Unit, + onTimestampClick: (TimelineItem.Event) -> Unit, onReactionClick: (key: String, TimelineItem.Event) -> Unit, onReactionLongClick: (key: String, TimelineItem.Event) -> Unit, onMoreReactionsClick: (TimelineItem.Event) -> Unit, @@ -132,8 +132,8 @@ private fun TimelineItemGroupedEventsRowContent( onLongClick = onLongClick, inReplyToClick = inReplyToClick, onUserDataClick = onUserDataClick, - onLinkClicked = onLinkClicked, - onTimestampClicked = onTimestampClicked, + onLinkClick = onLinkClick, + onTimestampClick = onTimestampClick, onReactionClick = onReactionClick, onReactionLongClick = onReactionLongClick, onMoreReactionsClick = onMoreReactionsClick, @@ -151,7 +151,7 @@ private fun TimelineItemGroupedEventsRowContent( receipts = timelineItem.aggregatedReadReceipts, ), renderReadReceipts = true, - onReadReceiptsClicked = onExpandGroupClick + onReadReceiptsClick = onExpandGroupClick ) } } @@ -173,8 +173,8 @@ internal fun TimelineItemGroupedEventsRowContentExpandedPreview() = ElementPrevi onLongClick = {}, inReplyToClick = {}, onUserDataClick = {}, - onLinkClicked = {}, - onTimestampClicked = {}, + onLinkClick = {}, + onTimestampClick = {}, onReactionClick = { _, _ -> }, onReactionLongClick = { _, _ -> }, onMoreReactionsClick = {}, @@ -198,8 +198,8 @@ internal fun TimelineItemGroupedEventsRowContentCollapsePreview() = ElementPrevi onLongClick = {}, inReplyToClick = {}, onUserDataClick = {}, - onLinkClicked = {}, - onTimestampClicked = {}, + onLinkClick = {}, + onTimestampClick = {}, onReactionClick = { _, _ -> }, onReactionLongClick = { _, _ -> }, onMoreReactionsClick = {}, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemReactionsView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemReactionsView.kt index ba357ab573..0b9d43d285 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemReactionsView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemReactionsView.kt @@ -42,9 +42,9 @@ fun TimelineItemReactionsView( reactionsState: TimelineItemReactions, isOutgoing: Boolean, userCanSendReaction: Boolean, - onReactionClicked: (emoji: String) -> Unit, - onReactionLongClicked: (emoji: String) -> Unit, - onMoreReactionsClicked: () -> Unit, + onReactionClick: (emoji: String) -> Unit, + onReactionLongClick: (emoji: String) -> Unit, + onMoreReactionsClick: () -> Unit, modifier: Modifier = Modifier, ) { var expanded: Boolean by rememberSaveable { mutableStateOf(false) } @@ -54,9 +54,9 @@ fun TimelineItemReactionsView( userCanSendReaction = userCanSendReaction, expanded = expanded, isOutgoing = isOutgoing, - onReactionClick = onReactionClicked, - onReactionLongClick = onReactionLongClicked, - onMoreReactionsClick = onMoreReactionsClicked, + onReactionClick = onReactionClick, + onReactionLongClick = onReactionLongClick, + onMoreReactionsClick = onMoreReactionsClick, onToggleExpandClick = { expanded = !expanded }, ) } @@ -179,8 +179,8 @@ private fun ContentToPreview( ), userCanSendReaction = true, isOutgoing = isOutgoing, - onReactionClicked = {}, - onReactionLongClicked = {}, - onMoreReactionsClicked = {}, + onReactionClick = {}, + onReactionLongClick = {}, + onMoreReactionsClick = {}, ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt index 4193a9f131..d1e49a8c43 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemRow.kt @@ -45,7 +45,7 @@ internal fun TimelineItemRow( isLastOutgoingMessage: Boolean, focusedEventId: EventId?, onUserDataClick: (UserId) -> Unit, - onLinkClicked: (String) -> Unit, + onLinkClick: (String) -> Unit, onClick: (TimelineItem.Event) -> Unit, onLongClick: (TimelineItem.Event) -> Unit, inReplyToClick: (EventId) -> Unit, @@ -53,7 +53,7 @@ internal fun TimelineItemRow( onReactionLongClick: (key: String, TimelineItem.Event) -> Unit, onMoreReactionsClick: (TimelineItem.Event) -> Unit, onReadReceiptClick: (TimelineItem.Event) -> Unit, - onTimestampClicked: (TimelineItem.Event) -> Unit, + onTimestampClick: (TimelineItem.Event) -> Unit, onSwipeToReply: (TimelineItem.Event) -> Unit, eventSink: (TimelineEvents.EventFromTimelineItem) -> Unit, modifier: Modifier = Modifier @@ -99,13 +99,13 @@ internal fun TimelineItemRow( onClick = { onClick(timelineItem) }, onLongClick = { onLongClick(timelineItem) }, onUserDataClick = onUserDataClick, - onLinkClicked = onLinkClicked, + onLinkClick = onLinkClick, inReplyToClick = inReplyToClick, onReactionClick = onReactionClick, onReactionLongClick = onReactionLongClick, onMoreReactionsClick = onMoreReactionsClick, onReadReceiptClick = onReadReceiptClick, - onTimestampClicked = onTimestampClicked, + onTimestampClick = onTimestampClick, onSwipeToReply = { onSwipeToReply(timelineItem) }, eventSink = eventSink, ) @@ -122,8 +122,8 @@ internal fun TimelineItemRow( onLongClick = onLongClick, inReplyToClick = inReplyToClick, onUserDataClick = onUserDataClick, - onLinkClicked = onLinkClicked, - onTimestampClicked = onTimestampClicked, + onLinkClick = onLinkClick, + onTimestampClick = onTimestampClick, onReactionClick = onReactionClick, onReactionLongClick = onReactionLongClick, onMoreReactionsClick = onMoreReactionsClick, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemStateEventRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemStateEventRow.kt index e0132ea75f..04c5c227ed 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemStateEventRow.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemStateEventRow.kt @@ -79,7 +79,7 @@ fun TimelineItemStateEventRow( ) { TimelineItemEventContentView( content = event.content, - onLinkClicked = {}, + onLinkClick = {}, eventSink = eventSink, modifier = Modifier.defaultTimelineContentPadding() ) @@ -92,7 +92,7 @@ fun TimelineItemStateEventRow( receipts = event.readReceiptState.receipts, ), renderReadReceipts = renderReadReceipts, - onReadReceiptsClicked = { onReadReceiptsClick(event) }, + onReadReceiptsClick = { onReadReceiptsClick(event) }, ) } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionBottomSheet.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionBottomSheet.kt index 3fe739a592..face810bc7 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionBottomSheet.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/CustomReactionBottomSheet.kt @@ -31,7 +31,7 @@ import io.element.android.libraries.matrix.api.core.EventId @Composable fun CustomReactionBottomSheet( state: CustomReactionState, - onEmojiSelected: (EventId, Emoji) -> Unit, + onSelectEmoji: (EventId, Emoji) -> Unit, modifier: Modifier = Modifier, ) { val sheetState = rememberModalBottomSheetState() @@ -46,7 +46,7 @@ fun CustomReactionBottomSheet( if (target?.event?.eventId == null) return sheetState.hide(coroutineScope) { state.eventSink(CustomReactionEvents.DismissCustomReactionSheet) - onEmojiSelected(target.event.eventId, emoji) + onSelectEmoji(target.event.eventId, emoji) } } @@ -57,7 +57,7 @@ fun CustomReactionBottomSheet( modifier = modifier ) { EmojiPicker( - onEmojiSelected = ::onEmojiSelectedDismiss, + onSelectEmoji = ::onEmojiSelectedDismiss, emojibaseStore = target.emojibaseStore, selectedEmojis = state.selectedEmoji, modifier = Modifier.fillMaxSize(), diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/EmojiItem.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/EmojiItem.kt index 8b720ce390..25bc605e73 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/EmojiItem.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/EmojiItem.kt @@ -49,7 +49,7 @@ import io.element.android.libraries.ui.strings.CommonStrings fun EmojiItem( item: Emoji, isSelected: Boolean, - onEmojiSelected: (Emoji) -> Unit, + onSelectEmoji: (Emoji) -> Unit, modifier: Modifier = Modifier, emojiSize: TextUnit = 20.sp, ) { @@ -69,7 +69,7 @@ fun EmojiItem( .background(backgroundColor, CircleShape) .clickable( enabled = true, - onClick = { onEmojiSelected(item) }, + onClick = { onSelectEmoji(item) }, indication = rememberRipple(bounded = false, radius = emojiSize.toDp() / 2 + 10.dp), interactionSource = remember { MutableInteractionSource() } ) @@ -102,7 +102,7 @@ internal fun EmojiItemPreview() = ElementPreview { skins = null ), isSelected = isSelected, - onEmojiSelected = {}, + onSelectEmoji = {}, ) } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/EmojiPicker.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/EmojiPicker.kt index ddf524b656..3af55a6e93 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/EmojiPicker.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/EmojiPicker.kt @@ -53,7 +53,7 @@ import kotlinx.coroutines.launch @OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) @Composable fun EmojiPicker( - onEmojiSelected: (Emoji) -> Unit, + onSelectEmoji: (Emoji) -> Unit, emojibaseStore: EmojibaseStore, selectedEmojis: ImmutableSet, modifier: Modifier = Modifier, @@ -99,7 +99,7 @@ fun EmojiPicker( modifier = Modifier.aspectRatio(1f), item = item, isSelected = selectedEmojis.contains(item.unicode), - onEmojiSelected = onEmojiSelected, + onSelectEmoji = onSelectEmoji, emojiSize = 32.dp.toSp(), ) } @@ -112,7 +112,7 @@ fun EmojiPicker( @Composable internal fun EmojiPickerPreview() = ElementPreview { EmojiPicker( - onEmojiSelected = {}, + onSelectEmoji = {}, emojibaseStore = EmojibaseDatasource().load(LocalContext.current), selectedEmojis = persistentSetOf("😀", "😄", "😃"), modifier = Modifier.fillMaxWidth(), diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAudioView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAudioView.kt index d4a2dbabc9..d618fe4244 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAudioView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemAudioView.kt @@ -46,7 +46,7 @@ import io.element.android.libraries.designsystem.theme.components.Text @Composable fun TimelineItemAudioView( content: TimelineItemAudioContent, - onContentLayoutChanged: (ContentAvoidingLayoutData) -> Unit, + onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, modifier: Modifier = Modifier, ) { val iconSize = 32.dp @@ -85,7 +85,7 @@ fun TimelineItemAudioView( maxLines = 1, overflow = TextOverflow.Ellipsis, onTextLayout = ContentAvoidingLayout.measureLastTextLine( - onContentLayoutChanged = onContentLayoutChanged, + onContentLayoutChange = onContentLayoutChange, extraWidth = iconSize + spacing ) ) @@ -99,6 +99,6 @@ internal fun TimelineItemAudioViewPreview(@PreviewParameter(TimelineItemAudioCon ElementPreview { TimelineItemAudioView( content, - onContentLayoutChanged = {}, + onContentLayoutChange = {}, ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEncryptedView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEncryptedView.kt index b0753be9b1..8cb9c9e58f 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEncryptedView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEncryptedView.kt @@ -33,7 +33,7 @@ import io.element.android.libraries.ui.strings.CommonStrings @Composable fun TimelineItemEncryptedView( content: TimelineItemEncryptedContent, - onContentLayoutChanged: (ContentAvoidingLayoutData) -> Unit, + onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, modifier: Modifier = Modifier ) { val isMembershipUtd = (content.data as? UnableToDecryptContent.Data.MegolmV1AesSha2)?.utdCause == UtdCause.Membership @@ -46,7 +46,7 @@ fun TimelineItemEncryptedView( text = stringResource(id = textId), iconDescription = stringResource(id = CommonStrings.dialog_title_warning), iconResourceId = iconId, - onContentLayoutChanged = onContentLayoutChanged, + onContentLayoutChange = onContentLayoutChange, modifier = modifier ) } @@ -58,6 +58,6 @@ internal fun TimelineItemEncryptedViewPreview( ) = ElementPreview { TimelineItemEncryptedView( content = content, - onContentLayoutChanged = {}, + onContentLayoutChange = {}, ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt index 8663f60bba..09eeee22e0 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt @@ -43,32 +43,32 @@ import io.element.android.libraries.architecture.Presenter @Composable fun TimelineItemEventContentView( content: TimelineItemEventContent, - onLinkClicked: (url: String) -> Unit, + onLinkClick: (url: String) -> Unit, eventSink: (TimelineEvents.EventFromTimelineItem) -> Unit, modifier: Modifier = Modifier, - onContentLayoutChanged: (ContentAvoidingLayoutData) -> Unit = {}, + onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit = {}, ) { val presenterFactories = LocalTimelineItemPresenterFactories.current when (content) { is TimelineItemEncryptedContent -> TimelineItemEncryptedView( content = content, - onContentLayoutChanged = onContentLayoutChanged, + onContentLayoutChange = onContentLayoutChange, modifier = modifier ) is TimelineItemRedactedContent -> TimelineItemRedactedView( content = content, - onContentLayoutChanged = onContentLayoutChanged, + onContentLayoutChange = onContentLayoutChange, modifier = modifier ) is TimelineItemTextBasedContent -> TimelineItemTextView( content = content, modifier = modifier, - onLinkClicked = onLinkClicked, - onContentLayoutChanged = onContentLayoutChanged + onLinkClick = onLinkClick, + onContentLayoutChange = onContentLayoutChange ) is TimelineItemUnknownContent -> TimelineItemUnknownView( content = content, - onContentLayoutChanged = onContentLayoutChanged, + onContentLayoutChange = onContentLayoutChange, modifier = modifier ) is TimelineItemLocationContent -> TimelineItemLocationView( @@ -77,7 +77,7 @@ fun TimelineItemEventContentView( ) is TimelineItemImageContent -> TimelineItemImageView( content = content, - onContentLayoutChanged = onContentLayoutChanged, + onContentLayoutChange = onContentLayoutChange, modifier = modifier, ) is TimelineItemStickerContent -> TimelineItemStickerView( @@ -86,17 +86,17 @@ fun TimelineItemEventContentView( ) is TimelineItemVideoContent -> TimelineItemVideoView( content = content, - onContentLayoutChanged = onContentLayoutChanged, + onContentLayoutChange = onContentLayoutChange, modifier = modifier ) is TimelineItemFileContent -> TimelineItemFileView( content = content, - onContentLayoutChanged = onContentLayoutChanged, + onContentLayoutChange = onContentLayoutChange, modifier = modifier ) is TimelineItemAudioContent -> TimelineItemAudioView( content = content, - onContentLayoutChanged = onContentLayoutChanged, + onContentLayoutChange = onContentLayoutChange, modifier = modifier ) is TimelineItemLegacyCallInviteContent -> TimelineItemLegacyCallInviteView(modifier = modifier) @@ -114,7 +114,7 @@ fun TimelineItemEventContentView( TimelineItemVoiceView( state = presenter.present(), content = content, - onContentLayoutChanged = onContentLayoutChanged, + onContentLayoutChange = onContentLayoutChange, modifier = modifier ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemFileView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemFileView.kt index c30a3aa89b..bd395c01f1 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemFileView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemFileView.kt @@ -46,7 +46,7 @@ import io.element.android.libraries.designsystem.theme.components.Text @Composable fun TimelineItemFileView( content: TimelineItemFileContent, - onContentLayoutChanged: (ContentAvoidingLayoutData) -> Unit, + onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, modifier: Modifier = Modifier, ) { val iconSize = 32.dp @@ -86,7 +86,7 @@ fun TimelineItemFileView( maxLines = 1, overflow = TextOverflow.Ellipsis, onTextLayout = ContentAvoidingLayout.measureLastTextLine( - onContentLayoutChanged = onContentLayoutChanged, + onContentLayoutChange = onContentLayoutChange, extraWidth = iconSize + spacing ) ) @@ -99,6 +99,6 @@ fun TimelineItemFileView( internal fun TimelineItemFileViewPreview(@PreviewParameter(TimelineItemFileContentProvider::class) content: TimelineItemFileContent) = ElementPreview { TimelineItemFileView( content, - onContentLayoutChanged = {}, + onContentLayoutChange = {}, ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemImageView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemImageView.kt index c514b192d4..5ef2196e59 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemImageView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemImageView.kt @@ -67,7 +67,7 @@ import io.element.android.wysiwyg.compose.EditorStyledText @Composable fun TimelineItemImageView( content: TimelineItemImageContent, - onContentLayoutChanged: (ContentAvoidingLayoutData) -> Unit, + onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, modifier: Modifier = Modifier, ) { val description = stringResource(CommonStrings.common_image) @@ -115,7 +115,7 @@ fun TimelineItemImageView( text = caption, style = ElementRichTextEditorStyle.textStyle(), releaseOnDetach = false, - onTextLayout = ContentAvoidingLayout.measureLegacyLastTextLine(onContentLayoutChanged = onContentLayoutChanged), + onTextLayout = ContentAvoidingLayout.measureLegacyLastTextLine(onContentLayoutChange = onContentLayoutChange), ) } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemInformativeView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemInformativeView.kt index b6f5d0e23b..6d627cf360 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemInformativeView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemInformativeView.kt @@ -41,12 +41,12 @@ fun TimelineItemInformativeView( text: String, iconDescription: String, @DrawableRes iconResourceId: Int, - onContentLayoutChanged: (ContentAvoidingLayoutData) -> Unit, + onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, modifier: Modifier = Modifier ) { Row( modifier = modifier.onSizeChanged { size -> - onContentLayoutChanged( + onContentLayoutChange( ContentAvoidingLayoutData( contentWidth = size.width, contentHeight = size.height, @@ -78,6 +78,6 @@ internal fun TimelineItemInformativeViewPreview() = ElementPreview { text = "Info", iconDescription = "", iconResourceId = CompoundDrawables.ic_compound_delete, - onContentLayoutChanged = {}, + onContentLayoutChange = {}, ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemPollView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemPollView.kt index 47f4aa7da6..65c42c8934 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemPollView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemPollView.kt @@ -34,16 +34,16 @@ fun TimelineItemPollView( eventSink: (TimelineEvents.TimelineItemPollEvents) -> Unit, modifier: Modifier = Modifier, ) { - fun onAnswerSelected(pollStartId: EventId, answerId: String) { - eventSink(TimelineEvents.PollAnswerSelected(pollStartId, answerId)) + fun onSelectAnswer(pollStartId: EventId, answerId: String) { + eventSink(TimelineEvents.SelectPollAnswer(pollStartId, answerId)) } - fun onPollEnd(pollStartId: EventId) { - eventSink(TimelineEvents.PollEndClicked(pollStartId)) + fun onEndPoll(pollStartId: EventId) { + eventSink(TimelineEvents.EndPoll(pollStartId)) } - fun onPollEdit(pollStartId: EventId) { - eventSink(TimelineEvents.PollEditClicked(pollStartId)) + fun onEditPoll(pollStartId: EventId) { + eventSink(TimelineEvents.EditPoll(pollStartId)) } PollContentView( @@ -54,9 +54,9 @@ fun TimelineItemPollView( isPollEnded = content.isEnded, isPollEditable = content.isEditable, isMine = content.isMine, - onAnswerSelected = ::onAnswerSelected, - onPollEdit = ::onPollEdit, - onPollEnd = ::onPollEnd, + onSelectAnswer = ::onSelectAnswer, + onEditPoll = ::onEditPoll, + onEndPoll = ::onEndPoll, modifier = modifier, ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemRedactedView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemRedactedView.kt index 0c001fae26..8859f93ceb 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemRedactedView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemRedactedView.kt @@ -29,14 +29,14 @@ import io.element.android.libraries.ui.strings.CommonStrings @Composable fun TimelineItemRedactedView( @Suppress("UNUSED_PARAMETER") content: TimelineItemRedactedContent, - onContentLayoutChanged: (ContentAvoidingLayoutData) -> Unit, + onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, modifier: Modifier = Modifier ) { TimelineItemInformativeView( text = stringResource(id = CommonStrings.common_message_removed), iconDescription = stringResource(id = CommonStrings.common_message_removed), iconResourceId = CompoundDrawables.ic_compound_delete, - onContentLayoutChanged = onContentLayoutChanged, + onContentLayoutChange = onContentLayoutChange, modifier = modifier ) } @@ -46,6 +46,6 @@ fun TimelineItemRedactedView( internal fun TimelineItemRedactedViewPreview() = ElementPreview { TimelineItemRedactedView( TimelineItemRedactedContent, - onContentLayoutChanged = {}, + onContentLayoutChange = {}, ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemTextView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemTextView.kt index c7e1b37bb2..a72a6354ed 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemTextView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemTextView.kt @@ -39,9 +39,9 @@ import io.element.android.wysiwyg.compose.EditorStyledText @Composable fun TimelineItemTextView( content: TimelineItemTextBasedContent, - onLinkClicked: (String) -> Unit, + onLinkClick: (String) -> Unit, modifier: Modifier = Modifier, - onContentLayoutChanged: (ContentAvoidingLayoutData) -> Unit = {}, + onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit = {}, ) { CompositionLocalProvider( LocalContentColor provides ElementTheme.colors.textPrimary, @@ -53,9 +53,9 @@ fun TimelineItemTextView( Box(modifier.semantics { contentDescription = body.toString() }) { EditorStyledText( text = body, - onLinkClickedListener = onLinkClicked, + onLinkClickedListener = onLinkClick, style = ElementRichTextEditorStyle.textStyle(), - onTextLayout = ContentAvoidingLayout.measureLegacyLastTextLine(onContentLayoutChanged = onContentLayoutChanged), + onTextLayout = ContentAvoidingLayout.measureLegacyLastTextLine(onContentLayoutChange = onContentLayoutChange), releaseOnDetach = false, ) } @@ -69,6 +69,6 @@ internal fun TimelineItemTextViewPreview( ) = ElementPreview { TimelineItemTextView( content = content, - onLinkClicked = {}, + onLinkClick = {}, ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemUnknownView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemUnknownView.kt index 0c0961a2a6..d4d0b69427 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemUnknownView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemUnknownView.kt @@ -29,14 +29,14 @@ import io.element.android.libraries.ui.strings.CommonStrings @Composable fun TimelineItemUnknownView( @Suppress("UNUSED_PARAMETER") content: TimelineItemUnknownContent, - onContentLayoutChanged: (ContentAvoidingLayoutData) -> Unit, + onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, modifier: Modifier = Modifier ) { TimelineItemInformativeView( text = stringResource(id = CommonStrings.common_unsupported_event), iconDescription = stringResource(id = CommonStrings.dialog_title_warning), iconResourceId = CompoundDrawables.ic_compound_info_solid, - onContentLayoutChanged = onContentLayoutChanged, + onContentLayoutChange = onContentLayoutChange, modifier = modifier ) } @@ -46,6 +46,6 @@ fun TimelineItemUnknownView( internal fun TimelineItemUnknownViewPreview() = ElementPreview { TimelineItemUnknownView( content = TimelineItemUnknownContent, - onContentLayoutChanged = {}, + onContentLayoutChange = {}, ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVideoView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVideoView.kt index 6711c18118..5aea80356a 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVideoView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVideoView.kt @@ -73,7 +73,7 @@ import io.element.android.wysiwyg.compose.EditorStyledText @Composable fun TimelineItemVideoView( content: TimelineItemVideoContent, - onContentLayoutChanged: (ContentAvoidingLayoutData) -> Unit, + onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, modifier: Modifier = Modifier, ) { val description = stringResource(CommonStrings.common_image) @@ -131,7 +131,7 @@ fun TimelineItemVideoView( text = caption, style = ElementRichTextEditorStyle.textStyle(), releaseOnDetach = false, - onTextLayout = ContentAvoidingLayout.measureLegacyLastTextLine(onContentLayoutChanged = onContentLayoutChanged), + onTextLayout = ContentAvoidingLayout.measureLegacyLastTextLine(onContentLayoutChange = onContentLayoutChange), ) } } 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 927e38371c..19fb526b87 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 @@ -67,7 +67,7 @@ import kotlinx.coroutines.delay fun TimelineItemVoiceView( state: VoiceMessageState, content: TimelineItemVoiceContent, - onContentLayoutChanged: (ContentAvoidingLayoutData) -> Unit, + onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, modifier: Modifier = Modifier, ) { fun playPause() { @@ -81,7 +81,7 @@ fun TimelineItemVoiceView( contentDescription = a11y } .onSizeChanged { - onContentLayoutChanged( + onContentLayoutChange( ContentAvoidingLayoutData( contentWidth = it.width, contentHeight = it.height, @@ -258,7 +258,7 @@ internal fun TimelineItemVoiceViewPreview( TimelineItemVoiceView( state = timelineItemVoiceViewParameters.state, content = timelineItemVoiceViewParameters.content, - onContentLayoutChanged = {}, + onContentLayoutChange = {}, ) } @@ -271,7 +271,7 @@ internal fun TimelineItemVoiceViewUnifiedPreview() = ElementPreview { TimelineItemVoiceView( state = it.state, content = it.content, - onContentLayoutChanged = {}, + onContentLayoutChange = {}, ) } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/layout/ContentAvoidingLayout.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/layout/ContentAvoidingLayout.kt index 334899288d..4ff8c66e8b 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/layout/ContentAvoidingLayout.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/layout/ContentAvoidingLayout.kt @@ -136,26 +136,26 @@ interface ContentAvoidingLayoutScope { /** * It should be called when the content layout changes, so it can update the [ContentAvoidingLayoutData] and measure and layout the content properly. */ - fun onContentLayoutChanged(data: ContentAvoidingLayoutData) + fun onContentLayoutChange(data: ContentAvoidingLayoutData) } private class ContentAvoidingLayoutScopeInstance( val data: MutableState = mutableStateOf(ContentAvoidingLayoutData()), ) : ContentAvoidingLayoutScope { - override fun onContentLayoutChanged(data: ContentAvoidingLayoutData) { + override fun onContentLayoutChange(data: ContentAvoidingLayoutData) { this.data.value = data } } object ContentAvoidingLayout { /** - * Measures the last line of a [TextLayoutResult] and calls [onContentLayoutChanged] with the [ContentAvoidingLayoutData]. + * Measures the last line of a [TextLayoutResult] and calls [onContentLayoutChange] with the [ContentAvoidingLayoutData]. * * This is supposed to be used in the `onTextLayout` parameter of a Text based component. */ @Composable internal fun measureLastTextLine( - onContentLayoutChanged: (ContentAvoidingLayoutData) -> Unit, + onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, extraWidth: Dp = 0.dp, ): ((TextLayoutResult) -> Unit) { val layoutDirection = LocalLayoutDirection.current @@ -167,7 +167,7 @@ object ContentAvoidingLayout { LayoutDirection.Rtl -> textLayout.getLineLeft(textLayout.lineCount - 1).roundToInt() } val lastLineHeight = textLayout.getLineBottom(textLayout.lineCount - 1).roundToInt() - onContentLayoutChanged( + onContentLayoutChange( ContentAvoidingLayoutData( contentWidth = textLayout.size.width + extraWidthPx, contentHeight = textLayout.size.height, @@ -179,13 +179,13 @@ object ContentAvoidingLayout { } /** - * Measures the last line of a [Layout] and calls [onContentLayoutChanged] with the [ContentAvoidingLayoutData]. + * Measures the last line of a [Layout] and calls [onContentLayoutChange] with the [ContentAvoidingLayoutData]. * * This is supposed to be used in the `onTextLayout` parameter of an [EditorStyledText] component. */ @Composable internal fun measureLegacyLastTextLine( - onContentLayoutChanged: (ContentAvoidingLayoutData) -> Unit, + onContentLayoutChange: (ContentAvoidingLayoutData) -> Unit, extraWidth: Dp = 0.dp, ): ((Layout) -> Unit) { val extraWidthPx = extraWidth.roundToPx() @@ -193,7 +193,7 @@ object ContentAvoidingLayout { // We need to add the external extra width so it's not taken into account as 'free space' val lastLineWidth = textLayout.getLineWidth(textLayout.lineCount - 1).roundToInt() val lastLineHeight = textLayout.getLineBottom(textLayout.lineCount - 1) - onContentLayoutChanged( + onContentLayoutChange( ContentAvoidingLayoutData( contentWidth = textLayout.width + extraWidthPx, contentHeight = textLayout.height, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/TimelineItemReadReceiptView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/TimelineItemReadReceiptView.kt index 540bff9a2b..3bce2b389b 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/TimelineItemReadReceiptView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/TimelineItemReadReceiptView.kt @@ -61,7 +61,7 @@ import kotlinx.collections.immutable.ImmutableList fun TimelineItemReadReceiptView( state: ReadReceiptViewState, renderReadReceipts: Boolean, - onReadReceiptsClicked: () -> Unit, + onReadReceiptsClick: () -> Unit, modifier: Modifier = Modifier, ) { if (state.receipts.isNotEmpty()) { @@ -73,7 +73,7 @@ fun TimelineItemReadReceiptView( .testTag(TestTags.messageReadReceipts) .clip(RoundedCornerShape(4.dp)) .clickable { - onReadReceiptsClicked() + onReadReceiptsClick() } .padding(2.dp) ) @@ -213,6 +213,6 @@ internal fun TimelineItemReadReceiptViewPreview( TimelineItemReadReceiptView( state = state, renderReadReceipts = true, - onReadReceiptsClicked = {}, + onReadReceiptsClick = {}, ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/bottomsheet/ReadReceiptBottomSheet.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/bottomsheet/ReadReceiptBottomSheet.kt index be63fa3409..1062e11977 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/bottomsheet/ReadReceiptBottomSheet.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/receipt/bottomsheet/ReadReceiptBottomSheet.kt @@ -47,7 +47,7 @@ import kotlinx.coroutines.launch @Composable internal fun ReadReceiptBottomSheet( state: ReadReceiptBottomSheetState, - onUserDataClicked: (UserId) -> Unit, + onUserDataClick: (UserId) -> Unit, modifier: Modifier = Modifier, ) { val isVisible = state.selectedEvent != null @@ -69,11 +69,11 @@ internal fun ReadReceiptBottomSheet( ) { ReadReceiptBottomSheetContent( state = state, - onUserDataClicked = { + onUserDataClick = { coroutineScope.launch { sheetState.hide() state.eventSink(ReadReceiptBottomSheetEvents.Dismiss) - onUserDataClicked.invoke(it) + onUserDataClick.invoke(it) } }, ) @@ -86,7 +86,7 @@ internal fun ReadReceiptBottomSheet( @Composable private fun ReadReceiptBottomSheetContent( state: ReadReceiptBottomSheetState, - onUserDataClicked: (UserId) -> Unit, + onUserDataClick: (UserId) -> Unit, ) { LazyColumn { item { @@ -101,7 +101,7 @@ private fun ReadReceiptBottomSheetContent( ) { val userId = UserId(it.avatarData.id) MatrixUserRow( - modifier = Modifier.clickable { onUserDataClicked(userId) }, + modifier = Modifier.clickable { onUserDataClick(userId) }, matrixUser = MatrixUser( userId = userId, displayName = it.avatarData.name, @@ -127,7 +127,7 @@ internal fun ReadReceiptBottomSheetPreview(@PreviewParameter(ReadReceiptBottomSh Column { ReadReceiptBottomSheetContent( state = state, - onUserDataClicked = {}, + onUserDataClick = {}, ) } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/debug/EventDebugInfoNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/debug/EventDebugInfoNode.kt index 6f53c5a306..824ef24f8b 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/debug/EventDebugInfoNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/debug/EventDebugInfoNode.kt @@ -42,7 +42,7 @@ class EventDebugInfoNode @AssistedInject constructor( private val inputs = inputs() - private fun onBackPressed() { + private fun onBackClick() { navigateUp() } @@ -53,7 +53,7 @@ class EventDebugInfoNode @AssistedInject constructor( model = timelineItemDebugInfo.model, originalJson = timelineItemDebugInfo.originalJson, latestEditedJson = timelineItemDebugInfo.latestEditedJson, - onBackPressed = ::onBackPressed + onBackClick = ::onBackClick ) } } 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 acff89e97c..0a893c1cea 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 @@ -73,7 +73,7 @@ fun EventDebugInfoView( model: String, originalJson: String?, latestEditedJson: String?, - onBackPressed: () -> Unit, + onBackClick: () -> Unit, modifier: Modifier = Modifier, isTest: Boolean = false, ) { @@ -87,7 +87,7 @@ fun EventDebugInfoView( style = ElementTheme.typography.aliasScreenTitle, ) }, - navigationIcon = { BackButton(onClick = onBackPressed) } + navigationIcon = { BackButton(onClick = onBackClick) } ) }, modifier = modifier @@ -190,6 +190,6 @@ internal fun EventDebugInfoViewPreview() = ElementPreview { model = "Rust(\n\tModel()\n)", originalJson = "{\"name\": \"original\"}", latestEditedJson = "{\"name\": \"edited\"}", - onBackPressed = { } + onBackClick = { } ) } 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 2aba9c3669..5c0623b480 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 @@ -268,12 +268,16 @@ class TimelineItemContentMessageFactory @Inject constructor( } // Find and set as URLSpans any links present in the text LinkifyCompat.addLinks(this, Linkify.WEB_URLS or Linkify.PHONE_NUMBERS or Linkify.EMAIL_ADDRESSES) - // Restore old spans if they don't conflict with the new ones + // Restore old spans, remove new ones if there is a conflict for ((urlSpan, location) in oldURLSpans) { val (start, end) = location - if (getSpans(start, end).isEmpty()) { - setSpan(urlSpan, start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + val addedSpans = getSpans(start, end).orEmpty() + if (addedSpans.isNotEmpty()) { + for (span in addedSpans) { + removeSpan(span) + } } + setSpan(urlSpan, start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) } return this } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/typing/MessagesViewWithTypingPreview.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/typing/MessagesViewWithTypingPreview.kt index 3b09afb7fc..c4a8a88153 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/typing/MessagesViewWithTypingPreview.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/typing/MessagesViewWithTypingPreview.kt @@ -30,14 +30,14 @@ internal fun MessagesViewWithTypingPreview( ) = ElementPreview { MessagesView( state = aMessagesState().copy(typingNotificationState = typingState), - onBackPressed = {}, - onRoomDetailsClicked = {}, - onEventClicked = { false }, + onBackClick = {}, + onRoomDetailsClick = {}, + onEventClick = { false }, onPreviewAttachments = {}, - onUserDataClicked = {}, - onLinkClicked = {}, - onSendLocationClicked = {}, - onCreatePollClicked = {}, - onJoinCallClicked = {}, + onUserDataClick = {}, + onLinkClick = {}, + onSendLocationClick = {}, + onCreatePollClick = {}, + onJoinCallClick = {}, ) } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessagePermissionRationaleDialog.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessagePermissionRationaleDialog.kt index 9898aba95a..73da921488 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessagePermissionRationaleDialog.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessagePermissionRationaleDialog.kt @@ -29,7 +29,7 @@ internal fun VoiceMessagePermissionRationaleDialog( ) { ConfirmationDialog( content = stringResource(CommonStrings.error_missing_microphone_voice_rationale_android, appName), - onSubmitClicked = onContinue, + onSubmitClick = onContinue, onDismiss = onDismiss, submitText = stringResource(CommonStrings.action_continue), cancelText = stringResource(CommonStrings.action_cancel), diff --git a/features/messages/impl/src/main/res/values-es/translations.xml b/features/messages/impl/src/main/res/values-es/translations.xml index 2f4fdca67d..8404cc6ec6 100644 --- a/features/messages/impl/src/main/res/values-es/translations.xml +++ b/features/messages/impl/src/main/res/values-es/translations.xml @@ -41,4 +41,13 @@ "%1$d cambio en la sala" "%1$d cambios en la sala" + + "%1$s, %2$s y %3$d otro" + "%1$s, %2$s y %3$d otros" + + + "%1$s está escribiendo" + "%1$s están escribiendo" + + "%1$s y %2$s" diff --git a/features/messages/impl/src/main/res/values-ka/translations.xml b/features/messages/impl/src/main/res/values-ka/translations.xml new file mode 100644 index 0000000000..080696776f --- /dev/null +++ b/features/messages/impl/src/main/res/values-ka/translations.xml @@ -0,0 +1,42 @@ + + + "აქტივობები" + "დროშები" + "Საჭმელ-სასმელი" + "ცხოველები & ბუნება" + "ობიექტები" + "ღიმილები & ხალხი" + "მოგზაურობა და ადგილები" + "სიმბოლოები" + "მომხმარებლის დაბლოკვა" + "შეამოწმეთ, გსურთ თუ არა ამ მომხმარებლის ყველა მიმდინარე და მომავალი შეტყობინების დამალვა" + "ეს შეტყობინება გაგზავნილი იქნება თქვენი სახლის სერვერის ადმინისტრატორისადმი. მას არ ექნება დაშიფვრული შეტყობინებების წაკითხვის შესაძლებლობა." + "ამ კონტენტის დარეპორტების მიზეზი" + "კამერა" + "ფოტოს გადაღება" + "ვიდეოს ჩაწერა" + "დანართი" + "ფოტოსა და ვიდეოს ბიბლიოთეკა" + "ადგილმდებარეობა" + "გამოკითხვა" + "ტექსტის ფორმატირება" + "შეტყობინებების ისტორია ამჟამად მიუწვდომელია." + "გსურთ მათი კვლავ მოწვევა?" + "თქვენ მარტო ხართ ამ ჩატში" + "ყველა" + "Ხელახლა გაგზავნა" + "თქვენი შეტყობინების გაგზავნა ვერ მოხერხდა" + "ემოჯის დამატება" + "ეს არის %1$s-ს დასაწყისი." + "ეს არის ამ საუბრის დასაწყისი." + "ნაკლების ჩვენება" + "შეტყობინება დაკოპირდა" + "თქვენ არ გაქვთ ამ ოთახში გამოქვეყნების ნებართვა" + "ნაკლების ჩვენება" + "მეტის ჩვენება" + "ახალი" + + "%1$dოთახის ცვლილება" + "%1$dოთახის ცვლილებები" + + diff --git a/features/messages/impl/src/main/res/values-pt-rBR/translations.xml b/features/messages/impl/src/main/res/values-pt-rBR/translations.xml new file mode 100644 index 0000000000..0dc4293f2d --- /dev/null +++ b/features/messages/impl/src/main/res/values-pt-rBR/translations.xml @@ -0,0 +1,53 @@ + + + "Atividades" + "Bandeiras" + "Comidas e Bebidas" + "Animais e Natureza" + "Objetos" + "Caras e Pessoas" + "Viagens e Lugares" + "Símbolos" + "Bloquear utilizador" + "Ativar para esconder todas as atuais e futuras mensagens deste utilizador" + "Esta mensagem será denunciada ao administrador do teu servidor. Porém, não lhe será possível ler quaisquer mensagens cifradas." + "Razão de denúncia" + "Câmara" + "Tirar foto" + "Gravar vídeo" + "Anexo" + "Biblioteca de fotos e vídeos" + "Localização" + "Sondagem" + "Formatação de texto" + "De momento, o histórico de mensagens está indisponível." + "O histórico de mensagens não está disponível nesta sala. Verifica este dispositivo para veres o histórico." + "Gostarias de convidá-lo de volta?" + "Estás sozinho nesta conversa" + "Notificar toda a sala" + "Toda a gente" + "Enviar novamente" + "Falha ao enviar a tua mensagem" + "Adicionar emoji" + "%1$s começou aqui." + "Esta conversa começou aqui." + "Mostrar menos" + "Mensagem copiada" + "Não tens permissão para publicar nesta sala" + "Mostrar menos" + "Mostrar mais" + "Novas" + + "%1$d alteração à sala" + "%1$d alterações à sala" + + + "%1$s, %2$s e %3$d outro" + "%1$s, %2$s e %3$d outros" + + + "%1$s está a escrever" + "%1$s estão a escrever" + + "%1$s e %2$s" + diff --git a/features/messages/impl/src/main/res/values-ro/translations.xml b/features/messages/impl/src/main/res/values-ro/translations.xml index 98520ece20..9b44893ce2 100644 --- a/features/messages/impl/src/main/res/values-ro/translations.xml +++ b/features/messages/impl/src/main/res/values-ro/translations.xml @@ -48,9 +48,9 @@ "%1$s, %2$s și încă %3$d" - "%1$s scrie" - "%1$s scriu" - "%1$s scriu" + "%1$s tastează" + "%1$s tastează" + "%1$s tastează" "%1$s și %2$s" diff --git a/features/messages/impl/src/main/res/values-zh/translations.xml b/features/messages/impl/src/main/res/values-zh/translations.xml new file mode 100644 index 0000000000..8e2e049453 --- /dev/null +++ b/features/messages/impl/src/main/res/values-zh/translations.xml @@ -0,0 +1,50 @@ + + + "活动" + "旗帜" + "食物和饮料" + "动物和自然" + "物品" + "表情和人物" + "旅行和地点" + "符号" + "封禁用户" + "请确认是否要隐藏该用户当前和未来的所有信息" + "此消息将举报给您的主服务器管理员。他们无法读取任何加密消息。" + "举报此内容的原因" + "相机" + "拍摄照片" + "录制视频" + "附件" + "照片和视频库" + "位置" + "投票" + "文本格式化" + "消息历史记录当前不可用。" + "此聊天室无法查看消息历史记录。请验证此设备以查看之。" + "您想邀请他们回来吗?" + "聊天中只有你一个人" + "通知整个房间" + "所有人" + "重新发送" + "消息发送失败" + "添加表情符号" + "这是 %1$s 聊天室的开始。" + "这是本对话的开始。" + "折叠" + "消息已复制" + "您无权在此房间发言" + "折叠" + "展开" + "新消息" + + "%1$d 聊天室变更" + + + "%1$s,%2$s 和其他 %3$d 个人" + + + "%1$s 正在输入" + + "%1$s 和 %2$s" + diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/FakeMessagesNavigator.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/FakeMessagesNavigator.kt index de2b2e5bf5..466acfa192 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/FakeMessagesNavigator.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/FakeMessagesNavigator.kt @@ -33,19 +33,19 @@ class FakeMessagesNavigator : MessagesNavigator { var onEditPollClickedCount = 0 private set - override fun onShowEventDebugInfoClicked(eventId: EventId?, debugInfo: TimelineItemDebugInfo) { + override fun onShowEventDebugInfoClick(eventId: EventId?, debugInfo: TimelineItemDebugInfo) { onShowEventDebugInfoClickedCount++ } - override fun onForwardEventClicked(eventId: EventId) { + override fun onForwardEventClick(eventId: EventId) { onForwardEventClickedCount++ } - override fun onReportContentClicked(eventId: EventId, senderId: UserId) { + override fun onReportContentClick(eventId: EventId, senderId: UserId) { onReportContentClickedCount++ } - override fun onEditPollClicked(eventId: EventId) { + override fun onEditPollClick(eventId: EventId) { onEditPollClickedCount++ } } 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 3adc7059b0..14eb3616ad 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 @@ -69,12 +69,14 @@ import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState import io.element.android.libraries.matrix.api.room.MessageEventType import io.element.android.libraries.matrix.api.room.RoomMembershipState +import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState import io.element.android.libraries.matrix.api.user.CurrentSessionIdHolder 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_ID import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_SESSION_ID_2 +import io.element.android.libraries.matrix.test.A_TRANSACTION_ID import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.matrix.test.permalink.FakePermalinkBuilder @@ -456,6 +458,31 @@ class MessagesPresenterTest { } } + @Test + fun `present - handle action redact message in error, in this case the message is just cancelled`() = runTest { + val coroutineDispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true) + val matrixRoom = FakeMatrixRoom() + val presenter = createMessagesPresenter(matrixRoom = matrixRoom, coroutineDispatchers = coroutineDispatchers) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + skipItems(1) + val initialState = awaitItem() + initialState.eventSink.invoke( + MessagesEvents.HandleAction( + action = TimelineItemAction.Redact, + event = aMessageEvent( + transactionId = A_TRANSACTION_ID, + sendState = LocalEventSendState.SendingFailed("Failed to send message") + ) + ) + ) + assertThat(matrixRoom.cancelSendCount).isEqualTo(1) + assertThat(matrixRoom.redactEventEventIdParam).isNull() + assertThat(awaitItem().actionListState.target).isEqualTo(ActionListState.Target.None) + } + } + @Test fun `present - handle action report content`() = runTest { val navigator = FakeMessagesNavigator() @@ -511,7 +538,7 @@ class MessagesPresenterTest { // Initially the composer doesn't have focus, so we don't show the alert assertThat(initialState.showReinvitePrompt).isFalse() // When the input field is focused we show the alert - initialState.composerState.richTextEditorState.requestFocus() + initialState.composerState.textEditorState.requestFocus() val focusedState = consumeItemsUntilPredicate(timeout = 250.milliseconds) { state -> state.showReinvitePrompt }.last() @@ -534,7 +561,7 @@ class MessagesPresenterTest { }.test { val initialState = awaitFirstItem() assertThat(initialState.showReinvitePrompt).isFalse() - initialState.composerState.richTextEditorState.requestFocus() + initialState.composerState.textEditorState.requestFocus() val focusedState = awaitItem() assertThat(focusedState.showReinvitePrompt).isFalse() } @@ -549,7 +576,7 @@ class MessagesPresenterTest { }.test { val initialState = awaitFirstItem() assertThat(initialState.showReinvitePrompt).isFalse() - initialState.composerState.richTextEditorState.requestFocus() + initialState.composerState.textEditorState.requestFocus() val focusedState = awaitItem() assertThat(focusedState.showReinvitePrompt).isFalse() } @@ -754,7 +781,7 @@ class MessagesPresenterTest { ): MessagesPresenter { val mediaSender = MediaSender(FakeMediaPreProcessor(), matrixRoom) val permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter) - val appPreferencesStore = InMemoryAppPreferencesStore(isRichTextEditorEnabled = true) + val appPreferencesStore = InMemoryAppPreferencesStore() val sessionPreferencesStore = InMemorySessionPreferencesStore() val messageComposerPresenter = MessageComposerPresenter( appCoroutineScope = this, @@ -773,7 +800,10 @@ class MessagesPresenterTest { permalinkParser = FakePermalinkParser(), permalinkBuilder = FakePermalinkBuilder(), timelineController = TimelineController(matrixRoom), - ) + ).apply { + showTextFormatting = true + isTesting = true + } val voiceMessageComposerPresenter = VoiceMessageComposerPresenter( this, FakeVoiceRecorder(), @@ -826,7 +856,6 @@ class MessagesPresenterTest { messageSummaryFormatter = FakeMessageSummaryFormatter(), navigator = navigator, clipboardHelper = clipboardHelper, - appPreferencesStore = appPreferencesStore, featureFlagsService = FakeFeatureFlagService(), buildMeta = aBuildMeta(), dispatchers = coroutineDispatchers, diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt index e176e20805..dd64f0b967 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesViewTest.kt @@ -88,7 +88,7 @@ class MessagesViewTest { ensureCalledOnce { callback -> rule.setMessagesView( state = state, - onBackPressed = callback, + onBackClick = callback, ) rule.pressBack() } @@ -103,7 +103,7 @@ class MessagesViewTest { ensureCalledOnce { callback -> rule.setMessagesView( state = state, - onRoomDetailsClicked = callback, + onRoomDetailsClick = callback, ) rule.onNodeWithText(state.roomName.dataOrNull().orEmpty()).performClick() } @@ -118,7 +118,7 @@ class MessagesViewTest { ensureCalledOnce { callback -> rule.setMessagesView( state = state, - onJoinCallClicked = callback, + onJoinCallClick = callback, ) val joinCallContentDescription = rule.activity.getString(CommonStrings.a11y_start_call) rule.onNodeWithContentDescription(joinCallContentDescription).performClick() @@ -138,7 +138,7 @@ class MessagesViewTest { ) rule.setMessagesView( state = state, - onEventClicked = callback, + onEventClick = callback, ) // Cannot perform click on "Text", it's not detected. Use tag instead rule.onAllNodesWithTag(TestTags.messageBubble.value).onFirst().performClick() @@ -287,7 +287,7 @@ class MessagesViewTest { ensureCalledOnce { callback -> rule.setMessagesView( state = state, - onSendLocationClicked = callback, + onSendLocationClick = callback, ) rule.clickOn(R.string.screen_room_attachment_source_location) } @@ -305,7 +305,7 @@ class MessagesViewTest { ensureCalledOnce { callback -> rule.setMessagesView( state = state, - onCreatePollClicked = callback, + onCreatePollClick = callback, ) // Then click on the poll action rule.clickOn(R.string.screen_room_attachment_source_poll) @@ -324,7 +324,7 @@ class MessagesViewTest { ) { callback -> rule.setMessagesView( state = state, - onUserDataClicked = callback, + onUserDataClick = callback, ) rule.onNodeWithTag(TestTags.timelineItemSenderInfo.value).performClick() } @@ -474,30 +474,30 @@ class MessagesViewTest { private fun AndroidComposeTestRule.setMessagesView( state: MessagesState, - onBackPressed: () -> Unit = EnsureNeverCalled(), - onRoomDetailsClicked: () -> Unit = EnsureNeverCalled(), - onEventClicked: (event: TimelineItem.Event) -> Boolean = EnsureNeverCalledWithParamAndResult(), - onUserDataClicked: (UserId) -> Unit = EnsureNeverCalledWithParam(), - onLinkClicked: (String) -> Unit = EnsureNeverCalledWithParam(), + onBackClick: () -> Unit = EnsureNeverCalled(), + onRoomDetailsClick: () -> Unit = EnsureNeverCalled(), + onEventClick: (event: TimelineItem.Event) -> Boolean = EnsureNeverCalledWithParamAndResult(), + onUserDataClick: (UserId) -> Unit = EnsureNeverCalledWithParam(), + onLinkClick: (String) -> Unit = EnsureNeverCalledWithParam(), onPreviewAttachments: (ImmutableList) -> Unit = EnsureNeverCalledWithParam(), - onSendLocationClicked: () -> Unit = EnsureNeverCalled(), - onCreatePollClicked: () -> Unit = EnsureNeverCalled(), - onJoinCallClicked: () -> Unit = EnsureNeverCalled(), + onSendLocationClick: () -> Unit = EnsureNeverCalled(), + onCreatePollClick: () -> Unit = EnsureNeverCalled(), + onJoinCallClick: () -> Unit = EnsureNeverCalled(), ) { setContent { // Cannot use the RichTextEditor, so simulate a LocalInspectionMode CompositionLocalProvider(LocalInspectionMode provides true) { MessagesView( state = state, - onBackPressed = onBackPressed, - onRoomDetailsClicked = onRoomDetailsClicked, - onEventClicked = onEventClicked, - onUserDataClicked = onUserDataClicked, - onLinkClicked = onLinkClicked, + onBackClick = onBackClick, + onRoomDetailsClick = onRoomDetailsClick, + onEventClick = onEventClick, + onUserDataClick = onUserDataClick, + onLinkClick = onLinkClick, onPreviewAttachments = onPreviewAttachments, - onSendLocationClicked = onSendLocationClicked, - onCreatePollClicked = onCreatePollClicked, - onJoinCallClicked = onJoinCallClicked, + onSendLocationClick = onSendLocationClick, + onCreatePollClick = onCreatePollClick, + onJoinCallClick = onJoinCallClick, ) } } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/MessageEventFixtures.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/MessageEventFixtures.kt index b959ff151f..b6a605bdbf 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/MessageEventFixtures.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/fixtures/MessageEventFixtures.kt @@ -28,6 +28,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.TransactionId import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo import io.element.android.libraries.matrix.api.timeline.item.event.LocalEventSendState import io.element.android.libraries.matrix.test.AN_EVENT_ID @@ -38,6 +39,7 @@ import kotlinx.collections.immutable.toImmutableList internal fun aMessageEvent( eventId: EventId? = AN_EVENT_ID, + transactionId: TransactionId? = null, isMine: Boolean = true, isEditable: Boolean = true, content: TimelineItemEventContent = TimelineItemTextContent(body = A_MESSAGE, htmlDocument = null, formattedBody = null, isEdited = false), @@ -48,6 +50,7 @@ internal fun aMessageEvent( ) = TimelineItem.Event( id = eventId?.value.orEmpty(), eventId = eventId, + transactionId = transactionId, senderId = A_USER_ID, senderProfile = aProfileTimelineDetailsReady(displayName = A_USER_NAME), senderAvatar = AvatarData(A_USER_ID.value, A_USER_NAME, size = AvatarSize.TimelineSender), diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenterTest.kt index 7274027059..f0bf4c42ef 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenterTest.kt @@ -26,7 +26,6 @@ import app.cash.turbine.ReceiveTurbine import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import im.vector.app.features.analytics.plan.Composer -import io.element.android.features.messages.impl.mentions.MentionSuggestion import io.element.android.features.messages.impl.messagecomposer.AttachmentsState import io.element.android.features.messages.impl.messagecomposer.MessageComposerContextImpl import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents @@ -77,10 +76,11 @@ import io.element.android.libraries.permissions.api.PermissionsPresenter import io.element.android.libraries.permissions.test.FakePermissionsPresenter import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore -import io.element.android.libraries.textcomposer.model.Message +import io.element.android.libraries.textcomposer.mentions.ResolvedMentionSuggestion import io.element.android.libraries.textcomposer.model.MessageComposerMode import io.element.android.libraries.textcomposer.model.Suggestion import io.element.android.libraries.textcomposer.model.SuggestionType +import io.element.android.libraries.textcomposer.model.TextEditorState import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.lambda.any @@ -127,7 +127,7 @@ class MessageComposerPresenterTest { }.test { val initialState = awaitFirstItem() assertThat(initialState.isFullScreen).isFalse() - assertThat(initialState.richTextEditorState.messageHtml).isEqualTo("") + assertThat(initialState.textEditorState.messageHtml()).isEqualTo("") assertThat(initialState.mode).isEqualTo(MessageComposerMode.Normal) assertThat(initialState.showAttachmentSourcePicker).isFalse() assertThat(initialState.canShareLocation).isTrue() @@ -158,10 +158,10 @@ class MessageComposerPresenterTest { presenter.present() }.test { val initialState = awaitFirstItem() - initialState.richTextEditorState.setHtml(A_MESSAGE) - assertThat(initialState.richTextEditorState.messageHtml).isEqualTo(A_MESSAGE) - initialState.richTextEditorState.setHtml("") - assertThat(initialState.richTextEditorState.messageHtml).isEqualTo("") + initialState.textEditorState.setHtml(A_MESSAGE) + assertThat(initialState.textEditorState.messageHtml()).isEqualTo(A_MESSAGE) + initialState.textEditorState.setHtml("") + assertThat(initialState.textEditorState.messageHtml()).isEqualTo("") } } @@ -170,7 +170,7 @@ class MessageComposerPresenterTest { val presenter = createPresenter(this) moleculeFlow(RecompositionMode.Immediate) { val state = presenter.present() - remember(state, state.richTextEditorState.messageHtml) { state } + remember(state, state.textEditorState.messageHtml()) { state } }.test { var state = awaitFirstItem() val mode = anEditMode() @@ -178,11 +178,11 @@ class MessageComposerPresenterTest { state = awaitItem() assertThat(state.mode).isEqualTo(mode) state = awaitItem() - assertThat(state.richTextEditorState.messageHtml).isEqualTo(A_MESSAGE) + assertThat(state.textEditorState.messageHtml()).isEqualTo(A_MESSAGE) state = backToNormalMode(state, skipCount = 1) // The message that was being edited is cleared - assertThat(state.richTextEditorState.messageHtml).isEqualTo("") + assertThat(state.textEditorState.messageHtml()).isEqualTo("") } } @@ -197,7 +197,7 @@ class MessageComposerPresenterTest { state.eventSink.invoke(MessageComposerEvents.SetMode(mode)) state = awaitItem() assertThat(state.mode).isEqualTo(mode) - assertThat(state.richTextEditorState.messageHtml).isEqualTo("") + assertThat(state.textEditorState.messageHtml()).isEqualTo("") backToNormalMode(state) } } @@ -213,11 +213,11 @@ class MessageComposerPresenterTest { state.eventSink.invoke(MessageComposerEvents.SetMode(mode)) state = awaitItem() assertThat(state.mode).isEqualTo(mode) - state.richTextEditorState.setHtml(A_REPLY) + state.textEditorState.setHtml(A_REPLY) state = backToNormalMode(state) // The message typed while replying is not cleared - assertThat(state.richTextEditorState.messageHtml).isEqualTo(A_REPLY) + assertThat(state.textEditorState.messageHtml()).isEqualTo(A_REPLY) } } @@ -232,25 +232,54 @@ class MessageComposerPresenterTest { state.eventSink.invoke(MessageComposerEvents.SetMode(mode)) state = awaitItem() assertThat(state.mode).isEqualTo(mode) - assertThat(state.richTextEditorState.messageHtml).isEqualTo("") + assertThat(state.textEditorState.messageHtml()).isEqualTo("") backToNormalMode(state) } } @Test - fun `present - send message`() = runTest { + fun `present - send message with rich text enabled`() = runTest { val presenter = createPresenter(this) moleculeFlow(RecompositionMode.Immediate) { val state = presenter.present() - remember(state, state.richTextEditorState.messageHtml) { state } + remember(state, state.textEditorState.messageHtml()) { state } }.test { val initialState = awaitFirstItem() - initialState.richTextEditorState.setHtml(A_MESSAGE) + initialState.textEditorState.setHtml(A_MESSAGE) val withMessageState = awaitItem() - assertThat(withMessageState.richTextEditorState.messageHtml).isEqualTo(A_MESSAGE) - withMessageState.eventSink.invoke(MessageComposerEvents.SendMessage(A_MESSAGE.toMessage())) + assertThat(withMessageState.textEditorState.messageHtml()).isEqualTo(A_MESSAGE) + withMessageState.eventSink.invoke(MessageComposerEvents.SendMessage) val messageSentState = awaitItem() - assertThat(messageSentState.richTextEditorState.messageHtml).isEqualTo("") + assertThat(messageSentState.textEditorState.messageHtml()).isEqualTo("") + waitForPredicate { analyticsService.capturedEvents.size == 1 } + assertThat(analyticsService.capturedEvents).containsExactly( + Composer( + inThread = false, + isEditing = false, + isReply = false, + messageType = Composer.MessageType.Text, + ) + ) + } + } + + @Test + fun `present - send message with plain text enabled`() = runTest { + val permalinkBuilder = FakePermalinkBuilder(result = { Result.success("") }) + val presenter = createPresenter(this, isRichTextEditorEnabled = false) + moleculeFlow(RecompositionMode.Immediate) { + val state = presenter.present() + val messageMarkdown = state.textEditorState.messageMarkdown(permalinkBuilder) + remember(state, messageMarkdown) { state } + }.test { + val initialState = awaitFirstItem() + initialState.textEditorState.setMarkdown(A_MESSAGE) + val withMessageState = awaitItem() + assertThat(withMessageState.textEditorState.messageMarkdown(permalinkBuilder)).isEqualTo(A_MESSAGE) + assertThat(withMessageState.textEditorState.messageHtml()).isNull() + withMessageState.eventSink.invoke(MessageComposerEvents.SendMessage) + val messageSentState = awaitItem() + assertThat(messageSentState.textEditorState.messageMarkdown(permalinkBuilder)).isEqualTo("") waitForPredicate { analyticsService.capturedEvents.size == 1 } assertThat(analyticsService.capturedEvents).containsExactly( Composer( @@ -278,23 +307,23 @@ class MessageComposerPresenterTest { ) moleculeFlow(RecompositionMode.Immediate) { val state = presenter.present() - remember(state, state.richTextEditorState.messageHtml) { state } + remember(state, state.textEditorState.messageHtml()) { state } }.test { val initialState = awaitFirstItem() - assertThat(initialState.richTextEditorState.messageHtml).isEqualTo("") + assertThat(initialState.textEditorState.messageHtml()).isEqualTo("") val mode = anEditMode() initialState.eventSink.invoke(MessageComposerEvents.SetMode(mode)) skipItems(1) val withMessageState = awaitItem() assertThat(withMessageState.mode).isEqualTo(mode) - assertThat(withMessageState.richTextEditorState.messageHtml).isEqualTo(A_MESSAGE) - withMessageState.richTextEditorState.setHtml(ANOTHER_MESSAGE) + assertThat(withMessageState.textEditorState.messageHtml()).isEqualTo(A_MESSAGE) + withMessageState.textEditorState.setHtml(ANOTHER_MESSAGE) val withEditedMessageState = awaitItem() - assertThat(withEditedMessageState.richTextEditorState.messageHtml).isEqualTo(ANOTHER_MESSAGE) - withEditedMessageState.eventSink.invoke(MessageComposerEvents.SendMessage(ANOTHER_MESSAGE.toMessage())) + assertThat(withEditedMessageState.textEditorState.messageHtml()).isEqualTo(ANOTHER_MESSAGE) + withEditedMessageState.eventSink.invoke(MessageComposerEvents.SendMessage) skipItems(1) val messageSentState = awaitItem() - assertThat(messageSentState.richTextEditorState.messageHtml).isEqualTo("") + assertThat(messageSentState.textEditorState.messageHtml()).isEqualTo("") advanceUntilIdle() @@ -328,23 +357,23 @@ class MessageComposerPresenterTest { ) moleculeFlow(RecompositionMode.Immediate) { val state = presenter.present() - remember(state, state.richTextEditorState.messageHtml) { state } + remember(state, state.textEditorState.messageHtml()) { state } }.test { val initialState = awaitFirstItem() - assertThat(initialState.richTextEditorState.messageHtml).isEqualTo("") + assertThat(initialState.textEditorState.messageHtml()).isEqualTo("") val mode = anEditMode(eventId = null, transactionId = A_TRANSACTION_ID) initialState.eventSink.invoke(MessageComposerEvents.SetMode(mode)) skipItems(1) val withMessageState = awaitItem() assertThat(withMessageState.mode).isEqualTo(mode) - assertThat(withMessageState.richTextEditorState.messageHtml).isEqualTo(A_MESSAGE) - withMessageState.richTextEditorState.setHtml(ANOTHER_MESSAGE) + assertThat(withMessageState.textEditorState.messageHtml()).isEqualTo(A_MESSAGE) + withMessageState.textEditorState.setHtml(ANOTHER_MESSAGE) val withEditedMessageState = awaitItem() - assertThat(withEditedMessageState.richTextEditorState.messageHtml).isEqualTo(ANOTHER_MESSAGE) - withEditedMessageState.eventSink.invoke(MessageComposerEvents.SendMessage(ANOTHER_MESSAGE.toMessage())) + assertThat(withEditedMessageState.textEditorState.messageHtml()).isEqualTo(ANOTHER_MESSAGE) + withEditedMessageState.eventSink.invoke(MessageComposerEvents.SendMessage) skipItems(1) val messageSentState = awaitItem() - assertThat(messageSentState.richTextEditorState.messageHtml).isEqualTo("") + assertThat(messageSentState.textEditorState.messageHtml()).isEqualTo("") advanceUntilIdle() @@ -380,17 +409,17 @@ class MessageComposerPresenterTest { presenter.present() }.test { val initialState = awaitFirstItem() - assertThat(initialState.richTextEditorState.messageHtml).isEqualTo("") + assertThat(initialState.textEditorState.messageHtml()).isEqualTo("") val mode = aReplyMode() initialState.eventSink.invoke(MessageComposerEvents.SetMode(mode)) val state = awaitItem() assertThat(state.mode).isEqualTo(mode) - assertThat(state.richTextEditorState.messageHtml).isEqualTo("") - state.richTextEditorState.setHtml(A_REPLY) - assertThat(state.richTextEditorState.messageHtml).isEqualTo(A_REPLY) - state.eventSink.invoke(MessageComposerEvents.SendMessage(A_REPLY.toMessage())) + assertThat(state.textEditorState.messageHtml()).isEqualTo("") + state.textEditorState.setHtml(A_REPLY) + assertThat(state.textEditorState.messageHtml()).isEqualTo(A_REPLY) + state.eventSink.invoke(MessageComposerEvents.SendMessage) val messageSentState = awaitItem() - assertThat(messageSentState.richTextEditorState.messageHtml).isEqualTo("") + assertThat(messageSentState.textEditorState.messageHtml()).isEqualTo("") advanceUntilIdle() @@ -725,7 +754,7 @@ class MessageComposerPresenterTest { @Test fun `present - ToggleTextFormatting toggles text formatting`() = runTest { - val presenter = createPresenter(this) + val presenter = createPresenter(this, isRichTextEditorEnabled = false) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { @@ -735,11 +764,12 @@ class MessageComposerPresenterTest { val composerOptions = awaitItem() assertThat(composerOptions.showAttachmentSourcePicker).isTrue() composerOptions.eventSink(MessageComposerEvents.ToggleTextFormatting(true)) - awaitItem() // composer options closed + skipItems(2) // composer options closed val showTextFormatting = awaitItem() assertThat(showTextFormatting.showAttachmentSourcePicker).isFalse() assertThat(showTextFormatting.showTextFormatting).isTrue() showTextFormatting.eventSink(MessageComposerEvents.ToggleTextFormatting(false)) + skipItems(1) val finished = awaitItem() assertThat(finished.showTextFormatting).isFalse() } @@ -781,19 +811,19 @@ class MessageComposerPresenterTest { // An empty suggestion returns the room and joined members that are not the current user initialState.eventSink(MessageComposerEvents.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, ""))) assertThat(awaitItem().memberSuggestions) - .containsExactly(MentionSuggestion.Room, MentionSuggestion.Member(bob), MentionSuggestion.Member(david)) + .containsExactly(ResolvedMentionSuggestion.AtRoom, ResolvedMentionSuggestion.Member(bob), ResolvedMentionSuggestion.Member(david)) // A suggestion containing a part of "room" will also return the room mention initialState.eventSink(MessageComposerEvents.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, "roo"))) - assertThat(awaitItem().memberSuggestions).containsExactly(MentionSuggestion.Room) + assertThat(awaitItem().memberSuggestions).containsExactly(ResolvedMentionSuggestion.AtRoom) // A non-empty suggestion will return those joined members whose user id matches it initialState.eventSink(MessageComposerEvents.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, "bob"))) - assertThat(awaitItem().memberSuggestions).containsExactly(MentionSuggestion.Member(bob)) + assertThat(awaitItem().memberSuggestions).containsExactly(ResolvedMentionSuggestion.Member(bob)) // A non-empty suggestion will return those joined members whose display name matches it initialState.eventSink(MessageComposerEvents.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, "dave"))) - assertThat(awaitItem().memberSuggestions).containsExactly(MentionSuggestion.Member(david)) + assertThat(awaitItem().memberSuggestions).containsExactly(ResolvedMentionSuggestion.Member(david)) // If the suggestion isn't a mention, no suggestions are returned initialState.eventSink(MessageComposerEvents.SuggestionReceived(Suggestion(0, 0, SuggestionType.Command, ""))) @@ -803,7 +833,7 @@ class MessageComposerPresenterTest { room.givenCanTriggerRoomNotification(Result.success(false)) initialState.eventSink(MessageComposerEvents.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, ""))) assertThat(awaitItem().memberSuggestions) - .containsExactly(MentionSuggestion.Member(bob), MentionSuggestion.Member(david)) + .containsExactly(ResolvedMentionSuggestion.Member(bob), ResolvedMentionSuggestion.Member(david)) // If room is a DM, `RoomMemberSuggestion.Room` is not returned room.givenCanTriggerRoomNotification(Result.success(true)) @@ -844,7 +874,7 @@ class MessageComposerPresenterTest { initialState.eventSink(MessageComposerEvents.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, ""))) skipItems(1) assertThat(awaitItem().memberSuggestions) - .containsExactly(MentionSuggestion.Member(bob), MentionSuggestion.Member(david)) + .containsExactly(ResolvedMentionSuggestion.Member(bob), ResolvedMentionSuggestion.Member(david)) } } @@ -862,10 +892,10 @@ class MessageComposerPresenterTest { presenter.present() }.test { val initialState = awaitFirstItem() - initialState.richTextEditorState.setHtml("Hey @bo") - initialState.eventSink(MessageComposerEvents.InsertMention(MentionSuggestion.Member(aRoomMember(userId = A_USER_ID_2)))) + initialState.textEditorState.setHtml("Hey @bo") + initialState.eventSink(MessageComposerEvents.InsertMention(ResolvedMentionSuggestion.Member(aRoomMember(userId = A_USER_ID_2)))) - assertThat(initialState.richTextEditorState.messageHtml) + assertThat(initialState.textEditorState.messageHtml()) .isEqualTo("Hey ${A_USER_ID_2.value}") } } @@ -892,14 +922,14 @@ class MessageComposerPresenterTest { // Check intentional mentions on message sent val mentionUser1 = listOf(A_USER_ID.value) - initialState.richTextEditorState.mentionsState = MentionsState( + (initialState.textEditorState as? TextEditorState.Rich)?.richTextEditorState?.mentionsState = MentionsState( userIds = mentionUser1, roomIds = emptyList(), roomAliases = emptyList(), hasAtRoomMention = false ) - initialState.richTextEditorState.setHtml(A_MESSAGE) - initialState.eventSink(MessageComposerEvents.SendMessage(A_MESSAGE.toMessage())) + initialState.textEditorState.setHtml(A_MESSAGE) + initialState.eventSink(MessageComposerEvents.SendMessage) advanceUntilIdle() @@ -908,14 +938,14 @@ class MessageComposerPresenterTest { // Check intentional mentions on reply sent initialState.eventSink(MessageComposerEvents.SetMode(aReplyMode())) val mentionUser2 = listOf(A_USER_ID_2.value) - awaitItem().richTextEditorState.mentionsState = MentionsState( + (awaitItem().textEditorState as? TextEditorState.Rich)?.richTextEditorState?.mentionsState = MentionsState( userIds = mentionUser2, roomIds = emptyList(), roomAliases = emptyList(), hasAtRoomMention = false ) - initialState.eventSink(MessageComposerEvents.SendMessage(A_MESSAGE.toMessage())) + initialState.eventSink(MessageComposerEvents.SendMessage) advanceUntilIdle() assert(replyMessageLambda) @@ -926,14 +956,14 @@ class MessageComposerPresenterTest { skipItems(1) initialState.eventSink(MessageComposerEvents.SetMode(anEditMode())) val mentionUser3 = listOf(A_USER_ID_3.value) - awaitItem().richTextEditorState.mentionsState = MentionsState( + (awaitItem().textEditorState as? TextEditorState.Rich)?.richTextEditorState?.mentionsState = MentionsState( userIds = mentionUser3, roomIds = emptyList(), roomAliases = emptyList(), hasAtRoomMention = false ) - initialState.eventSink(MessageComposerEvents.SendMessage(A_MESSAGE.toMessage())) + initialState.eventSink(MessageComposerEvents.SendMessage) advanceUntilIdle() assert(editMessageLambda) @@ -949,7 +979,7 @@ class MessageComposerPresenterTest { val presenter = createPresenter(this) moleculeFlow(RecompositionMode.Immediate) { val state = presenter.present() - remember(state, state.richTextEditorState.messageHtml) { state } + remember(state, state.textEditorState.messageHtml()) { state } }.test { val initialState = awaitFirstItem() initialState.eventSink.invoke(MessageComposerEvents.SendUri(Uri.parse("content://uri"))) @@ -1007,7 +1037,8 @@ class MessageComposerPresenterTest { mediaPreProcessor: MediaPreProcessor = this.mediaPreProcessor, snackbarDispatcher: SnackbarDispatcher = this.snackbarDispatcher, permissionPresenter: PermissionsPresenter = FakePermissionsPresenter(), - permalinkBuilder: PermalinkBuilder = FakePermalinkBuilder() + permalinkBuilder: PermalinkBuilder = FakePermalinkBuilder(), + isRichTextEditorEnabled: Boolean = true, ) = MessageComposerPresenter( coroutineScope, room, @@ -1025,7 +1056,10 @@ class MessageComposerPresenterTest { permalinkParser = FakePermalinkParser(), permalinkBuilder = permalinkBuilder, timelineController = TimelineController(room), - ) + ).apply { + isTesting = true + showTextFormatting = isRichTextEditorEnabled + } private suspend fun ReceiveTurbine.awaitFirstItem(): T { // Skip 2 item if Mentions feature is enabled, else 1 @@ -1043,7 +1077,10 @@ fun anEditMode( fun aReplyMode() = MessageComposerMode.Reply(A_USER_NAME, null, false, AN_EVENT_ID, A_MESSAGE) fun aQuoteMode() = MessageComposerMode.Quote(AN_EVENT_ID, A_MESSAGE) -private fun String.toMessage() = Message( - html = this, - markdown = this, -) +private suspend fun TextEditorState.setHtml(html: String) { + (this as? TextEditorState.Rich)?.richTextEditorState?.setHtml(html) ?: error("TextEditorState is not Rich") +} + +private fun TextEditorState.setMarkdown(markdown: String) { + (this as? TextEditorState.Markdown)?.state?.text?.update(markdown, needsDisplaying = false) ?: error("TextEditorState is not Markdown") +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/TestRichTextEditorStateFactory.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/TestRichTextEditorStateFactory.kt index 17e1c65daf..921a7331fd 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/TestRichTextEditorStateFactory.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/TestRichTextEditorStateFactory.kt @@ -23,7 +23,7 @@ import io.element.android.wysiwyg.compose.rememberRichTextEditorState class TestRichTextEditorStateFactory : RichTextEditorStateFactory { @Composable - override fun create(): RichTextEditorState { + override fun remember(): RichTextEditorState { return rememberRichTextEditorState("", fake = true) } } 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 bfca79d714..265e3ef636 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 @@ -409,7 +409,7 @@ private const val FAKE_UNIQUE_ID_2 = "FAKE_UNIQUE_ID_2" presenter.present() }.test { val initialState = awaitFirstItem() - initialState.eventSink.invoke(TimelineEvents.PollAnswerSelected(AN_EVENT_ID, "anAnswerId")) + initialState.eventSink.invoke(TimelineEvents.SelectPollAnswer(AN_EVENT_ID, "anAnswerId")) } delay(1) sendPollResponseAction.verifyExecutionCount(1) @@ -425,7 +425,7 @@ private const val FAKE_UNIQUE_ID_2 = "FAKE_UNIQUE_ID_2" presenter.present() }.test { val initialState = awaitFirstItem() - initialState.eventSink.invoke(TimelineEvents.PollEndClicked(AN_EVENT_ID)) + initialState.eventSink.invoke(TimelineEvents.EndPoll(AN_EVENT_ID)) } delay(1) endPollAction.verifyExecutionCount(1) @@ -440,7 +440,7 @@ private const val FAKE_UNIQUE_ID_2 = "FAKE_UNIQUE_ID_2" moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { - awaitFirstItem().eventSink(TimelineEvents.PollEditClicked(AN_EVENT_ID)) + awaitFirstItem().eventSink(TimelineEvents.EditPoll(AN_EVENT_ID)) assertThat(navigator.onEditPollClickedCount).isEqualTo(1) } } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewTest.kt index 29d46d2da2..f5d78860b3 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/TimelineViewTest.kt @@ -101,15 +101,15 @@ class TimelineViewTest { private fun AndroidComposeTestRule.setTimelineView( state: TimelineState, typingNotificationState: TypingNotificationState = aTypingNotificationState(), - onUserDataClicked: (UserId) -> Unit = EnsureNeverCalledWithParam(), - onLinkClicked: (String) -> Unit = EnsureNeverCalledWithParam(), - onMessageClicked: (TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(), - onMessageLongClicked: (TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(), - onTimestampClicked: (TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(), + onUserDataClick: (UserId) -> Unit = EnsureNeverCalledWithParam(), + onLinkClick: (String) -> Unit = EnsureNeverCalledWithParam(), + onMessageClick: (TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(), + onMessageLongClick: (TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(), + onTimestampClick: (TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(), onSwipeToReply: (TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(), - onReactionClicked: (emoji: String, TimelineItem.Event) -> Unit = EnsureNeverCalledWithTwoParams(), - onReactionLongClicked: (emoji: String, TimelineItem.Event) -> Unit = EnsureNeverCalledWithTwoParams(), - onMoreReactionsClicked: (TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(), + onReactionClick: (emoji: String, TimelineItem.Event) -> Unit = EnsureNeverCalledWithTwoParams(), + onReactionLongClick: (emoji: String, TimelineItem.Event) -> Unit = EnsureNeverCalledWithTwoParams(), + onMoreReactionsClick: (TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(), onReadReceiptClick: (TimelineItem.Event) -> Unit = EnsureNeverCalledWithParam(), forceJumpToBottomVisibility: Boolean = false, ) { @@ -117,15 +117,15 @@ private fun AndroidComposeTestRule.setTimel TimelineView( state = state, typingNotificationState = typingNotificationState, - onUserDataClicked = onUserDataClicked, - onLinkClicked = onLinkClicked, - onMessageClicked = onMessageClicked, - onMessageLongClicked = onMessageLongClicked, - onTimestampClicked = onTimestampClicked, + onUserDataClick = onUserDataClick, + onLinkClick = onLinkClick, + onMessageClick = onMessageClick, + onMessageLongClick = onMessageLongClick, + onTimestampClick = onTimestampClick, onSwipeToReply = onSwipeToReply, - onReactionClicked = onReactionClicked, - onReactionLongClicked = onReactionLongClicked, - onMoreReactionsClicked = onMoreReactionsClicked, + onReactionClick = onReactionClick, + onReactionLongClick = onReactionLongClick, + onMoreReactionsClick = onMoreReactionsClick, onReadReceiptClick = onReadReceiptClick, forceJumpToBottomVisibility = forceJumpToBottomVisibility, ) diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemPollViewTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemPollViewTest.kt index 4ea16a26b0..4c791da406 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemPollViewTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemPollViewTest.kt @@ -57,7 +57,7 @@ class TimelineItemPollViewTest { } val answer = content.answerItems[answerIndex].answer rule.onNode(hasText(answer.text)).performClick() - eventsRecorder.assertSingle(TimelineEvents.PollAnswerSelected(content.eventId!!, answer.id)) + eventsRecorder.assertSingle(TimelineEvents.SelectPollAnswer(content.eventId!!, answer.id)) } @Test @@ -74,7 +74,7 @@ class TimelineItemPollViewTest { ) } rule.clickOn(CommonStrings.action_edit_poll) - eventsRecorder.assertSingle(TimelineEvents.PollEditClicked(content.eventId!!)) + eventsRecorder.assertSingle(TimelineEvents.EditPoll(content.eventId!!)) } @Test @@ -93,6 +93,6 @@ class TimelineItemPollViewTest { // A confirmation dialog should be shown eventsRecorder.assertEmpty() rule.pressTag(TestTags.dialogPositive.value) - eventsRecorder.assertSingle(TimelineEvents.PollEndClicked(content.eventId!!)) + eventsRecorder.assertSingle(TimelineEvents.EndPoll(content.eventId!!)) } } 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 35de78f65b..6d8fb1ad9a 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 @@ -16,7 +16,9 @@ package io.element.android.features.messages.impl.timeline.factories.event +import android.net.Uri import android.text.SpannableString +import android.text.SpannableStringBuilder import android.text.Spanned import android.text.style.URLSpan import androidx.core.text.buildSpannedString @@ -46,6 +48,7 @@ import io.element.android.libraries.matrix.api.media.ImageInfo import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.api.media.ThumbnailInfo import io.element.android.libraries.matrix.api.media.VideoInfo +import io.element.android.libraries.matrix.api.permalink.PermalinkData 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 @@ -75,6 +78,7 @@ import org.robolectric.RobolectricTestRunner import kotlin.time.Duration import kotlin.time.Duration.Companion.minutes +@Suppress("LargeClass") @RunWith(RobolectricTestRunner::class) class TimelineItemContentMessageFactoryTest { @Test @@ -641,6 +645,31 @@ class TimelineItemContentMessageFactoryTest { assertThat((result as TimelineItemEmoteContent).formattedBody).isEqualTo(SpannableString("* Bob formatted")) } + @Test + fun `a message with existing URLSpans keeps it after linkification`() = runTest { + val expectedSpanned = SpannableStringBuilder().apply { + append("Test ") + inSpans(URLSpan("https://www.example.org")) { + append("me@matrix.org") + } + } + val sut = createTimelineItemContentMessageFactory( + htmlConverterTransform = { expectedSpanned }, + permalinkParser = FakePermalinkParser { PermalinkData.FallbackLink(Uri.EMPTY) } + ) + val result = sut.create( + content = createMessageContent( + type = TextMessageType( + body = "Test [me@matrix.org](https://www.example.org)", + formatted = FormattedBody(MessageFormat.HTML, "Test me@matrix.org") + ) + ), + senderDisambiguatedDisplayName = "Bob", + eventId = AN_EVENT_ID, + ) + assertThat((result as TimelineItemTextContent).formattedBody).isEqualTo(expectedSpanned) + } + private fun createMessageContent( body: String = "Body", inReplyTo: InReplyTo? = null, @@ -660,12 +689,13 @@ class TimelineItemContentMessageFactoryTest { private fun createTimelineItemContentMessageFactory( featureFlagService: FeatureFlagService = FakeFeatureFlagService(), htmlConverterTransform: (String) -> CharSequence = { it }, + permalinkParser: FakePermalinkParser = FakePermalinkParser(), ) = TimelineItemContentMessageFactory( fileSizeFormatter = FakeFileSizeFormatter(), fileExtensionExtractor = FileExtensionExtractorWithoutValidation(), featureFlagService = featureFlagService, htmlConverterProvider = FakeHtmlConverterProvider(htmlConverterTransform), - permalinkParser = FakePermalinkParser(), + permalinkParser = permalinkParser, ) private fun createStickerContent( diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/model/InReplyToDetailTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/model/InReplyToDetailTest.kt index d73f7246c9..b103cc8e68 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/model/InReplyToDetailTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/model/InReplyToDetailTest.kt @@ -58,6 +58,7 @@ class InReplyToDetailTest { senderProfile = aProfileTimelineDetails(), content = RoomMembershipContent( userId = A_USER_ID, + userDisplayName = null, change = MembershipChange.INVITED, ) ) diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/model/InReplyToMetadataKtTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/model/InReplyToMetadataKtTest.kt index b56d871704..c155325444 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/model/InReplyToMetadataKtTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/model/InReplyToMetadataKtTest.kt @@ -380,7 +380,7 @@ class InReplyToMetadataKtTest { fun `room membership content`() = runTest { moleculeFlow(RecompositionMode.Immediate) { anInReplyToDetailsReady( - eventContent = RoomMembershipContent(A_USER_ID, null) + eventContent = RoomMembershipContent(A_USER_ID, null, null) ).metadata() }.test { awaitItem().let { diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/DefaultVoiceMessageMediaRepoTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/DefaultVoiceMessageMediaRepoTest.kt index a37f2e775f..6e12756df7 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/DefaultVoiceMessageMediaRepoTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/DefaultVoiceMessageMediaRepoTest.kt @@ -21,7 +21,7 @@ import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.matrix.api.media.MatrixMediaLoader import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.api.mxc.MxcTools -import io.element.android.libraries.matrix.test.media.FakeMediaLoader +import io.element.android.libraries.matrix.test.media.FakeMatrixMediaLoader import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test @@ -34,12 +34,12 @@ class DefaultVoiceMessageMediaRepoTest { @Test fun `cache miss - downloads and returns cached file successfully`() = runTest { - val fakeMediaLoader = FakeMediaLoader().apply { + val matrixMediaLoader = FakeMatrixMediaLoader().apply { path = temporaryFolder.createRustMediaFile().path } val repo = createDefaultVoiceMessageMediaRepo( temporaryFolder = temporaryFolder, - matrixMediaLoader = fakeMediaLoader, + matrixMediaLoader = matrixMediaLoader, ) repo.getMediaFile().let { result -> @@ -53,12 +53,12 @@ class DefaultVoiceMessageMediaRepoTest { @Test fun `cache miss - download fails`() = runTest { - val fakeMediaLoader = FakeMediaLoader().apply { + val matrixMediaLoader = FakeMatrixMediaLoader().apply { shouldFail = true } val repo = createDefaultVoiceMessageMediaRepo( temporaryFolder = temporaryFolder, - matrixMediaLoader = fakeMediaLoader, + matrixMediaLoader = matrixMediaLoader, ) repo.getMediaFile().let { result -> @@ -71,7 +71,7 @@ class DefaultVoiceMessageMediaRepoTest { @Test fun `cache miss - download succeeds but file move fails`() = runTest { - val fakeMediaLoader = FakeMediaLoader().apply { + val matrixMediaLoader = FakeMatrixMediaLoader().apply { path = temporaryFolder.createRustMediaFile().path } File(temporaryFolder.cachedFilePath).apply { @@ -83,7 +83,7 @@ class DefaultVoiceMessageMediaRepoTest { } val repo = createDefaultVoiceMessageMediaRepo( temporaryFolder = temporaryFolder, - matrixMediaLoader = fakeMediaLoader, + matrixMediaLoader = matrixMediaLoader, ) repo.getMediaFile().let { result -> @@ -100,12 +100,12 @@ class DefaultVoiceMessageMediaRepoTest { @Test fun `cache hit - returns cached file successfully`() = runTest { temporaryFolder.createCachedFile() - val fakeMediaLoader = FakeMediaLoader().apply { + val matrixMediaLoader = FakeMatrixMediaLoader().apply { shouldFail = true // so that if we hit the media loader it will crash } val repo = createDefaultVoiceMessageMediaRepo( temporaryFolder = temporaryFolder, - matrixMediaLoader = fakeMediaLoader, + matrixMediaLoader = matrixMediaLoader, ) repo.getMediaFile().let { result -> @@ -135,7 +135,7 @@ class DefaultVoiceMessageMediaRepoTest { private fun createDefaultVoiceMessageMediaRepo( temporaryFolder: TemporaryFolder, - matrixMediaLoader: MatrixMediaLoader = FakeMediaLoader(), + matrixMediaLoader: MatrixMediaLoader = FakeMatrixMediaLoader(), mxcUri: String = MXC_URI, ) = DefaultVoiceMessageMediaRepo( cacheDir = temporaryFolder.root, diff --git a/features/migration/impl/build.gradle.kts b/features/migration/impl/build.gradle.kts index 2c8ffa58b3..392a8f3889 100644 --- a/features/migration/impl/build.gradle.kts +++ b/features/migration/impl/build.gradle.kts @@ -40,6 +40,7 @@ dependencies { testImplementation(libs.test.junit) testImplementation(libs.coroutines.test) testImplementation(libs.molecule.runtime) + testImplementation(libs.test.robolectric) testImplementation(libs.test.truth) testImplementation(libs.test.turbine) testImplementation(projects.libraries.sessionStorage.implMemory) diff --git a/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/migrations/AppMigration04.kt b/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/migrations/AppMigration04.kt new file mode 100644 index 0000000000..79947b683e --- /dev/null +++ b/features/migration/impl/src/main/kotlin/io/element/android/features/migration/impl/migrations/AppMigration04.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.migration.impl.migrations + +import android.content.Context +import com.squareup.anvil.annotations.ContributesMultibinding +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.di.ApplicationContext +import javax.inject.Inject + +/** + * Remove notifications.bin file, used to store notification data locally. + */ +@ContributesMultibinding(AppScope::class) +class AppMigration04 @Inject constructor( + @ApplicationContext private val context: Context, +) : AppMigration { + companion object { + internal const val NOTIFICATION_FILE_NAME = "notifications.bin" + } + override val order: Int = 4 + + override suspend fun migrate() { + runCatching { context.getDatabasePath(NOTIFICATION_FILE_NAME).delete() } + } +} diff --git a/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/MigrationPresenterTest.kt b/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/MigrationPresenterTest.kt index 3ea0625f76..29be8682e3 100644 --- a/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/MigrationPresenterTest.kt +++ b/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/MigrationPresenterTest.kt @@ -37,7 +37,7 @@ class MigrationPresenterTest { @Test fun `present - no migration should occurs if ApplicationMigrationVersion is the last one`() = runTest { - val migrations = (1..10).map { FakeMigration(it) } + val migrations = (1..10).map { FakeAppMigration(it) } val store = InMemoryMigrationStore(migrations.maxOf { it.order }) val presenter = createPresenter( migrationStore = store, @@ -57,7 +57,7 @@ class MigrationPresenterTest { @Test fun `present - testing all migrations`() = runTest { val store = InMemoryMigrationStore(0) - val migrations = (1..10).map { FakeMigration(it) } + val migrations = (1..10).map { FakeAppMigration(it) } val presenter = createPresenter( migrationStore = store, migrations = migrations.toSet(), @@ -81,13 +81,13 @@ class MigrationPresenterTest { private fun createPresenter( migrationStore: MigrationStore = InMemoryMigrationStore(0), - migrations: Set = setOf(FakeMigration(1)), + migrations: Set = setOf(FakeAppMigration(1)), ) = MigrationPresenter( migrationStore = migrationStore, migrations = migrations, ) -private class FakeMigration( +private class FakeAppMigration( override val order: Int, var migrateLambda: LambdaNoParamRecorder = lambdaRecorder { -> }, ) : AppMigration { diff --git a/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/migrations/AppMigration02Test.kt b/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/migrations/AppMigration02Test.kt index 1a077fda2e..6bb2f5babd 100644 --- a/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/migrations/AppMigration02Test.kt +++ b/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/migrations/AppMigration02Test.kt @@ -17,7 +17,7 @@ package io.element.android.features.migration.impl.migrations import com.google.common.truth.Truth.assertThat -import io.element.android.libraries.preferences.test.FakeSessionPreferenceStoreFactory +import io.element.android.libraries.preferences.test.FakeSessionPreferencesStoreFactory import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore import io.element.android.libraries.sessionstorage.impl.memory.InMemorySessionStore import io.element.android.libraries.sessionstorage.test.aSessionData @@ -33,7 +33,7 @@ class AppMigration02Test { updateData(aSessionData()) } val sessionPreferencesStore = InMemorySessionPreferencesStore(isSessionVerificationSkipped = false) - val sessionPreferencesStoreFactory = FakeSessionPreferenceStoreFactory( + val sessionPreferencesStoreFactory = FakeSessionPreferencesStoreFactory( getLambda = lambdaRecorder { _, _, -> sessionPreferencesStore }, ) val migration = AppMigration02(sessionStore = sessionStore, sessionPreferenceStoreFactory = sessionPreferencesStoreFactory) diff --git a/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/migrations/AppMigration04Test.kt b/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/migrations/AppMigration04Test.kt new file mode 100644 index 0000000000..5549a8d8a2 --- /dev/null +++ b/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/migrations/AppMigration04Test.kt @@ -0,0 +1,45 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.migration.impl.migrations + +import androidx.test.platform.app.InstrumentationRegistry +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class AppMigration04Test { + @Test + fun `test migration`() = runTest { + val context = InstrumentationRegistry.getInstrumentation().context + + // Create fake temporary file at the path to be deleted + val file = context.getDatabasePath(AppMigration04.NOTIFICATION_FILE_NAME) + file.parentFile?.mkdirs() + file.createNewFile() + assertThat(file.exists()).isTrue() + + val migration = AppMigration04(context) + + migration.migrate() + + // Check that the file has been deleted + assertThat(file.exists()).isFalse() + } +} diff --git a/features/onboarding/impl/src/main/res/values-ka/translations.xml b/features/onboarding/impl/src/main/res/values-ka/translations.xml new file mode 100644 index 0000000000..e75edfdefa --- /dev/null +++ b/features/onboarding/impl/src/main/res/values-ka/translations.xml @@ -0,0 +1,9 @@ + + + "ხელით შესვლა" + "შესვლა QR კოდით" + "ანგარიშის შექმნა" + "კეთილი იყოს თქვენი მობრძანება უსწრაფეს %1$s-ში. დამუხტულია სიჩქარისა და სიმარტივისათვის." + "კეთილი იყოს თქვენი მობრძანება %1$s-ში! დამუხტული სიჩქარისა და სიმარტივისთვის." + "იყავი შენს element-ში" + diff --git a/features/onboarding/impl/src/main/res/values-pt-rBR/translations.xml b/features/onboarding/impl/src/main/res/values-pt-rBR/translations.xml new file mode 100644 index 0000000000..311343ef9f --- /dev/null +++ b/features/onboarding/impl/src/main/res/values-pt-rBR/translations.xml @@ -0,0 +1,9 @@ + + + "Iniciar sessão manualmente" + "Iniciar sessão com código QR" + "Criar conta" + "Bem-vindo(a) à %1$s mais rápida de sempre. Super rápida e simples." + "Bem-vindo(a) à %1$s. Revitalizado, rápido e simples." + "A liberdade do teu elemento" + diff --git a/features/onboarding/impl/src/main/res/values-zh/translations.xml b/features/onboarding/impl/src/main/res/values-zh/translations.xml new file mode 100644 index 0000000000..f8b53c3369 --- /dev/null +++ b/features/onboarding/impl/src/main/res/values-zh/translations.xml @@ -0,0 +1,9 @@ + + + "手动登录" + "使用二维码登录" + "创建账户" + "欢迎使用 %1$s,快而简约的消息应用。" + "欢迎使用 %1$s,速度与简洁的极致。" + "融入您的 Element" + diff --git a/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollContentView.kt b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollContentView.kt index b8f77ce3af..e5c445bd4e 100644 --- a/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollContentView.kt +++ b/features/poll/api/src/main/kotlin/io/element/android/features/poll/api/pollcontent/PollContentView.kt @@ -52,9 +52,9 @@ import kotlinx.collections.immutable.ImmutableList @Composable fun PollContentView( state: PollContentState, - onAnswerSelected: (pollStartId: EventId, answerId: String) -> Unit, - onPollEdit: (pollStartId: EventId) -> Unit, - onPollEnd: (pollStartId: EventId) -> Unit, + onSelectAnswer: (pollStartId: EventId, answerId: String) -> Unit, + onEditPoll: (pollStartId: EventId) -> Unit, + onEndPoll: (pollStartId: EventId) -> Unit, modifier: Modifier = Modifier, ) { PollContentView( @@ -65,9 +65,9 @@ fun PollContentView( isPollEditable = state.isPollEditable, isPollEnded = state.isPollEnded, isMine = state.isMine, - onPollEdit = onPollEdit, - onAnswerSelected = onAnswerSelected, - onPollEnd = onPollEnd, + onEditPoll = onEditPoll, + onSelectAnswer = onSelectAnswer, + onEndPoll = onEndPoll, modifier = modifier, ) } @@ -81,23 +81,23 @@ fun PollContentView( isPollEditable: Boolean, isPollEnded: Boolean, isMine: Boolean, - onAnswerSelected: (pollStartId: EventId, answerId: String) -> Unit, - onPollEdit: (pollStartId: EventId) -> Unit, - onPollEnd: (pollStartId: EventId) -> Unit, + onSelectAnswer: (pollStartId: EventId, answerId: String) -> Unit, + onEditPoll: (pollStartId: EventId) -> Unit, + onEndPoll: (pollStartId: EventId) -> Unit, modifier: Modifier = Modifier, ) { val votesCount = remember(answerItems) { answerItems.sumOf { it.votesCount } } - fun onAnswerSelected(pollAnswer: PollAnswer) { - eventId?.let { onAnswerSelected(it, pollAnswer.id) } + fun onSelectAnswer(pollAnswer: PollAnswer) { + eventId?.let { onSelectAnswer(it, pollAnswer.id) } } - fun onPollEdit() { - eventId?.let { onPollEdit(it) } + fun onEditPoll() { + eventId?.let { onEditPoll(it) } } - fun onPollEnd() { - eventId?.let { onPollEnd(it) } + fun onEndPoll() { + eventId?.let { onEndPoll(it) } } var showConfirmation: Boolean by remember { mutableStateOf(false) } @@ -105,8 +105,8 @@ fun PollContentView( if (showConfirmation) { ConfirmationDialog( content = stringResource(id = CommonStrings.common_poll_end_confirmation), - onSubmitClicked = { - onPollEnd() + onSubmitClick = { + onEndPoll() showConfirmation = false }, onDismiss = { showConfirmation = false }, @@ -119,7 +119,7 @@ fun PollContentView( ) { PollTitle(title = question, isPollEnded = isPollEnded) - PollAnswers(answerItems = answerItems, onAnswerSelected = ::onAnswerSelected) + PollAnswers(answerItems = answerItems, onSelectAnswer = ::onSelectAnswer) if (isPollEnded || pollKind == PollKind.Disclosed) { DisclosedPollBottomNotice(votesCount = votesCount) @@ -131,8 +131,8 @@ fun PollContentView( CreatorView( isPollEnded = isPollEnded, isPollEditable = isPollEditable, - onPollEdit = ::onPollEdit, - onPollEnd = { showConfirmation = true }, + onEditPoll = ::onEditPoll, + onEndPoll = { showConfirmation = true }, modifier = Modifier.fillMaxWidth(), ) } @@ -170,7 +170,7 @@ private fun PollTitle( @Composable private fun PollAnswers( answerItems: ImmutableList, - onAnswerSelected: (PollAnswer) -> Unit, + onSelectAnswer: (PollAnswer) -> Unit, ) { Column( modifier = Modifier.selectableGroup(), @@ -183,7 +183,7 @@ private fun PollAnswers( .selectable( selected = it.isSelected, enabled = it.isEnabled, - onClick = { onAnswerSelected(it.answer) }, + onClick = { onSelectAnswer(it.answer) }, role = Role.RadioButton, ), ) @@ -219,21 +219,21 @@ private fun ColumnScope.UndisclosedPollBottomNotice() { private fun CreatorView( isPollEnded: Boolean, isPollEditable: Boolean, - onPollEdit: () -> Unit, - onPollEnd: () -> Unit, + onEditPoll: () -> Unit, + onEndPoll: () -> Unit, modifier: Modifier = Modifier ) { when { isPollEditable -> Button( text = stringResource(id = CommonStrings.action_edit_poll), - onClick = onPollEdit, + onClick = onEditPoll, modifier = modifier, ) !isPollEnded -> Button( text = stringResource(id = CommonStrings.action_end_poll), - onClick = onPollEnd, + onClick = onEndPoll, modifier = modifier, ) } @@ -250,9 +250,9 @@ internal fun PollContentViewUndisclosedPreview() = ElementPreview { isPollEnded = false, isPollEditable = false, isMine = false, - onAnswerSelected = { _, _ -> }, - onPollEdit = {}, - onPollEnd = {}, + onSelectAnswer = { _, _ -> }, + onEditPoll = {}, + onEndPoll = {}, ) } @@ -267,9 +267,9 @@ internal fun PollContentViewDisclosedPreview() = ElementPreview { isPollEnded = false, isPollEditable = false, isMine = false, - onAnswerSelected = { _, _ -> }, - onPollEdit = {}, - onPollEnd = {}, + onSelectAnswer = { _, _ -> }, + onEditPoll = {}, + onEndPoll = {}, ) } @@ -284,9 +284,9 @@ internal fun PollContentViewEndedPreview() = ElementPreview { isPollEnded = true, isPollEditable = false, isMine = false, - onAnswerSelected = { _, _ -> }, - onPollEdit = {}, - onPollEnd = {}, + onSelectAnswer = { _, _ -> }, + onEditPoll = {}, + onEndPoll = {}, ) } @@ -301,9 +301,9 @@ internal fun PollContentViewCreatorEditablePreview() = ElementPreview { isPollEnded = false, isPollEditable = true, isMine = true, - onAnswerSelected = { _, _ -> }, - onPollEdit = {}, - onPollEnd = {}, + onSelectAnswer = { _, _ -> }, + onEditPoll = {}, + onEndPoll = {}, ) } @@ -318,9 +318,9 @@ internal fun PollContentViewCreatorPreview() = ElementPreview { isPollEnded = false, isPollEditable = false, isMine = true, - onAnswerSelected = { _, _ -> }, - onPollEdit = {}, - onPollEnd = {}, + onSelectAnswer = { _, _ -> }, + onEditPoll = {}, + onEndPoll = {}, ) } @@ -335,8 +335,8 @@ internal fun PollContentViewCreatorEndedPreview() = ElementPreview { isPollEnded = true, isPollEditable = false, isMine = true, - onAnswerSelected = { _, _ -> }, - onPollEdit = {}, - onPollEnd = {}, + onSelectAnswer = { _, _ -> }, + onEditPoll = {}, + onEndPoll = {}, ) } 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 65b1d5b38e..41f704e006 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 @@ -79,7 +79,7 @@ fun CreatePollView( if (state.showBackConfirmation) { ConfirmationDialog( content = stringResource(id = R.string.screen_create_poll_cancel_confirmation_content_android), - onSubmitClicked = { state.eventSink(CreatePollEvents.NavBack) }, + onSubmitClick = { state.eventSink(CreatePollEvents.NavBack) }, onDismiss = { state.eventSink(CreatePollEvents.HideConfirmation) } ) } @@ -87,7 +87,7 @@ fun CreatePollView( ConfirmationDialog( title = stringResource(id = R.string.screen_edit_poll_delete_confirmation_title), content = stringResource(id = R.string.screen_edit_poll_delete_confirmation), - onSubmitClicked = { state.eventSink(CreatePollEvents.Delete(confirmed = true)) }, + onSubmitClick = { state.eventSink(CreatePollEvents.Delete(confirmed = true)) }, onDismiss = { state.eventSink(CreatePollEvents.HideConfirmation) } ) } @@ -103,7 +103,7 @@ fun CreatePollView( mode = state.mode, saveEnabled = state.canSave, onBackPress = navBack, - onSaveClicked = { state.eventSink(CreatePollEvents.Save) } + onSaveClick = { state.eventSink(CreatePollEvents.Save) } ) }, ) { paddingValues -> @@ -220,7 +220,7 @@ private fun CreatePollTopAppBar( mode: CreatePollState.Mode, saveEnabled: Boolean, onBackPress: () -> Unit = {}, - onSaveClicked: () -> Unit = {}, + onSaveClick: () -> Unit = {}, ) { TopAppBar( title = { @@ -241,7 +241,7 @@ private fun CreatePollTopAppBar( CreatePollState.Mode.New -> stringResource(id = CommonStrings.action_create) CreatePollState.Mode.Edit -> stringResource(id = CommonStrings.action_done) }, - onClick = onSaveClicked, + onClick = onSaveClick, enabled = saveEnabled, ) } diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryEvents.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryEvents.kt index edc848da41..c2f3f98d11 100644 --- a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryEvents.kt +++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryEvents.kt @@ -21,7 +21,7 @@ import io.element.android.libraries.matrix.api.core.EventId sealed interface PollHistoryEvents { data object LoadMore : PollHistoryEvents - data class PollAnswerSelected(val pollStartId: EventId, val answerId: String) : PollHistoryEvents - data class PollEndClicked(val pollStartId: EventId) : PollHistoryEvents - data class OnFilterSelected(val filter: PollHistoryFilter) : PollHistoryEvents + data class SelectPollAnswer(val pollStartId: EventId, val answerId: String) : PollHistoryEvents + data class EndPoll(val pollStartId: EventId) : PollHistoryEvents + data class SelectFilter(val filter: PollHistoryFilter) : PollHistoryEvents } diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryPresenter.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryPresenter.kt index 981fe00c32..b1579d6cdb 100644 --- a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryPresenter.kt +++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryPresenter.kt @@ -73,13 +73,13 @@ class PollHistoryPresenter @Inject constructor( is PollHistoryEvents.LoadMore -> { coroutineScope.loadMore(timeline) } - is PollHistoryEvents.PollAnswerSelected -> appCoroutineScope.launch { + is PollHistoryEvents.SelectPollAnswer -> appCoroutineScope.launch { sendPollResponseAction.execute(pollStartId = event.pollStartId, answerId = event.answerId) } - is PollHistoryEvents.PollEndClicked -> appCoroutineScope.launch { + is PollHistoryEvents.EndPoll -> appCoroutineScope.launch { endPollAction.execute(pollStartId = event.pollStartId) } - is PollHistoryEvents.OnFilterSelected -> { + is PollHistoryEvents.SelectFilter -> { activeFilter = event.filter } } diff --git a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryView.kt b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryView.kt index 466ddc54f9..d9bb9e5e10 100644 --- a/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryView.kt +++ b/features/poll/impl/src/main/kotlin/io/element/android/features/poll/impl/history/PollHistoryView.kt @@ -74,12 +74,12 @@ fun PollHistoryView( state.eventSink(PollHistoryEvents.LoadMore) } - fun onAnswerSelected(pollStartId: EventId, answerId: String) { - state.eventSink(PollHistoryEvents.PollAnswerSelected(pollStartId, answerId)) + fun onSelectAnswer(pollStartId: EventId, answerId: String) { + state.eventSink(PollHistoryEvents.SelectPollAnswer(pollStartId, answerId)) } - fun onPollEnd(pollStartId: EventId) { - state.eventSink(PollHistoryEvents.PollEndClicked(pollStartId)) + fun onEndPoll(pollStartId: EventId) { + state.eventSink(PollHistoryEvents.EndPoll(pollStartId)) } Scaffold( @@ -111,7 +111,7 @@ fun PollHistoryView( } PollHistoryFilterButtons( activeFilter = state.activeFilter, - onFilterSelected = { state.eventSink(PollHistoryEvents.OnFilterSelected(it)) }, + onSelectFilter = { state.eventSink(PollHistoryEvents.SelectFilter(it)) }, modifier = Modifier .fillMaxWidth() .padding(horizontal = 16.dp, vertical = 8.dp), @@ -128,9 +128,9 @@ fun PollHistoryView( pollHistoryItems = pollHistoryItems, hasMoreToLoad = state.hasMoreToLoad, isLoading = state.isLoading, - onAnswerSelected = ::onAnswerSelected, - onPollEdit = onEditPoll, - onPollEnd = ::onPollEnd, + onSelectAnswer = ::onSelectAnswer, + onEditPoll = onEditPoll, + onEndPoll = ::onEndPoll, onLoadMore = ::onLoadMore, modifier = Modifier.fillMaxSize(), ) @@ -143,7 +143,7 @@ fun PollHistoryView( @Composable private fun PollHistoryFilterButtons( activeFilter: PollHistoryFilter, - onFilterSelected: (PollHistoryFilter) -> Unit, + onSelectFilter: (PollHistoryFilter) -> Unit, modifier: Modifier = Modifier, ) { SingleChoiceSegmentedButtonRow(modifier = modifier) { @@ -152,7 +152,7 @@ private fun PollHistoryFilterButtons( index = filter.ordinal, count = PollHistoryFilter.entries.size, selected = activeFilter == filter, - onClick = { onFilterSelected(filter) }, + onClick = { onSelectFilter(filter) }, text = stringResource(filter.stringResource), ) } @@ -165,9 +165,9 @@ private fun PollHistoryList( pollHistoryItems: ImmutableList, hasMoreToLoad: Boolean, isLoading: Boolean, - onAnswerSelected: (pollStartId: EventId, answerId: String) -> Unit, - onPollEdit: (pollStartId: EventId) -> Unit, - onPollEnd: (pollStartId: EventId) -> Unit, + onSelectAnswer: (pollStartId: EventId, answerId: String) -> Unit, + onEditPoll: (pollStartId: EventId) -> Unit, + onEndPoll: (pollStartId: EventId) -> Unit, onLoadMore: () -> Unit, modifier: Modifier = Modifier, ) { @@ -180,9 +180,9 @@ private fun PollHistoryList( items(pollHistoryItems) { pollHistoryItem -> PollHistoryItemRow( pollHistoryItem = pollHistoryItem, - onAnswerSelected = onAnswerSelected, - onPollEdit = onPollEdit, - onPollEnd = onPollEnd, + onSelectAnswer = onSelectAnswer, + onEditPoll = onEditPoll, + onEndPoll = onEndPoll, modifier = Modifier.padding(vertical = 8.dp, horizontal = 16.dp) ) } @@ -232,9 +232,9 @@ private fun LoadMoreButton(isLoading: Boolean, onClick: () -> Unit) { @Composable private fun PollHistoryItemRow( pollHistoryItem: PollHistoryItem, - onAnswerSelected: (pollStartId: EventId, answerId: String) -> Unit, - onPollEdit: (pollStartId: EventId) -> Unit, - onPollEnd: (pollStartId: EventId) -> Unit, + onSelectAnswer: (pollStartId: EventId, answerId: String) -> Unit, + onEditPoll: (pollStartId: EventId) -> Unit, + onEndPoll: (pollStartId: EventId) -> Unit, modifier: Modifier = Modifier, ) { Surface( @@ -251,9 +251,9 @@ private fun PollHistoryItemRow( Spacer(modifier = Modifier.height(4.dp)) PollContentView( state = pollHistoryItem.state, - onAnswerSelected = onAnswerSelected, - onPollEdit = onPollEdit, - onPollEnd = onPollEnd, + onSelectAnswer = onSelectAnswer, + onEditPoll = onEditPoll, + onEndPoll = onEndPoll, ) } } diff --git a/features/poll/impl/src/main/res/values-ka/translations.xml b/features/poll/impl/src/main/res/values-ka/translations.xml new file mode 100644 index 0000000000..c2417983af --- /dev/null +++ b/features/poll/impl/src/main/res/values-ka/translations.xml @@ -0,0 +1,11 @@ + + + "ვარიანტის დამატება" + "შედეგების ჩვენება მხოლოდ გამოკითხვის დასრულების შემდეგ" + "ხმების დამალვა" + "ვარიანტი %1$d" + "კითხვა ან თემა" + "რას ეხება გამოკითხვა?" + "გამოკითხვის შექმნა" + "გამოკითხვის რედაქტირება" + diff --git a/features/poll/impl/src/main/res/values-pt-rBR/translations.xml b/features/poll/impl/src/main/res/values-pt-rBR/translations.xml new file mode 100644 index 0000000000..456b03b317 --- /dev/null +++ b/features/poll/impl/src/main/res/values-pt-rBR/translations.xml @@ -0,0 +1,19 @@ + + + "Adicionar opção" + "Mostrar resultados só após o da sondagem" + "Ocultar votos" + "Opção %1$d" + "As tuas alterações não foram guardadas. Tens a certeza que queres voltar atrás?" + "Pergunta ou tópico" + "De que trata a sondagem?" + "Criar sondagem" + "Tens a certeza que queres apagar esta sondagem?" + "Eliminar sondagem" + "Editar sondagem" + "Não foi possível encontrar nenhuma sondagem em curso." + "Não foi possível encontrar nenhuma sondagem anterior." + "Em curso" + "Passado" + "Sondagens" + diff --git a/features/poll/impl/src/main/res/values-zh/translations.xml b/features/poll/impl/src/main/res/values-zh/translations.xml new file mode 100644 index 0000000000..a951a5ebb6 --- /dev/null +++ b/features/poll/impl/src/main/res/values-zh/translations.xml @@ -0,0 +1,19 @@ + + + "添加选项" + "仅在投票结束后显示结果" + "隐藏投票" + "选项 %1$d" + "您的更改尚未保存。确定要返回吗?" + "问题或话题" + "投票的内容是什么?" + "创建投票" + "您确定要删除此投票吗?" + "删除投票" + "编辑投票" + "无法找到正在进行的投票。" + "无法找到历史投票" + "正在进行" + "历史" + "投票" + diff --git a/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/history/PollHistoryPresenterTest.kt b/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/history/PollHistoryPresenterTest.kt index 638f9e6ff0..652aafef4f 100644 --- a/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/history/PollHistoryPresenterTest.kt +++ b/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/history/PollHistoryPresenterTest.kt @@ -99,12 +99,12 @@ class PollHistoryPresenterTest { }.test { awaitItem().also { state -> assertThat(state.activeFilter).isEqualTo(PollHistoryFilter.ONGOING) - state.eventSink(PollHistoryEvents.OnFilterSelected(PollHistoryFilter.PAST)) + state.eventSink(PollHistoryEvents.SelectFilter(PollHistoryFilter.PAST)) } skipItems(1) awaitItem().also { state -> assertThat(state.activeFilter).isEqualTo(PollHistoryFilter.PAST) - state.eventSink(PollHistoryEvents.OnFilterSelected(PollHistoryFilter.ONGOING)) + state.eventSink(PollHistoryEvents.SelectFilter(PollHistoryFilter.ONGOING)) } awaitItem().also { state -> assertThat(state.activeFilter).isEqualTo(PollHistoryFilter.ONGOING) @@ -125,10 +125,10 @@ class PollHistoryPresenterTest { presenter.present() }.test { val state = awaitItem() - state.eventSink(PollHistoryEvents.PollEndClicked(AN_EVENT_ID)) + state.eventSink(PollHistoryEvents.EndPoll(AN_EVENT_ID)) runCurrent() endPollAction.verifyExecutionCount(1) - state.eventSink(PollHistoryEvents.PollAnswerSelected(AN_EVENT_ID, "answer")) + state.eventSink(PollHistoryEvents.SelectPollAnswer(AN_EVENT_ID, "answer")) runCurrent() sendPollResponseAction.verifyExecutionCount(1) cancelAndConsumeRemainingEvents() diff --git a/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/history/PollHistoryViewTest.kt b/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/history/PollHistoryViewTest.kt index 0ef2e42b7d..946796ab61 100644 --- a/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/history/PollHistoryViewTest.kt +++ b/features/poll/impl/src/test/kotlin/io/element/android/features/poll/impl/history/PollHistoryViewTest.kt @@ -114,7 +114,7 @@ class PollHistoryViewTest { eventsRecorder.assertEmpty() rule.clickOn(CommonStrings.action_ok) eventsRecorder.assertSingle( - PollHistoryEvents.PollEndClicked(eventId) + PollHistoryEvents.EndPoll(eventId) ) } @@ -142,7 +142,7 @@ class PollHistoryViewTest { ) rule.onNodeWithText(answer.text).performClick() eventsRecorder.assertSingle( - PollHistoryEvents.PollAnswerSelected(eventId, answer.id) + PollHistoryEvents.SelectPollAnswer(eventId, answer.id) ) } @@ -156,7 +156,7 @@ class PollHistoryViewTest { ) rule.clickOn(R.string.screen_polls_history_filter_past) eventsRecorder.assertSingle( - PollHistoryEvents.OnFilterSelected(filter = PollHistoryFilter.PAST) + PollHistoryEvents.SelectFilter(filter = PollHistoryFilter.PAST) ) } 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 e488d911ed..0453fa0ce2 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 @@ -45,7 +45,7 @@ interface PreferencesEntryPoint : FeatureEntryPoint { interface Callback : Plugin { fun onOpenBugReport() - fun onSecureBackupClicked() + fun onSecureBackupClick() fun onOpenRoomNotificationSettings(roomId: RoomId) } } diff --git a/features/preferences/impl/build.gradle.kts b/features/preferences/impl/build.gradle.kts index edfb275f17..44616d0566 100644 --- a/features/preferences/impl/build.gradle.kts +++ b/features/preferences/impl/build.gradle.kts @@ -57,6 +57,7 @@ dependencies { implementation(projects.libraries.mediaupload.api) implementation(projects.libraries.permissions.api) implementation(projects.libraries.push.api) + implementation(projects.libraries.pushproviders.api) implementation(projects.features.rageshake.api) implementation(projects.features.lockscreen.api) implementation(projects.features.analytics.api) @@ -90,6 +91,7 @@ dependencies { testImplementation(projects.features.rageshake.test) testImplementation(projects.features.rageshake.impl) testImplementation(projects.libraries.indicator.impl) + testImplementation(projects.libraries.pushproviders.test) testImplementation(projects.features.logout.impl) testImplementation(projects.services.analytics.test) testImplementation(projects.services.toolbox.test) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt index b93e02dd39..8fef6053a4 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 @@ -115,8 +115,8 @@ class PreferencesFlowNode @AssistedInject constructor( plugins().forEach { it.onOpenBugReport() } } - override fun onSecureBackupClicked() { - plugins().forEach { it.onSecureBackupClicked() } + override fun onSecureBackupClick() { + plugins().forEach { it.onSecureBackupClick() } } override fun onOpenAnalytics() { @@ -151,7 +151,7 @@ class PreferencesFlowNode @AssistedInject constructor( backstack.push(NavTarget.BlockedUsers) } - override fun onSignOutClicked() { + override fun onSignOutClick() { backstack.push(NavTarget.SignOut) } } @@ -180,7 +180,7 @@ class PreferencesFlowNode @AssistedInject constructor( backstack.push(NavTarget.EditDefaultNotificationSetting(isOneToOne)) } - override fun onTroubleshootNotificationsClicked() { + override fun onTroubleshootNotificationsClick() { backstack.push(NavTarget.TroubleshootNotifications) } } @@ -212,17 +212,15 @@ class PreferencesFlowNode @AssistedInject constructor( createNode(buildContext, listOf(inputs)) } NavTarget.LockScreenSettings -> { - lockScreenEntryPoint.nodeBuilder(this, buildContext) - .target(LockScreenEntryPoint.Target.Settings) - .build() + lockScreenEntryPoint.nodeBuilder(this, buildContext, LockScreenEntryPoint.Target.Settings).build() } NavTarget.BlockedUsers -> { createNode(buildContext) } NavTarget.SignOut -> { val callBack: LogoutEntryPoint.Callback = object : LogoutEntryPoint.Callback { - override fun onChangeRecoveryKeyClicked() { - plugins().forEach { it.onSecureBackupClicked() } + override fun onChangeRecoveryKeyClick() { + plugins().forEach { it.onSecureBackupClick() } } } logoutEntryPoint.nodeBuilder(this, buildContext) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/about/AboutNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/about/AboutNode.kt index 5936b26b1a..bc2ff7894b 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/about/AboutNode.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/about/AboutNode.kt @@ -36,7 +36,7 @@ class AboutNode @AssistedInject constructor( @Assisted plugins: List, private val presenter: AboutPresenter, ) : Node(buildContext, plugins = plugins) { - private fun onElementLegalClicked( + private fun onElementLegalClick( activity: Activity, darkTheme: Boolean, elementLegal: ElementLegal, @@ -51,9 +51,9 @@ class AboutNode @AssistedInject constructor( val state = presenter.present() AboutView( state = state, - onBackPressed = ::navigateUp, - onElementLegalClicked = { elementLegal -> - onElementLegalClicked(activity, isDark, elementLegal) + onBackClick = ::navigateUp, + onElementLegalClick = { elementLegal -> + onElementLegalClick(activity, isDark, elementLegal) }, modifier = modifier ) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/about/AboutView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/about/AboutView.kt index c581811b6b..4a55217275 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/about/AboutView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/about/AboutView.kt @@ -29,19 +29,19 @@ import io.element.android.libraries.ui.strings.CommonStrings @Composable fun AboutView( state: AboutState, - onElementLegalClicked: (ElementLegal) -> Unit, - onBackPressed: () -> Unit, + onElementLegalClick: (ElementLegal) -> Unit, + onBackClick: () -> Unit, modifier: Modifier = Modifier, ) { PreferencePage( modifier = modifier, - onBackPressed = onBackPressed, + onBackClick = onBackClick, title = stringResource(id = CommonStrings.common_about) ) { state.elementLegals.forEach { elementLegal -> PreferenceText( title = stringResource(id = elementLegal.titleRes), - onClick = { onElementLegalClicked(elementLegal) } + onClick = { onElementLegalClick(elementLegal) } ) } } @@ -52,7 +52,7 @@ fun AboutView( internal fun AboutViewPreview(@PreviewParameter(AboutStateProvider::class) state: AboutState) = ElementPreview { AboutView( state = state, - onElementLegalClicked = {}, - onBackPressed = {}, + onElementLegalClick = {}, + onBackClick = {}, ) } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsEvents.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsEvents.kt index 8ee433f630..ab4987f9d9 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsEvents.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsEvents.kt @@ -19,7 +19,6 @@ package io.element.android.features.preferences.impl.advanced import io.element.android.compound.theme.Theme sealed interface AdvancedSettingsEvents { - data class SetRichTextEditorEnabled(val enabled: Boolean) : AdvancedSettingsEvents data class SetDeveloperModeEnabled(val enabled: Boolean) : AdvancedSettingsEvents data class SetSharePresenceEnabled(val enabled: Boolean) : AdvancedSettingsEvents data object ChangeTheme : AdvancedSettingsEvents diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsNode.kt index 45d2a07a71..8989fba04a 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsNode.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsNode.kt @@ -38,7 +38,7 @@ class AdvancedSettingsNode @AssistedInject constructor( AdvancedSettingsView( state = state, modifier = modifier, - onBackPressed = ::navigateUp + onBackClick = ::navigateUp ) } } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenter.kt index 2f0c2b7417..d6fbfb4c24 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenter.kt @@ -38,9 +38,6 @@ class AdvancedSettingsPresenter @Inject constructor( @Composable override fun present(): AdvancedSettingsState { val localCoroutineScope = rememberCoroutineScope() - val isRichTextEditorEnabled by appPreferencesStore - .isRichTextEditorEnabledFlow() - .collectAsState(initial = false) val isDeveloperModeEnabled by appPreferencesStore .isDeveloperModeEnabledFlow() .collectAsState(initial = false) @@ -52,11 +49,9 @@ class AdvancedSettingsPresenter @Inject constructor( } .collectAsState(initial = Theme.System) var showChangeThemeDialog by remember { mutableStateOf(false) } + fun handleEvents(event: AdvancedSettingsEvents) { when (event) { - is AdvancedSettingsEvents.SetRichTextEditorEnabled -> localCoroutineScope.launch { - appPreferencesStore.setRichTextEditorEnabled(event.enabled) - } is AdvancedSettingsEvents.SetDeveloperModeEnabled -> localCoroutineScope.launch { appPreferencesStore.setDeveloperModeEnabled(event.enabled) } @@ -73,7 +68,6 @@ class AdvancedSettingsPresenter @Inject constructor( } return AdvancedSettingsState( - isRichTextEditorEnabled = isRichTextEditorEnabled, isDeveloperModeEnabled = isDeveloperModeEnabled, isSharePresenceEnabled = isSharePresenceEnabled, theme = theme, diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsState.kt index 469f8a630c..527515d867 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsState.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsState.kt @@ -19,7 +19,6 @@ package io.element.android.features.preferences.impl.advanced import io.element.android.compound.theme.Theme data class AdvancedSettingsState( - val isRichTextEditorEnabled: Boolean, val isDeveloperModeEnabled: Boolean, val isSharePresenceEnabled: Boolean, val theme: Theme, diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsStateProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsStateProvider.kt index 5e255fc091..21ccec52e4 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsStateProvider.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsStateProvider.kt @@ -23,7 +23,6 @@ open class AdvancedSettingsStateProvider : PreviewParameterProvider get() = sequenceOf( aAdvancedSettingsState(), - aAdvancedSettingsState(isRichTextEditorEnabled = true), aAdvancedSettingsState(isDeveloperModeEnabled = true), aAdvancedSettingsState(showChangeThemeDialog = true), aAdvancedSettingsState(isSendPublicReadReceiptsEnabled = true), @@ -31,15 +30,14 @@ open class AdvancedSettingsStateProvider : PreviewParameterProvider Unit = {}, ) = AdvancedSettingsState( - isRichTextEditorEnabled = isRichTextEditorEnabled, isDeveloperModeEnabled = isDeveloperModeEnabled, isSharePresenceEnabled = isSendPublicReadReceiptsEnabled, theme = Theme.System, showChangeThemeDialog = showChangeThemeDialog, - eventSink = {} + eventSink = eventSink ) 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 9e739c3a00..38311ee6ef 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 @@ -38,12 +38,12 @@ import kotlinx.collections.immutable.toImmutableList @Composable fun AdvancedSettingsView( state: AdvancedSettingsState, - onBackPressed: () -> Unit, + onBackClick: () -> Unit, modifier: Modifier = Modifier, ) { PreferencePage( modifier = modifier, - onBackPressed = onBackPressed, + onBackClick = onBackClick, title = stringResource(id = CommonStrings.common_advanced_settings) ) { ListItem( @@ -57,18 +57,6 @@ fun AdvancedSettingsView( state.eventSink(AdvancedSettingsEvents.ChangeTheme) } ) - ListItem( - headlineContent = { - Text(text = stringResource(id = CommonStrings.common_rich_text_editor)) - }, - supportingContent = { - Text(text = stringResource(id = R.string.screen_advanced_settings_rich_text_editor_description)) - }, - trailingContent = ListItemContent.Switch( - checked = state.isRichTextEditorEnabled, - ), - onClick = { state.eventSink(AdvancedSettingsEvents.SetRichTextEditorEnabled(!state.isRichTextEditorEnabled)) } - ) ListItem( headlineContent = { Text(text = stringResource(id = CommonStrings.action_view_source)) @@ -99,7 +87,7 @@ fun AdvancedSettingsView( SingleSelectionDialog( options = getOptions(), initialSelection = themes.indexOf(state.theme), - onOptionSelected = { + onSelectOption = { state.eventSink( AdvancedSettingsEvents.SetTheme( themes[it] @@ -133,5 +121,5 @@ private fun Theme.toHumanReadable(): String { @Composable internal fun AdvancedSettingsViewPreview(@PreviewParameter(AdvancedSettingsStateProvider::class) state: AdvancedSettingsState) = ElementPreview { - AdvancedSettingsView(state = state, onBackPressed = { }) + AdvancedSettingsView(state = state, onBackClick = { }) } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/analytics/AnalyticsSettingsNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/analytics/AnalyticsSettingsNode.kt index 1ef0ec10e6..49d65819b9 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/analytics/AnalyticsSettingsNode.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/analytics/AnalyticsSettingsNode.kt @@ -37,7 +37,7 @@ class AnalyticsSettingsNode @AssistedInject constructor( val state = presenter.present() AnalyticsSettingsView( state = state, - onBackPressed = ::navigateUp, + onBackClick = ::navigateUp, modifier = modifier ) } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/analytics/AnalyticsSettingsView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/analytics/AnalyticsSettingsView.kt index c41249b1e2..67c12f31a1 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/analytics/AnalyticsSettingsView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/analytics/AnalyticsSettingsView.kt @@ -29,12 +29,12 @@ import io.element.android.libraries.ui.strings.CommonStrings @Composable fun AnalyticsSettingsView( state: AnalyticsSettingsState, - onBackPressed: () -> Unit, + onBackClick: () -> Unit, modifier: Modifier = Modifier, ) { PreferencePage( modifier = modifier, - onBackPressed = onBackPressed, + onBackClick = onBackClick, title = stringResource(id = CommonStrings.common_analytics) ) { AnalyticsPreferencesView( @@ -48,6 +48,6 @@ fun AnalyticsSettingsView( internal fun AnalyticsSettingsViewPreview(@PreviewParameter(AnalyticsSettingsStateProvider::class) state: AnalyticsSettingsState) = ElementPreview { AnalyticsSettingsView( state = state, - onBackPressed = {}, + onBackClick = {}, ) } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersNode.kt index e277ecc9b2..ba253bf549 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersNode.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersNode.kt @@ -37,7 +37,7 @@ class BlockedUsersNode @AssistedInject constructor( val state = presenter.present() BlockedUsersView( state = state, - onBackPressed = ::navigateUp, + onBackClick = ::navigateUp, modifier = modifier, ) } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersPresenter.kt index 5424fd737d..5a8d9200e5 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersPresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersPresenter.kt @@ -21,20 +21,26 @@ import androidx.compose.runtime.MutableState import androidx.compose.runtime.collectAsState 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 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.featureflag.api.FeatureFlagService +import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.user.MatrixUser +import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import javax.inject.Inject class BlockedUsersPresenter @Inject constructor( private val matrixClient: MatrixClient, + private val featureFlagService: FeatureFlagService, ) : Presenter { @Composable override fun present(): BlockedUsersState { @@ -47,7 +53,24 @@ class BlockedUsersPresenter @Inject constructor( mutableStateOf(AsyncAction.Uninitialized) } + val renderBlockedUsersDetail = featureFlagService + .isFeatureEnabledFlow(FeatureFlags.ShowBlockedUsersDetails) + .collectAsState(initial = false) val ignoredUserIds by matrixClient.ignoredUsersFlow.collectAsState() + val ignoredMatrixUser by produceState( + initialValue = ignoredUserIds.map { MatrixUser(userId = it) }, + key1 = renderBlockedUsersDetail.value, + key2 = ignoredUserIds + ) { + value = ignoredUserIds.map { + if (renderBlockedUsersDetail.value) { + matrixClient.getProfile(it).getOrNull() + } else { + null + } + ?: MatrixUser(userId = it) + } + } fun handleEvents(event: BlockedUsersEvents) { when (event) { @@ -68,7 +91,7 @@ class BlockedUsersPresenter @Inject constructor( } } return BlockedUsersState( - blockedUsers = ignoredUserIds, + blockedUsers = ignoredMatrixUser.toPersistentList(), unblockUserAction = unblockUserAction.value, eventSink = ::handleEvents ) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersState.kt index 8b5209a0cd..42ba3d5a11 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersState.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersState.kt @@ -17,11 +17,11 @@ package io.element.android.features.preferences.impl.blockedusers 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 import kotlinx.collections.immutable.ImmutableList data class BlockedUsersState( - val blockedUsers: ImmutableList, + val blockedUsers: ImmutableList, val unblockUserAction: AsyncAction, val eventSink: (BlockedUsersEvents) -> Unit, ) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersStatePreviewProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersStatePreviewProvider.kt index 0b5466ed04..a09213a333 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersStatePreviewProvider.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersStatePreviewProvider.kt @@ -18,7 +18,7 @@ package io.element.android.features.preferences.impl.blockedusers import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.libraries.architecture.AsyncAction -import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.ui.components.aMatrixUserList import kotlinx.collections.immutable.toPersistentList @@ -26,10 +26,9 @@ class BlockedUsersStatePreviewProvider : PreviewParameterProvider get() = sequenceOf( aBlockedUsersState(), + aBlockedUsersState(blockedUsers = aMatrixUserList().map { it.copy(displayName = null, avatarUrl = null) }), aBlockedUsersState(blockedUsers = emptyList()), aBlockedUsersState(unblockUserAction = AsyncAction.Confirming), - // Sadly there's no good way to preview Loading or Failure states since they're presented with an animation - // All these 3 screen states will be displayed as the Uninitialized one aBlockedUsersState(unblockUserAction = AsyncAction.Loading), aBlockedUsersState(unblockUserAction = AsyncAction.Failure(Throwable("Failed to unblock user"))), aBlockedUsersState(unblockUserAction = AsyncAction.Success(Unit)), @@ -37,12 +36,13 @@ class BlockedUsersStatePreviewProvider : PreviewParameterProvider = aMatrixUserList().map { it.userId }, + blockedUsers: List = aMatrixUserList(), unblockUserAction: AsyncAction = AsyncAction.Uninitialized, + eventSink: (BlockedUsersEvents) -> Unit = {}, ): BlockedUsersState { return BlockedUsersState( blockedUsers = blockedUsers.toPersistentList(), unblockUserAction = unblockUserAction, - eventSink = {}, + eventSink = eventSink, ) } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersView.kt index 190950e645..680f22cc45 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersView.kt @@ -51,7 +51,7 @@ import io.element.android.libraries.ui.strings.CommonStrings @Composable fun BlockedUsersView( state: BlockedUsersState, - onBackPressed: () -> Unit, + onBackClick: () -> Unit, modifier: Modifier = Modifier, ) { Box(modifier = modifier) { @@ -65,7 +65,7 @@ fun BlockedUsersView( ) }, navigationIcon = { - BackButton(onClick = onBackPressed) + BackButton(onClick = onBackClick) } ) } @@ -73,9 +73,9 @@ fun BlockedUsersView( LazyColumn( modifier = Modifier.padding(padding) ) { - items(state.blockedUsers) { userId -> + items(state.blockedUsers) { matrixUser -> BlockedUserItem( - userId = userId, + matrixUser = matrixUser, onClick = { state.eventSink(BlockedUsersEvents.Unblock(it)) } ) } @@ -110,7 +110,7 @@ fun BlockedUsersView( title = stringResource(R.string.screen_blocked_users_unblock_alert_title), content = stringResource(R.string.screen_blocked_users_unblock_alert_description), submitText = stringResource(R.string.screen_blocked_users_unblock_alert_action), - onSubmitClicked = { state.eventSink(BlockedUsersEvents.ConfirmUnblock) }, + onSubmitClick = { state.eventSink(BlockedUsersEvents.ConfirmUnblock) }, onDismiss = { state.eventSink(BlockedUsersEvents.Cancel) } ) } @@ -121,12 +121,12 @@ fun BlockedUsersView( @Composable private fun BlockedUserItem( - userId: UserId, + matrixUser: MatrixUser, onClick: (UserId) -> Unit, ) { MatrixUserRow( - modifier = Modifier.clickable { onClick(userId) }, - matrixUser = MatrixUser(userId), + modifier = Modifier.clickable { onClick(matrixUser.userId) }, + matrixUser = matrixUser, ) } @@ -136,7 +136,7 @@ internal fun BlockedUsersViewPreview(@PreviewParameter(BlockedUsersStatePreviewP ElementPreview { BlockedUsersView( state = state, - onBackPressed = {} + onBackClick = {} ) } } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsNode.kt index 8cec8fa85f..412f89e14f 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsNode.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsNode.kt @@ -59,7 +59,7 @@ class DeveloperSettingsNode @AssistedInject constructor( modifier = modifier, onOpenShowkase = ::openShowkase, onOpenConfigureTracing = ::onOpenConfigureTracing, - onBackPressed = ::navigateUp + onBackClick = ::navigateUp ) } } 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 5a40534b87..dd684fcaaa 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 @@ -39,16 +39,19 @@ fun DeveloperSettingsView( state: DeveloperSettingsState, onOpenShowkase: () -> Unit, onOpenConfigureTracing: () -> Unit, - onBackPressed: () -> Unit, + onBackClick: () -> Unit, modifier: Modifier = Modifier, ) { PreferencePage( modifier = modifier, - onBackPressed = onBackPressed, + onBackClick = onBackClick, title = stringResource(id = CommonStrings.common_developer_options) ) { // Note: this is OK to hardcode strings in this debug screen. - PreferenceCategory(title = "Feature flags") { + PreferenceCategory( + title = "Feature flags", + showTopDivider = false, + ) { FeatureListContent(state) } ElementCallCategory(state = state) @@ -67,14 +70,14 @@ fun DeveloperSettingsView( RageshakePreferencesView( state = state.rageshakeState, ) - PreferenceCategory(title = "Crash", showDivider = false) { + PreferenceCategory(title = "Crash", showTopDivider = false) { PreferenceText( title = "Crash the app 💥", onClick = { error("This crash is a test.") } ) } val cache = state.cacheSize - PreferenceCategory(title = "Cache", showDivider = false) { + PreferenceCategory(title = "Cache", showTopDivider = false) { PreferenceText( title = "Clear cache", currentValue = cache.dataOrNull(), @@ -93,11 +96,12 @@ fun DeveloperSettingsView( private fun ElementCallCategory( state: DeveloperSettingsState, ) { - PreferenceCategory(title = "Element Call", showDivider = true) { + PreferenceCategory(title = "Element Call", showTopDivider = true) { val callUrlState = state.customElementCallBaseUrlState fun isUsingDefaultUrl(value: String?): Boolean { return value.isNullOrEmpty() || value == callUrlState.defaultUrl } + val supportingText = if (isUsingDefaultUrl(callUrlState.baseUrl)) { stringResource(R.string.screen_advanced_settings_element_call_base_url_description) } else { @@ -137,6 +141,6 @@ internal fun DeveloperSettingsViewPreview(@PreviewParameter(DeveloperSettingsSta state = state, onOpenShowkase = {}, onOpenConfigureTracing = {}, - onBackPressed = {} + onBackClick = {} ) } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/tracing/ConfigureTracingNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/tracing/ConfigureTracingNode.kt index b95f85dbeb..a624f38d08 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/tracing/ConfigureTracingNode.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/tracing/ConfigureTracingNode.kt @@ -37,7 +37,7 @@ class ConfigureTracingNode @AssistedInject constructor( val state = presenter.present() ConfigureTracingView( state = state, - onBackPressed = ::navigateUp, + onBackClick = ::navigateUp, modifier = modifier ) } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/tracing/ConfigureTracingView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/tracing/ConfigureTracingView.kt index bf77762a64..c44b9e4ad5 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/tracing/ConfigureTracingView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/tracing/ConfigureTracingView.kt @@ -62,7 +62,7 @@ import kotlinx.collections.immutable.ImmutableMap @Composable fun ConfigureTracingView( state: ConfigureTracingState, - onBackPressed: () -> Unit, + onBackClick: () -> Unit, modifier: Modifier = Modifier, ) { var showMenu by remember { mutableStateOf(false) } @@ -75,7 +75,7 @@ fun ConfigureTracingView( topBar = { TopAppBar( navigationIcon = { - BackButton(onClick = onBackPressed) + BackButton(onClick = onBackClick) }, title = { Text( @@ -234,6 +234,6 @@ internal fun ConfigureTracingViewPreview( ) = ElementPreview { ConfigureTracingView( state = state, - onBackPressed = {}, + onBackClick = {}, ) } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsEvents.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsEvents.kt index f38c211b67..72896fd613 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsEvents.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsEvents.kt @@ -25,4 +25,7 @@ sealed interface NotificationSettingsEvents { data object FixConfigurationMismatch : NotificationSettingsEvents data object ClearConfigurationMismatchError : NotificationSettingsEvents data object ClearNotificationChangeError : NotificationSettingsEvents + data object ChangePushProvider : NotificationSettingsEvents + data object CancelChangePushProvider : NotificationSettingsEvents + data class SetPushProvider(val index: Int) : NotificationSettingsEvents } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsNode.kt index 621b0ed8b1..0c6e7660ef 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsNode.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsNode.kt @@ -35,7 +35,7 @@ class NotificationSettingsNode @AssistedInject constructor( ) : Node(buildContext, plugins = plugins) { interface Callback : Plugin { fun editDefaultNotificationMode(isOneToOne: Boolean) - fun onTroubleshootNotificationsClicked() + fun onTroubleshootNotificationsClick() } private val callbacks = plugins() @@ -44,8 +44,8 @@ class NotificationSettingsNode @AssistedInject constructor( callbacks.forEach { it.editDefaultNotificationMode(isOneToOne) } } - private fun onTroubleshootNotificationsClicked() { - callbacks.forEach { it.onTroubleshootNotificationsClicked() } + private fun onTroubleshootNotificationsClick() { + callbacks.forEach { it.onTroubleshootNotificationsClick() } } @Composable @@ -54,8 +54,8 @@ class NotificationSettingsNode @AssistedInject constructor( NotificationSettingsView( state = state, onOpenEditDefault = { openEditDefault(isOneToOne = it) }, - onBackPressed = ::navigateUp, - onTroubleshootNotificationsClicked = ::onTroubleshootNotificationsClicked, + onBackClick = ::navigateUp, + onTroubleshootNotificationsClick = ::onTroubleshootNotificationsClick, modifier = modifier, ) } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenter.kt index 9aa9cafb81..5cfc14545e 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenter.kt @@ -20,17 +20,25 @@ 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.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue 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.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService import io.element.android.libraries.matrix.api.room.RoomNotificationMode +import io.element.android.libraries.push.api.PushService +import io.element.android.libraries.pushproviders.api.Distributor +import io.element.android.libraries.pushproviders.api.PushProvider import io.element.android.libraries.pushstore.api.UserPushStore import io.element.android.libraries.pushstore.api.UserPushStoreFactory +import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.debounce @@ -44,7 +52,8 @@ class NotificationSettingsPresenter @Inject constructor( private val notificationSettingsService: NotificationSettingsService, private val userPushStoreFactory: UserPushStoreFactory, private val matrixClient: MatrixClient, - private val systemNotificationsEnabledProvider: SystemNotificationsEnabledProvider + private val pushService: PushService, + private val systemNotificationsEnabledProvider: SystemNotificationsEnabledProvider, ) : Presenter { @Composable override fun present(): NotificationSettingsState { @@ -68,6 +77,60 @@ class NotificationSettingsPresenter @Inject constructor( observeNotificationSettings(matrixSettings) } + // List of PushProvider -> Distributor + val distributors = remember { + pushService.getAvailablePushProviders() + .flatMap { pushProvider -> + pushProvider.getDistributors().map { distributor -> + pushProvider to distributor + } + } + } + // List of Distributor names + val distributorNames = remember { + distributors.map { it.second.name }.toImmutableList() + } + + var currentDistributorName by remember { mutableStateOf>(AsyncData.Uninitialized) } + var refreshPushProvider by remember { mutableIntStateOf(0) } + + LaunchedEffect(refreshPushProvider) { + val p = pushService.getCurrentPushProvider() + val name = p?.getCurrentDistributor(matrixClient)?.name + currentDistributorName = if (name != null) { + AsyncData.Success(name) + } else { + AsyncData.Failure(Exception("Failed to get current push provider")) + } + } + + var showChangePushProviderDialog by remember { mutableStateOf(false) } + + fun CoroutineScope.changePushProvider( + data: Pair? + ) = launch { + showChangePushProviderDialog = false + data ?: return@launch + // No op if the value is the same. + if (data.second.name == currentDistributorName.dataOrNull()) return@launch + currentDistributorName = AsyncData.Loading(currentDistributorName.dataOrNull()) + data.let { (pushProvider, distributor) -> + pushService.registerWith( + matrixClient = matrixClient, + pushProvider = pushProvider, + distributor = distributor + ) + .fold( + { + refreshPushProvider++ + }, + { + currentDistributorName = AsyncData.Failure(it) + } + ) + } + } + fun handleEvents(event: NotificationSettingsEvents) { when (event) { is NotificationSettingsEvents.SetAtRoomNotificationsEnabled -> { @@ -88,6 +151,9 @@ class NotificationSettingsPresenter @Inject constructor( systemNotificationsEnabled.value = systemNotificationsEnabledProvider.notificationsEnabled() } NotificationSettingsEvents.ClearNotificationChangeError -> changeNotificationSettingAction.value = AsyncAction.Uninitialized + NotificationSettingsEvents.ChangePushProvider -> showChangePushProviderDialog = true + NotificationSettingsEvents.CancelChangePushProvider -> showChangePushProviderDialog = false + is NotificationSettingsEvents.SetPushProvider -> localCoroutineScope.changePushProvider(distributors.getOrNull(event.index)) } } @@ -98,6 +164,9 @@ class NotificationSettingsPresenter @Inject constructor( appNotificationsEnabled = appNotificationsEnabled.value ), changeNotificationSettingAction = changeNotificationSettingAction.value, + currentPushDistributor = currentDistributorName, + availablePushDistributors = distributorNames, + showChangePushProviderDialog = showChangePushProviderDialog, eventSink = ::handleEvents ) } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsState.kt index 5c23e74cd5..b545213c7a 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsState.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsState.kt @@ -18,13 +18,18 @@ package io.element.android.features.preferences.impl.notifications import androidx.compose.runtime.Immutable import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.matrix.api.room.RoomNotificationMode +import kotlinx.collections.immutable.ImmutableList @Immutable data class NotificationSettingsState( val matrixSettings: MatrixSettings, val appSettings: AppSettings, val changeNotificationSettingAction: AsyncAction, + val currentPushDistributor: AsyncData, + val availablePushDistributors: ImmutableList, + val showChangePushProviderDialog: Boolean, val eventSink: (NotificationSettingsEvents) -> Unit, ) { sealed interface MatrixSettings { @@ -46,4 +51,10 @@ data class NotificationSettingsState( val systemNotificationsEnabled: Boolean, val appNotificationsEnabled: Boolean, ) + + /** + * Whether the advanced settings should be shown. + * This is true if the current push distributor is in a failure state or if there are multiple push distributors available. + */ + val showAdvancedSettings: Boolean = currentPushDistributor.isFailure() || availablePushDistributors.size > 1 } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsStateProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsStateProvider.kt index dc1e972aa6..5685804e06 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsStateProvider.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsStateProvider.kt @@ -18,14 +18,26 @@ package io.element.android.features.preferences.impl.notifications import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.matrix.api.room.RoomNotificationMode +import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList open class NotificationSettingsStateProvider : PreviewParameterProvider { override val values: Sequence get() = sequenceOf( + aValidNotificationSettingsState(systemNotificationsEnabled = false), aValidNotificationSettingsState(), aValidNotificationSettingsState(changeNotificationSettingAction = AsyncAction.Loading), aValidNotificationSettingsState(changeNotificationSettingAction = AsyncAction.Failure(Throwable("error"))), + aValidNotificationSettingsState( + availablePushDistributors = listOf("Firebase"), + changeNotificationSettingAction = AsyncAction.Failure(Throwable("error")), + ), + aValidNotificationSettingsState(availablePushDistributors = listOf("Firebase")), + aValidNotificationSettingsState(showChangePushProviderDialog = true), + aValidNotificationSettingsState(currentPushDistributor = AsyncData.Loading()), + aValidNotificationSettingsState(currentPushDistributor = AsyncData.Failure(Exception("Failed to change distributor"))), aInvalidNotificationSettingsState(), aInvalidNotificationSettingsState(fixFailed = true), ) @@ -36,7 +48,11 @@ fun aValidNotificationSettingsState( atRoomNotificationsEnabled: Boolean = true, callNotificationsEnabled: Boolean = true, inviteForMeNotificationsEnabled: Boolean = true, + systemNotificationsEnabled: Boolean = true, appNotificationEnabled: Boolean = true, + currentPushDistributor: AsyncData = AsyncData.Success("Firebase"), + availablePushDistributors: List = listOf("Firebase", "ntfy"), + showChangePushProviderDialog: Boolean = false, eventSink: (NotificationSettingsEvents) -> Unit = {}, ) = NotificationSettingsState( matrixSettings = NotificationSettingsState.MatrixSettings.Valid( @@ -47,10 +63,13 @@ fun aValidNotificationSettingsState( defaultOneToOneNotificationMode = RoomNotificationMode.ALL_MESSAGES, ), appSettings = NotificationSettingsState.AppSettings( - systemNotificationsEnabled = false, + systemNotificationsEnabled = systemNotificationsEnabled, appNotificationsEnabled = appNotificationEnabled, ), changeNotificationSettingAction = changeNotificationSettingAction, + currentPushDistributor = currentPushDistributor, + availablePushDistributors = availablePushDistributors.toImmutableList(), + showChangePushProviderDialog = showChangePushProviderDialog, eventSink = eventSink, ) @@ -66,5 +85,8 @@ fun aInvalidNotificationSettingsState( appNotificationsEnabled = true, ), changeNotificationSettingAction = AsyncAction.Uninitialized, + currentPushDistributor = AsyncData.Uninitialized, + availablePushDistributors = persistentListOf(), + showChangePushProviderDialog = false, eventSink = eventSink, ) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsView.kt index d62f972d71..cf5127cee0 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsView.kt @@ -16,28 +16,38 @@ package io.element.android.features.preferences.impl.notifications +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.progressSemantics import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp import androidx.lifecycle.Lifecycle import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.features.preferences.impl.R import io.element.android.libraries.androidutils.system.startNotificationSettingsIntent +import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.designsystem.atomic.molecules.DialogLikeBannerMolecule import io.element.android.libraries.designsystem.components.async.AsyncActionView import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog +import io.element.android.libraries.designsystem.components.dialogs.ListOption +import io.element.android.libraries.designsystem.components.dialogs.SingleSelectionDialog +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.PreferencePage import io.element.android.libraries.designsystem.components.preferences.PreferenceSwitch import io.element.android.libraries.designsystem.components.preferences.PreferenceText 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.ListItem +import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.utils.OnLifecycleEvent import io.element.android.libraries.matrix.api.room.RoomNotificationMode import io.element.android.libraries.ui.strings.CommonStrings +import kotlinx.collections.immutable.toImmutableList /** * A view that allows a user edit their global notification settings. @@ -46,8 +56,8 @@ import io.element.android.libraries.ui.strings.CommonStrings fun NotificationSettingsView( state: NotificationSettingsState, onOpenEditDefault: (isOneToOne: Boolean) -> Unit, - onTroubleshootNotificationsClicked: () -> Unit, - onBackPressed: () -> Unit, + onTroubleshootNotificationsClick: () -> Unit, + onBackClick: () -> Unit, modifier: Modifier = Modifier, ) { OnLifecycleEvent { _, event -> @@ -58,27 +68,27 @@ fun NotificationSettingsView( } PreferencePage( modifier = modifier, - onBackPressed = onBackPressed, + onBackClick = onBackClick, title = stringResource(id = R.string.screen_notification_settings_title) ) { when (state.matrixSettings) { is NotificationSettingsState.MatrixSettings.Invalid -> InvalidNotificationSettingsView( showError = state.matrixSettings.fixFailed, - onContinueClicked = { state.eventSink(NotificationSettingsEvents.FixConfigurationMismatch) }, + onContinueClick = { state.eventSink(NotificationSettingsEvents.FixConfigurationMismatch) }, onDismissError = { state.eventSink(NotificationSettingsEvents.ClearConfigurationMismatchError) }, ) NotificationSettingsState.MatrixSettings.Uninitialized -> return@PreferencePage is NotificationSettingsState.MatrixSettings.Valid -> NotificationSettingsContentView( matrixSettings = state.matrixSettings, - systemSettings = state.appSettings, - onNotificationsEnabledChanged = { state.eventSink(NotificationSettingsEvents.SetNotificationsEnabled(it)) }, - onGroupChatsClicked = { onOpenEditDefault(false) }, - onDirectChatsClicked = { onOpenEditDefault(true) }, - onMentionNotificationsChanged = { state.eventSink(NotificationSettingsEvents.SetAtRoomNotificationsEnabled(it)) }, + state = state, + onNotificationsEnabledChange = { state.eventSink(NotificationSettingsEvents.SetNotificationsEnabled(it)) }, + onGroupChatsClick = { onOpenEditDefault(false) }, + onDirectChatsClick = { onOpenEditDefault(true) }, + onMentionNotificationsChange = { state.eventSink(NotificationSettingsEvents.SetAtRoomNotificationsEnabled(it)) }, // TODO We are removing the call notification toggle until support for call notifications has been added // onCallsNotificationsChanged = { state.eventSink(NotificationSettingsEvents.SetCallNotificationsEnabled(it)) }, - onInviteForMeNotificationsChanged = { state.eventSink(NotificationSettingsEvents.SetInviteForMeNotificationsEnabled(it)) }, - onTroubleshootNotificationsClicked = onTroubleshootNotificationsClicked, + onInviteForMeNotificationsChange = { state.eventSink(NotificationSettingsEvents.SetInviteForMeNotificationsEnabled(it)) }, + onTroubleshootNotificationsClick = onTroubleshootNotificationsClick, ) } AsyncActionView( @@ -93,17 +103,18 @@ fun NotificationSettingsView( @Composable private fun NotificationSettingsContentView( matrixSettings: NotificationSettingsState.MatrixSettings.Valid, - systemSettings: NotificationSettingsState.AppSettings, - onNotificationsEnabledChanged: (Boolean) -> Unit, - onGroupChatsClicked: () -> Unit, - onDirectChatsClicked: () -> Unit, - onMentionNotificationsChanged: (Boolean) -> Unit, + state: NotificationSettingsState, + onNotificationsEnabledChange: (Boolean) -> Unit, + onGroupChatsClick: () -> Unit, + onDirectChatsClick: () -> Unit, + onMentionNotificationsChange: (Boolean) -> Unit, // TODO We are removing the call notification toggle until support for call notifications has been added // onCallsNotificationsChanged: (Boolean) -> Unit, - onInviteForMeNotificationsChanged: (Boolean) -> Unit, - onTroubleshootNotificationsClicked: () -> Unit, + onInviteForMeNotificationsChange: (Boolean) -> Unit, + onTroubleshootNotificationsClick: () -> Unit, ) { val context = LocalContext.current + val systemSettings: NotificationSettingsState.AppSettings = state.appSettings if (systemSettings.appNotificationsEnabled && !systemSettings.systemNotificationsEnabled) { PreferenceText( icon = CompoundIcons.NotificationsOffSolid(), @@ -121,8 +132,7 @@ private fun NotificationSettingsContentView( PreferenceSwitch( title = stringResource(id = R.string.screen_notification_settings_enable_notifications), isChecked = systemSettings.appNotificationsEnabled, - switchAlignment = Alignment.Top, - onCheckedChange = onNotificationsEnabledChanged + onCheckedChange = onNotificationsEnabledChange ) if (systemSettings.appNotificationsEnabled) { @@ -130,13 +140,13 @@ private fun NotificationSettingsContentView( PreferenceText( title = stringResource(id = R.string.screen_notification_settings_group_chats), subtitle = getTitleForRoomNotificationMode(mode = matrixSettings.defaultGroupNotificationMode), - onClick = onGroupChatsClicked + onClick = onGroupChatsClick ) PreferenceText( title = stringResource(id = R.string.screen_notification_settings_direct_chats), subtitle = getTitleForRoomNotificationMode(mode = matrixSettings.defaultOneToOneNotificationMode), - onClick = onDirectChatsClicked + onClick = onDirectChatsClick ) } @@ -145,8 +155,7 @@ private fun NotificationSettingsContentView( modifier = Modifier, title = stringResource(id = R.string.screen_notification_settings_room_mention_label), isChecked = matrixSettings.atRoomNotificationsEnabled, - switchAlignment = Alignment.Top, - onCheckedChange = onMentionNotificationsChanged + onCheckedChange = onMentionNotificationsChange ) } PreferenceCategory(title = stringResource(id = R.string.screen_notification_settings_additional_settings_section_title)) { @@ -162,17 +171,62 @@ private fun NotificationSettingsContentView( modifier = Modifier, title = stringResource(id = R.string.screen_notification_settings_invite_for_me_label), isChecked = matrixSettings.inviteForMeNotificationsEnabled, - switchAlignment = Alignment.Top, - onCheckedChange = onInviteForMeNotificationsChanged + onCheckedChange = onInviteForMeNotificationsChange ) } PreferenceCategory(title = stringResource(id = R.string.troubleshoot_notifications_entry_point_section)) { PreferenceText( modifier = Modifier, title = stringResource(id = R.string.troubleshoot_notifications_entry_point_title), - onClick = onTroubleshootNotificationsClicked + onClick = onTroubleshootNotificationsClick ) } + if (state.showAdvancedSettings) { + PreferenceCategory(title = stringResource(id = CommonStrings.common_advanced_settings)) { + ListItem( + headlineContent = { + Text(text = stringResource(id = R.string.screen_advanced_settings_push_provider_android)) + }, + trailingContent = when (state.currentPushDistributor) { + AsyncData.Uninitialized, + is AsyncData.Loading -> ListItemContent.Custom { + CircularProgressIndicator( + modifier = Modifier + .progressSemantics() + .size(20.dp), + strokeWidth = 2.dp + ) + } + is AsyncData.Failure -> ListItemContent.Text( + stringResource(id = CommonStrings.common_error) + ) + is AsyncData.Success -> ListItemContent.Text( + state.currentPushDistributor.dataOrNull() ?: "" + ) + }, + onClick = { + if (state.currentPushDistributor.isReady()) { + state.eventSink(NotificationSettingsEvents.ChangePushProvider) + } + } + ) + } + if (state.showChangePushProviderDialog) { + SingleSelectionDialog( + title = stringResource(id = R.string.screen_advanced_settings_choose_distributor_dialog_title_android), + options = state.availablePushDistributors.map { + ListOption(title = it) + }.toImmutableList(), + initialSelection = state.availablePushDistributors.indexOf(state.currentPushDistributor.dataOrNull()), + onSelectOption = { index -> + state.eventSink( + NotificationSettingsEvents.SetPushProvider(index) + ) + }, + onDismissRequest = { state.eventSink(NotificationSettingsEvents.CancelChangePushProvider) }, + ) + } + } } } @@ -188,14 +242,14 @@ private fun getTitleForRoomNotificationMode(mode: RoomNotificationMode?) = @Composable private fun InvalidNotificationSettingsView( showError: Boolean, - onContinueClicked: () -> Unit, + onContinueClick: () -> Unit, onDismissError: () -> Unit, ) { DialogLikeBannerMolecule( title = stringResource(R.string.screen_notification_settings_configuration_mismatch), content = stringResource(R.string.screen_notification_settings_configuration_mismatch_description), - onSubmitClicked = onContinueClicked, - onDismissClicked = null, + onSubmitClick = onContinueClick, + onDismissClick = null, ) if (showError) { @@ -212,8 +266,8 @@ private fun InvalidNotificationSettingsView( internal fun NotificationSettingsViewPreview(@PreviewParameter(NotificationSettingsStateProvider::class) state: NotificationSettingsState) = ElementPreview { NotificationSettingsView( state = state, - onBackPressed = {}, + onBackClick = {}, onOpenEditDefault = {}, - onTroubleshootNotificationsClicked = {}, + onTroubleshootNotificationsClick = {}, ) } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/SystemNotificationsEnabledProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/SystemNotificationsEnabledProvider.kt index 9aaec23e94..3adbd7528e 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/SystemNotificationsEnabledProvider.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/SystemNotificationsEnabledProvider.kt @@ -16,11 +16,9 @@ package io.element.android.features.preferences.impl.notifications -import android.content.Context import androidx.core.app.NotificationManagerCompat import com.squareup.anvil.annotations.ContributesBinding import io.element.android.libraries.di.AppScope -import io.element.android.libraries.di.ApplicationContext import io.element.android.libraries.di.SingleIn import javax.inject.Inject @@ -29,11 +27,11 @@ interface SystemNotificationsEnabledProvider { } @SingleIn(AppScope::class) -@ContributesBinding(AppScope::class, boundType = SystemNotificationsEnabledProvider::class) +@ContributesBinding(AppScope::class) class DefaultSystemNotificationsEnabledProvider @Inject constructor( - @ApplicationContext private val context: Context, + private val notificationManager: NotificationManagerCompat, ) : SystemNotificationsEnabledProvider { override fun notificationsEnabled(): Boolean { - return NotificationManagerCompat.from(context).areNotificationsEnabled() + return notificationManager.areNotificationsEnabled() } } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/DefaultNotificationSettingOption.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/DefaultNotificationSettingOption.kt index b5a79947af..e6d1234d04 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/DefaultNotificationSettingOption.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/DefaultNotificationSettingOption.kt @@ -30,7 +30,7 @@ import io.element.android.libraries.matrix.api.room.RoomNotificationMode @Composable fun DefaultNotificationSettingOption( mode: RoomNotificationMode, - onOptionSelected: (RoomNotificationMode) -> Unit, + onSelectOption: (RoomNotificationMode) -> Unit, displayMentionsOnlyDisclaimer: Boolean, modifier: Modifier = Modifier, isSelected: Boolean = false, @@ -51,7 +51,7 @@ fun DefaultNotificationSettingOption( headlineContent = { Text(title) }, supportingContent = subtitle?.let { { Text(it) } }, trailingContent = ListItemContent.RadioButton(selected = isSelected), - onClick = { onOptionSelected(mode) }, + onClick = { onSelectOption(mode) }, ) } @@ -63,19 +63,19 @@ internal fun DefaultNotificationSettingOptionPreview() = ElementPreview { mode = RoomNotificationMode.ALL_MESSAGES, isSelected = true, displayMentionsOnlyDisclaimer = false, - onOptionSelected = {}, + onSelectOption = {}, ) DefaultNotificationSettingOption( mode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY, isSelected = false, displayMentionsOnlyDisclaimer = false, - onOptionSelected = {}, + onSelectOption = {}, ) DefaultNotificationSettingOption( mode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY, isSelected = false, displayMentionsOnlyDisclaimer = true, - onOptionSelected = {}, + onSelectOption = {}, ) } } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingNode.kt index de365878ad..92dc1f94f6 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingNode.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingNode.kt @@ -58,7 +58,7 @@ class EditDefaultNotificationSettingNode @AssistedInject constructor( EditDefaultNotificationSettingView( state = state, openRoomNotificationSettings = { openRoomNotificationSettings(it) }, - onBackPressed = ::navigateUp, + onBackClick = ::navigateUp, modifier = modifier, ) } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingView.kt index 1938a4b4f0..f13aa90c4d 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingView.kt @@ -47,7 +47,7 @@ import io.element.android.libraries.ui.strings.CommonStrings fun EditDefaultNotificationSettingView( state: EditDefaultNotificationSettingState, openRoomNotificationSettings: (roomId: RoomId) -> Unit, - onBackPressed: () -> Unit, + onBackClick: () -> Unit, modifier: Modifier = Modifier, ) { val title = if (state.isOneToOne) { @@ -57,7 +57,7 @@ fun EditDefaultNotificationSettingView( } PreferencePage( modifier = modifier, - onBackPressed = onBackPressed, + onBackClick = onBackClick, title = stringResource(id = title) ) { // Only ALL_MESSAGES and MENTIONS_AND_KEYWORDS_ONLY are valid global defaults. @@ -68,7 +68,10 @@ fun EditDefaultNotificationSettingView( } else { R.string.screen_notification_settings_edit_screen_group_section_header } - PreferenceCategory(title = stringResource(id = categoryTitle)) { + PreferenceCategory( + title = stringResource(id = categoryTitle), + showTopDivider = false, + ) { if (state.mode != null) { Column(modifier = Modifier.selectableGroup()) { validModes.forEach { item -> @@ -76,7 +79,7 @@ fun EditDefaultNotificationSettingView( mode = item, isSelected = state.mode == item, displayMentionsOnlyDisclaimer = state.displayMentionsOnlyDisclaimer, - onOptionSelected = { state.eventSink(EditDefaultNotificationSettingStateEvents.SetNotificationMode(it)) } + onSelectOption = { state.eventSink(EditDefaultNotificationSettingStateEvents.SetNotificationMode(it)) } ) } } @@ -137,6 +140,6 @@ internal fun EditDefaultNotificationSettingViewPreview( EditDefaultNotificationSettingView( state = state, openRoomNotificationSettings = {}, - onBackPressed = {}, + onBackClick = {}, ) } 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 11f3fc3dd3..106f07ecc2 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 @@ -44,7 +44,7 @@ class PreferencesRootNode @AssistedInject constructor( ) : Node(buildContext, plugins = plugins) { interface Callback : Plugin { fun onOpenBugReport() - fun onSecureBackupClicked() + fun onSecureBackupClick() fun onOpenAnalytics() fun onOpenAbout() fun onOpenDeveloperSettings() @@ -53,15 +53,15 @@ class PreferencesRootNode @AssistedInject constructor( fun onOpenAdvancedSettings() fun onOpenUserProfile(matrixUser: MatrixUser) fun onOpenBlockedUsers() - fun onSignOutClicked() + fun onSignOutClick() } private fun onOpenBugReport() { plugins().forEach { it.onOpenBugReport() } } - private fun onSecureBackupClicked() { - plugins().forEach { it.onSecureBackupClicked() } + private fun onSecureBackupClick() { + plugins().forEach { it.onSecureBackupClick() } } private fun onOpenDeveloperSettings() { @@ -80,7 +80,7 @@ class PreferencesRootNode @AssistedInject constructor( plugins().forEach { it.onOpenAbout() } } - private fun onManageAccountClicked( + private fun onManageAccountClick( activity: Activity, url: String?, isDark: Boolean, @@ -117,8 +117,8 @@ class PreferencesRootNode @AssistedInject constructor( plugins().forEach { it.onOpenBlockedUsers() } } - private fun onSignOutClicked() { - plugins().forEach { it.onSignOutClicked() } + private fun onSignOutClick() { + plugins().forEach { it.onSignOutClick() } } @Composable @@ -129,23 +129,23 @@ class PreferencesRootNode @AssistedInject constructor( PreferencesRootView( state = state, modifier = modifier, - onBackPressed = this::navigateUp, + onBackClick = this::navigateUp, onOpenRageShake = this::onOpenBugReport, onOpenAnalytics = this::onOpenAnalytics, onOpenAbout = this::onOpenAbout, - onSecureBackupClicked = this::onSecureBackupClicked, + onSecureBackupClick = this::onSecureBackupClick, onOpenDeveloperSettings = this::onOpenDeveloperSettings, onOpenAdvancedSettings = this::onOpenAdvancedSettings, - onManageAccountClicked = { onManageAccountClicked(activity, it, isDark) }, + onManageAccountClick = { onManageAccountClick(activity, it, isDark) }, onOpenNotificationSettings = this::onOpenNotificationSettings, onOpenLockScreenSettings = this::onOpenLockScreenSettings, onOpenUserProfile = this::onOpenUserProfile, onOpenBlockedUsers = this::onOpenBlockedUsers, - onSignOutClicked = { + onSignOutClick = { if (state.directLogoutState.canDoDirectSignOut) { state.directLogoutState.eventSink(DirectLogoutEvents.Logout(ignoreSdkError = false)) } else { - onSignOutClicked() + onSignOutClick() } }, ) 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 20c28427a8..86992d4ac7 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt @@ -74,7 +74,7 @@ class PreferencesRootPresenter @Inject constructor( } // We should display the 'complete verification' option if the current session can be verified - val canVerifyUserSession by sessionVerificationService.canVerifySessionFlow.collectAsState(false) + val canVerifyUserSession by sessionVerificationService.needsSessionVerification.collectAsState(false) val showSecureBackupIndicator by indicatorService.showSettingChatBackupIndicator() 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 a70f73fa6e..3ec69f25bf 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 @@ -52,9 +52,9 @@ import io.element.android.libraries.ui.strings.CommonStrings @Composable fun PreferencesRootView( state: PreferencesRootState, - onBackPressed: () -> Unit, - onSecureBackupClicked: () -> Unit, - onManageAccountClicked: (url: String) -> Unit, + onBackClick: () -> Unit, + onSecureBackupClick: () -> Unit, + onManageAccountClick: (url: String) -> Unit, onOpenAnalytics: () -> Unit, onOpenRageShake: () -> Unit, onOpenLockScreenSettings: () -> Unit, @@ -64,7 +64,7 @@ fun PreferencesRootView( onOpenNotificationSettings: () -> Unit, onOpenUserProfile: (MatrixUser) -> Unit, onOpenBlockedUsers: () -> Unit, - onSignOutClicked: () -> Unit, + onSignOutClick: () -> Unit, modifier: Modifier = Modifier, ) { val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage) @@ -72,7 +72,7 @@ fun PreferencesRootView( // Include pref from other modules PreferencePage( modifier = modifier, - onBackPressed = onBackPressed, + onBackClick = onBackClick, title = stringResource(id = CommonStrings.common_settings), snackbarHost = { SnackbarHost(snackbarHostState) } ) { @@ -88,13 +88,13 @@ fun PreferencesRootView( state = state, onOpenNotificationSettings = onOpenNotificationSettings, onOpenLockScreenSettings = onOpenLockScreenSettings, - onSecureBackupClicked = onSecureBackupClicked, + onSecureBackupClick = onSecureBackupClick, ) // 'Account' section ManageAccountSection( state = state, - onManageAccountClicked = onManageAccountClicked, + onManageAccountClick = onManageAccountClick, onOpenBlockedUsers = onOpenBlockedUsers ) @@ -106,7 +106,7 @@ fun PreferencesRootView( onOpenRageShake = onOpenRageShake, onOpenAdvancedSettings = onOpenAdvancedSettings, onOpenDeveloperSettings = onOpenDeveloperSettings, - onSignOutClicked = onSignOutClicked, + onSignOutClick = onSignOutClick, ) Footer( @@ -121,7 +121,7 @@ private fun ColumnScope.ManageAppSection( state: PreferencesRootState, onOpenNotificationSettings: () -> Unit, onOpenLockScreenSettings: () -> Unit, - onSecureBackupClicked: () -> Unit, + onSecureBackupClick: () -> Unit, ) { if (state.showNotificationSettings) { ListItem( @@ -142,7 +142,7 @@ private fun ColumnScope.ManageAppSection( headlineContent = { Text(stringResource(id = CommonStrings.common_chat_backup)) }, leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.KeySolid())), trailingContent = ListItemContent.Badge.takeIf { state.showSecureBackupBadge }, - onClick = onSecureBackupClicked, + onClick = onSecureBackupClick, ) } if (state.showNotificationSettings || state.showLockScreenSettings || state.showSecureBackup) { @@ -153,7 +153,7 @@ private fun ColumnScope.ManageAppSection( @Composable private fun ColumnScope.ManageAccountSection( state: PreferencesRootState, - onManageAccountClicked: (url: String) -> Unit, + onManageAccountClick: (url: String) -> Unit, onOpenBlockedUsers: () -> Unit, ) { state.accountManagementUrl?.let { url -> @@ -161,7 +161,7 @@ private fun ColumnScope.ManageAccountSection( headlineContent = { Text(stringResource(id = CommonStrings.action_manage_account)) }, leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.UserProfile())), trailingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.PopOut())), - onClick = { onManageAccountClicked(url) }, + onClick = { onManageAccountClick(url) }, ) } @@ -170,7 +170,7 @@ private fun ColumnScope.ManageAccountSection( headlineContent = { Text(stringResource(id = CommonStrings.action_manage_devices)) }, leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Devices())), trailingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.PopOut())), - onClick = { onManageAccountClicked(url) }, + onClick = { onManageAccountClick(url) }, ) } @@ -195,7 +195,7 @@ private fun ColumnScope.GeneralSection( onOpenRageShake: () -> Unit, onOpenAdvancedSettings: () -> Unit, onOpenDeveloperSettings: () -> Unit, - onSignOutClicked: () -> Unit, + onSignOutClick: () -> Unit, ) { ListItem( headlineContent = { Text(stringResource(id = CommonStrings.common_about)) }, @@ -226,7 +226,7 @@ private fun ColumnScope.GeneralSection( headlineContent = { Text(stringResource(id = CommonStrings.action_signout)) }, leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.SignOut())), style = ListItemStyle.Destructive, - onClick = onSignOutClicked, + onClick = onSignOutClick, ) } @@ -279,18 +279,18 @@ internal fun PreferencesRootViewDarkPreview(@PreviewParameter(MatrixUserProvider private fun ContentToPreview(matrixUser: MatrixUser) { PreferencesRootView( state = aPreferencesRootState(myUser = matrixUser), - onBackPressed = {}, + onBackClick = {}, onOpenAnalytics = {}, onOpenRageShake = {}, onOpenDeveloperSettings = {}, onOpenAdvancedSettings = {}, onOpenAbout = {}, - onSecureBackupClicked = {}, - onManageAccountClicked = {}, + onSecureBackupClick = {}, + onManageAccountClick = {}, onOpenNotificationSettings = {}, onOpenLockScreenSettings = {}, onOpenUserProfile = {}, onOpenBlockedUsers = {}, - onSignOutClicked = {}, + onSignOutClick = {}, ) } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileNode.kt index 4fcbb94cf1..46b2f8f9d3 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileNode.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/user/editprofile/EditUserProfileNode.kt @@ -47,8 +47,8 @@ class EditUserProfileNode @AssistedInject constructor( val state = presenter.present() EditUserProfileView( state = state, - onBackPressed = ::navigateUp, - onProfileEdited = ::navigateUp, + onBackClick = ::navigateUp, + onEditProfileSuccess = ::navigateUp, modifier = modifier ) } 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 c37dba8c3a..d85936d5d8 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 @@ -24,7 +24,7 @@ import io.element.android.libraries.permissions.api.PermissionsState import kotlinx.collections.immutable.ImmutableList data class EditUserProfileState( - val userId: UserId?, + val userId: UserId, val displayName: String, val userAvatarUrl: Uri?, val avatarActions: ImmutableList, 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 a0d0fcfad6..167c301a35 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 @@ -25,12 +25,10 @@ import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material.ExperimentalMaterialApi -import androidx.compose.material.ModalBottomSheetValue -import androidx.compose.material.rememberModalBottomSheetState import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.runtime.Composable -import androidx.compose.runtime.rememberCoroutineScope +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 @@ -57,27 +55,21 @@ import io.element.android.libraries.matrix.ui.components.AvatarActionBottomSheet import io.element.android.libraries.matrix.ui.components.EditableAvatarView import io.element.android.libraries.permissions.api.PermissionsView import io.element.android.libraries.ui.strings.CommonStrings -import kotlinx.coroutines.launch -@OptIn(ExperimentalMaterialApi::class, ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class) @Composable fun EditUserProfileView( state: EditUserProfileState, - onBackPressed: () -> Unit, - onProfileEdited: () -> Unit, + onBackClick: () -> Unit, + onEditProfileSuccess: () -> Unit, modifier: Modifier = Modifier, ) { - val coroutineScope = rememberCoroutineScope() val focusManager = LocalFocusManager.current - val itemActionsBottomSheetState = rememberModalBottomSheetState( - initialValue = ModalBottomSheetValue.Hidden, - ) + val isAvatarActionsSheetVisible = remember { mutableStateOf(false) } - fun onAvatarClicked() { + fun onAvatarClick() { focusManager.clearFocus() - coroutineScope.launch { - itemActionsBottomSheetState.show() - } + isAvatarActionsSheetVisible.value = true } Scaffold( @@ -90,7 +82,7 @@ fun EditUserProfileView( style = ElementTheme.typography.aliasScreenTitle, ) }, - navigationIcon = { BackButton(onClick = onBackPressed) }, + navigationIcon = { BackButton(onClick = onBackClick) }, actions = { TextButton( text = stringResource(CommonStrings.action_save), @@ -114,22 +106,20 @@ fun EditUserProfileView( ) { Spacer(modifier = Modifier.height(24.dp)) EditableAvatarView( - userId = state.userId?.value, + matrixId = state.userId.value, displayName = state.displayName, avatarUrl = state.userAvatarUrl, avatarSize = AvatarSize.RoomHeader, - onAvatarClicked = { onAvatarClicked() }, + onAvatarClick = { onAvatarClick() }, modifier = Modifier.align(Alignment.CenterHorizontally), ) Spacer(modifier = Modifier.height(16.dp)) - state.userId?.let { - Text( - modifier = Modifier.fillMaxWidth(), - text = it.value, - style = ElementTheme.typography.fontBodyLgRegular, - textAlign = TextAlign.Center, - ) - } + Text( + modifier = Modifier.fillMaxWidth(), + text = state.userId.value, + style = ElementTheme.typography.fontBodyLgRegular, + textAlign = TextAlign.Center, + ) Spacer(modifier = Modifier.height(40.dp)) LabelledOutlinedTextField( label = stringResource(R.string.screen_edit_profile_display_name), @@ -142,8 +132,9 @@ fun EditUserProfileView( AvatarActionBottomSheet( actions = state.avatarActions, - modalBottomSheetState = itemActionsBottomSheetState, - onActionSelected = { state.eventSink(EditUserProfileEvents.HandleAvatarAction(it)) } + isVisible = isAvatarActionsSheetVisible.value, + onDismiss = { isAvatarActionsSheetVisible.value = false }, + onSelectAction = { state.eventSink(EditUserProfileEvents.HandleAvatarAction(it)) } ) AsyncActionView( @@ -153,7 +144,7 @@ fun EditUserProfileView( progressText = stringResource(R.string.screen_edit_profile_updating_details), ) }, - onSuccess = { onProfileEdited() }, + onSuccess = { onEditProfileSuccess() }, errorTitle = { stringResource(R.string.screen_edit_profile_error_title) }, errorMessage = { stringResource(R.string.screen_edit_profile_error) }, onErrorDismiss = { state.eventSink(EditUserProfileEvents.CancelSaveChanges) }, @@ -169,8 +160,8 @@ fun EditUserProfileView( internal fun EditUserProfileViewPreview(@PreviewParameter(EditUserProfileStateProvider::class) state: EditUserProfileState) = ElementPreview { EditUserProfileView( - onBackPressed = {}, - onProfileEdited = {}, + onBackClick = {}, + onEditProfileSuccess = {}, state = state, ) } diff --git a/features/preferences/impl/src/main/res/values-be/translations.xml b/features/preferences/impl/src/main/res/values-be/translations.xml index 450a47ce61..33d1b2706e 100644 --- a/features/preferences/impl/src/main/res/values-be/translations.xml +++ b/features/preferences/impl/src/main/res/values-be/translations.xml @@ -1,10 +1,12 @@ + "Выберыце спосаб атрымання апавяшчэнняў" "Рэжым распрацоўшчыка" "Падайце распрацоўнікам доступ да функцый і функцыянальным магчымасцям." "Базавы URL сервера званкоў Element" "Задайце свой сервер Element Call." "Адрас пазначаны няправільна, пераканайцеся, што вы ўказалі пратакол (http/https) і правільны адрас." + "Пастаўшчык push-апавяшчэнняў" "Адключыць рэдактар фарматаванага тэксту і ўключыць Markdown." "Апавяшчэнні аб чытанні" "Калі выключыць, вашы пасведчанні аб прачытанні нікому не будуць адпраўляцца. Вы па-ранейшаму будзеце атрымліваць пасведчанні аб прачытанні ад іншых карыстальнікаў." diff --git a/features/preferences/impl/src/main/res/values-cs/translations.xml b/features/preferences/impl/src/main/res/values-cs/translations.xml index 60bc4b2b3e..587623d4e5 100644 --- a/features/preferences/impl/src/main/res/values-cs/translations.xml +++ b/features/preferences/impl/src/main/res/values-cs/translations.xml @@ -1,10 +1,12 @@ + "Vyberte, jak chcete přijímat oznámení" "Vývojářský režim" "Povolením získáte přístup k funkcím a funkcím pro vývojáře." "Vlastní URL pro Element Call" "Nastavte vlastní URL pro Element Call." "Neplatné URL, ujistěte se, že jste uvedli protokol (http/https) a správnou adresu." + "Poskytovatel push oznámení" "Vypněte editor formátovaného textu pro ruční zadání Markdown." "Potvrzení o přečtení" "Pokud je vypnuto, potvrzení o přečtení se nikomu neodesílají. Stále budete dostávat potvrzení o přečtení od ostatních uživatelů." diff --git a/features/preferences/impl/src/main/res/values-de/translations.xml b/features/preferences/impl/src/main/res/values-de/translations.xml index 1faac8c4eb..3013315b21 100644 --- a/features/preferences/impl/src/main/res/values-de/translations.xml +++ b/features/preferences/impl/src/main/res/values-de/translations.xml @@ -1,5 +1,6 @@ + "Wähle aus, wie du Benachrichtigungen erhalten möchtest" "Entwickler-Modus" "Aktivieren, um Zugriff auf Features und Funktionen für Entwickler zu aktivieren." "Benutzerdefinierte Element-Aufruf-Basis-URL" diff --git a/features/preferences/impl/src/main/res/values-es/translations.xml b/features/preferences/impl/src/main/res/values-es/translations.xml index 2a6de80c05..db20929504 100644 --- a/features/preferences/impl/src/main/res/values-es/translations.xml +++ b/features/preferences/impl/src/main/res/values-es/translations.xml @@ -1,15 +1,22 @@ + "Elige cómo recibir las notificaciones" "Modo desarrollador" "Habilita para tener acceso a características y funcionalidades para desarrolladores." "URL base personalizada de Element Call" "Define una URL base personalizada para Element Call." "URL no válida, asegúrate de incluir el protocolo (http/https) y la dirección correcta." "Desactiva el editor de texto enriquecido para escribir Markdown manualmente." + "Confirmaciones de lectura" + "Si se desactiva, las confirmaciones de lectura no se enviarán a nadie. Seguirás recibiendo confirmaciones de lectura de otros usuarios." + "Compartir presencia" + "Si se desactiva, no podrás enviar ni recibir confirmaciones de lectura ni notificaciones de escritura" "Habilita la opción para ver el contenido en bruto del mensaje en la cronología." + "No tienes usuarios bloqueados" "Desbloquear" "Podrás ver todos sus mensajes de nuevo." "Desbloquear usuario" + "Desbloqueando…" "Nombre público" "Tu nombre visible" "Se encontró un error desconocido y no se pudo cambiar la información." diff --git a/features/preferences/impl/src/main/res/values-fr/translations.xml b/features/preferences/impl/src/main/res/values-fr/translations.xml index 9c415fea2d..ac12390e76 100644 --- a/features/preferences/impl/src/main/res/values-fr/translations.xml +++ b/features/preferences/impl/src/main/res/values-fr/translations.xml @@ -1,10 +1,12 @@ + "Choisissez le mode de réception des notifications" "Mode développeur" "Activer pour pouvoir accéder aux fonctionnalités destinées aux développeurs." "URL de base pour Element Call personnalisée" "Configurer une URL de base pour Element Call." "URL invalide, assurez-vous d’inclure le protocol (http/https) et l’adresse correcte." + "Fournisseur de Push" "Désactivez l’éditeur de texte enrichi pour saisir manuellement du Markdown." "Accusés de lecture" "En cas de désactivation, vos accusés de lecture ne seront pas envoyés aux autres membres. Vous verrez toujours les accusés des autres membres." diff --git a/features/preferences/impl/src/main/res/values-hu/translations.xml b/features/preferences/impl/src/main/res/values-hu/translations.xml index 841695edcf..964e2dc58e 100644 --- a/features/preferences/impl/src/main/res/values-hu/translations.xml +++ b/features/preferences/impl/src/main/res/values-hu/translations.xml @@ -1,5 +1,6 @@ + "Válassza ki az értesítések fogadási módját" "Fejlesztői mód" "Engedélyezze, hogy elérje a fejlesztőknek szánt funkciókat." "Egyéni Element Call alapwebcím" diff --git a/features/preferences/impl/src/main/res/values-in/translations.xml b/features/preferences/impl/src/main/res/values-in/translations.xml index 8bca603eeb..2068782846 100644 --- a/features/preferences/impl/src/main/res/values-in/translations.xml +++ b/features/preferences/impl/src/main/res/values-in/translations.xml @@ -1,5 +1,6 @@ + "Pilih cara menerima notifikasi" "Mode pengembang" "Aktifkan untuk mengakses fitur dan fungsi untuk para pengembang." "URL dasar Element Call khusus" diff --git a/features/preferences/impl/src/main/res/values-it/translations.xml b/features/preferences/impl/src/main/res/values-it/translations.xml index e15213218c..b8a483d290 100644 --- a/features/preferences/impl/src/main/res/values-it/translations.xml +++ b/features/preferences/impl/src/main/res/values-it/translations.xml @@ -1,5 +1,6 @@ + "Scegli come ricevere le notifiche" "Modalità sviluppatore" "Attiva per avere accesso alle funzionalità per sviluppatori." "URL base di Element Call personalizzato" diff --git a/features/preferences/impl/src/main/res/values-ka/translations.xml b/features/preferences/impl/src/main/res/values-ka/translations.xml new file mode 100644 index 0000000000..f27eac6d40 --- /dev/null +++ b/features/preferences/impl/src/main/res/values-ka/translations.xml @@ -0,0 +1,44 @@ + + + "აირჩიეთ, თუ როგორ გსურთ შეტყობინებების მიღება" + "დეველოპერის რეჟიმი" + "ჩართეთ დეველოპერების ფუნქციებზე წვდომა." + "მორგებული Element-ის ზარის საბაზისო URL" + "დააყენეთ საბაზისო URL Element-ის ზარებისათვის." + "არასწორი URL, გთხოვთ, დარწმუნდეთ, რომ შეიტანეთ პროტოკოლი (http/https) და სწორი მისამართი." + "გამორთეთ მდიდარი ტექსტის რედაქტორი, რათა ხელით აკრიფოთ Markdown." + "განბლოკვა" + "თქვენ კვლავ შეძლებთ მათგან ყველა შეტყობინების ნახვას." + "Მომხმარებლის განბლოკვა" + "ნაჩვენები სახელი" + "თქვენი ნაჩვენები სახელი" + "დაფიქსირდა უცნობი შეცდომა და ინფორმაციის შეცვლა ვერ მოხერხდა." + "პროფილის განახლება ვერ მოხერხდა" + "Პროფილის რედაქტირება" + "პროფილის განახლება…" + "დამატებითი პარამეტრები" + "აუდიო და ვიდეო ზარები" + "კონფიგურაციის შეუსაბამობა" + "ჩვენ გავამარტივეთ შეტყობინებების პარამეტრები, რათა გაგიადვილოთ ვარიანტების პოვნა. + +თქვენ მიერ წარსულში არჩეული ზოგიერთი მორგებული პარამეტრი აქ არ არის ნაჩვენები, მაგრამ ისინი კვლავ აქტიურია. თუ გააგრძელებთ, თქვენი ზოგიერთი პარამეტრი შეიძლება შეიცვალოს." + "პირდაპირი ჩატები" + "მორგებული პარამეტრი ჩატზე" + "შეტყობინებების პარამეტრის განახლებისას მოხდა შეცდომა." + "ყველა შეტყობინება" + "მხოლოდ ხსენებები და საკვანძო სიტყვები" + "პირდაპირ ჩატებზე, შემატყობინეთ:" + "ჯგუფურ ჩატებზე, შემატყობინეთ:" + "შეტყობინებების ჩართვა ამ მოწყობილობაზე" + "კონფიგურაცია არ გამოსწორებულა, გთხოვთ, კვლავ სცადოთ." + "ჯგუფური ჩატები" + "ხსენებები" + "ყველა" + "ხსენებები" + "ჩემი შეტყობინება შემდეგისთვის:" + "ჩემი შეტყობინება @room-ზე" + "შეტყობინებების მისაღებად გთხოვთ შეცვალოთ %1$s." + "სისტემის პარამეტრები" + "სისტემის შეტყობინებები გამორთულია" + "შეტყობინებები" + diff --git a/features/preferences/impl/src/main/res/values-pt-rBR/translations.xml b/features/preferences/impl/src/main/res/values-pt-rBR/translations.xml new file mode 100644 index 0000000000..5986f8e93a --- /dev/null +++ b/features/preferences/impl/src/main/res/values-pt-rBR/translations.xml @@ -0,0 +1,56 @@ + + + "Escolhe como receber notificações" + "Modo de programador" + "Permite o acesso a funcionalidades para programadores." + "URL base para Element Call personalizado" + "Define um URL base para a Element Call." + "URL inválido, certifica-te de que incluis o protocolo (http/https) e o endereço correto." + "Fornecedor de envio" + "Desativa o editor de texto rico para poderes escrever Markdown manualmente." + "Recibos de leitura" + "Se desativada, os teus recibos de leitura não serão enviados a ninguém. Continuas a receber recibos de leitura de outros utilizadores." + "Partilhar presença" + "Se desativado, não poderás enviar ou receber recibos de leitura ou notificações de escrita" + "Ativa a opção para ver a origem da mensagem na cronologia." + "Não tens nenhum utilizador bloqueado" + "Desbloquear" + "Poderás voltar a ver todas as suas mensagens." + "Desbloquear utilizador" + "A desbloquear…" + "Pseudónimo" + "O teu pseudónimo" + "Foi encontrado um erro desconhecido e a informação não foi alterada." + "Não foi possível atualizar o perfil" + "Editar perfil" + "A atualizar o perfil…" + "Configurações adicionais" + "Chamadas de áudio e vídeo" + "Incompatibilidade de configuração" + "Simplificámos as configurações de notificação para tornar as opções mais fáceis de encontrar. Algumas configurações personalizadas que escolheste no passado não são mostradas aqui, mas continuam ativas. + +Se prosseguires, algumas delas podem ser alteradas." + "Diretas" + "Configuração personalizada por conversa" + "Erro ao atualizar a configuração de notificação." + "Qualquer mensagem" + "Menções ou palavras-chave" + "Em conversas diretas, notifica-me se receber" + "Em conversas de grupo, notifica-me se receber" + "Ativar as notificações neste dispositivo" + "A configuração não foi corrigida, tenta novamente." + "De grupo" + "Convites" + "O teu servidor não suporta esta opção em salas cifradas, pelo que poderás não ser notificado em algumas salas." + "Menções" + "Tudo" + "Menções" + "Conversas" + "Quando aparece uma @room" + "Para receberes notificações, altera as tuas %1$s." + "configurações do sistema" + "Notificações do sistema desativadas" + "Notificações" + "Resolução de problemas" + "Resolver problemas com as notificações" + diff --git a/features/preferences/impl/src/main/res/values-ro/translations.xml b/features/preferences/impl/src/main/res/values-ro/translations.xml index 75c0411aa8..b191002a32 100644 --- a/features/preferences/impl/src/main/res/values-ro/translations.xml +++ b/features/preferences/impl/src/main/res/values-ro/translations.xml @@ -1,5 +1,6 @@ + "Alegeți modul de primire a notificărilor" "Modul dezvoltator" "Activați pentru a avea acces la funcționalități pentru dezvoltatori." "Adresa URL de bază Element Call" @@ -11,9 +12,11 @@ "Împărtășiți prezența" "Dacă dezactivată, nu veți putea trimite sau primi chitanțe de citire sau notificări de tastare." "Activați opțiunea pentru a vizualiza sursa mesajelor." + "Nu aveți utilizatori blocați" "Deblocați" "La deblocarea utilizatorului, veți putea vedea din nou toate mesajele de la acesta." "Deblocați utilizatorul" + "Se deblochează…" "Nume" "Numele dumneavoastra" "A fost întâlnită o eroare necunoscută și informațiile nu au putut fi modificate." @@ -49,4 +52,6 @@ Dacă continuați, unele dintre setările dumneavoastră pot fi modificate.""Setări de sistem" "Notificările de sistem sunt dezactivate" "Notificări" + "Depanare" + "Depanați notificările" diff --git a/features/preferences/impl/src/main/res/values-ru/translations.xml b/features/preferences/impl/src/main/res/values-ru/translations.xml index 3b24cf8c80..3c4a49dd6d 100644 --- a/features/preferences/impl/src/main/res/values-ru/translations.xml +++ b/features/preferences/impl/src/main/res/values-ru/translations.xml @@ -1,10 +1,12 @@ + "Выберите способ получения уведомлений" "Режим разработчика" "Предоставьте разработчикам доступ к функциям и функциональным возможностям." "Базовый URL сервера звонков Element" "Задайте свой сервер Element Call." "Адрес указан неверно, удостоверьтесь, что вы указали протокол (http/https) и правильный адрес." + "Поставщик push-уведомлений" "Отключить редактор форматированного текста и включить Markdown." "Уведомления о прочтении" "Если этот параметр выключен, ваш статус о прочтении не будет отображаться. Вы по-прежнему будете видеть статус о прочтении от других пользователей." diff --git a/features/preferences/impl/src/main/res/values-sk/translations.xml b/features/preferences/impl/src/main/res/values-sk/translations.xml index f382d0ab6f..edabc77ec5 100644 --- a/features/preferences/impl/src/main/res/values-sk/translations.xml +++ b/features/preferences/impl/src/main/res/values-sk/translations.xml @@ -1,10 +1,12 @@ + "Vyberte spôsob prijímania oznámení" "Vývojársky režim" "Umožniť prístup k možnostiam a funkciám pre vývojárov." "Vlastná Element Call základná URL adresa" "Nastaviť vlastnú základnú URL adresu pre Element Call." "Neplatná adresa URL, uistite sa, že ste uviedli protokol (http/https) a správnu adresu." + "Poskytovateľ oznámení Push" "Vypnite rozšírený textový editor na ručné písanie Markdown." "Potvrdenia o prečítaní" "Ak je táto funkcia vypnutá, vaše potvrdenia o prečítaní sa nebudú nikomu odosielať. Stále budete dostávať potvrdenia o prečítaní od ostatných používateľov." diff --git a/features/preferences/impl/src/main/res/values-sv/translations.xml b/features/preferences/impl/src/main/res/values-sv/translations.xml index adcb60f9d5..bf91212cab 100644 --- a/features/preferences/impl/src/main/res/values-sv/translations.xml +++ b/features/preferences/impl/src/main/res/values-sv/translations.xml @@ -1,5 +1,6 @@ + "Välj hur du vill ta emot aviseringar" "Utvecklarläge" "Aktivera för att ha tillgång till funktionalitet för utvecklare." "Anpassad bas-URL för Element Call" @@ -14,6 +15,7 @@ "Avblockera" "Du kommer att kunna se alla meddelanden från dem igen." "Avblockera användare" + "Avblockerar …" "Visningsnamn" "Ditt visningsnamn" "Ett okänt fel påträffades och informationen kunde inte ändras." diff --git a/features/preferences/impl/src/main/res/values-uk/translations.xml b/features/preferences/impl/src/main/res/values-uk/translations.xml index 33478a51fb..0fdf2a67ad 100644 --- a/features/preferences/impl/src/main/res/values-uk/translations.xml +++ b/features/preferences/impl/src/main/res/values-uk/translations.xml @@ -1,5 +1,6 @@ + "Виберіть спосіб отримання сповіщень" "Режим розробника" "Увімкніть доступ до функцій і можливостей для розробників." "Користувацька URL-адреса Element Call" @@ -15,7 +16,7 @@ "Розблокувати" "Ви знову зможете бачити всі повідомлення від них." "Розблокувати користувача" - "Заблокування…" + "Розблокування…" "Відображуване ім\'я" "Ваше відображуване ім\'я" "Була виявлена невідома помилка, і інформацію не вдалося змінити." diff --git a/features/preferences/impl/src/main/res/values-zh-rTW/translations.xml b/features/preferences/impl/src/main/res/values-zh-rTW/translations.xml index 68ea61f94e..000be710d7 100644 --- a/features/preferences/impl/src/main/res/values-zh-rTW/translations.xml +++ b/features/preferences/impl/src/main/res/values-zh-rTW/translations.xml @@ -1,5 +1,6 @@ + "選擇接收通知的機制" "開發者模式" "分享動態" "解除封鎖" diff --git a/features/preferences/impl/src/main/res/values-zh/translations.xml b/features/preferences/impl/src/main/res/values-zh/translations.xml new file mode 100644 index 0000000000..5a4485ed14 --- /dev/null +++ b/features/preferences/impl/src/main/res/values-zh/translations.xml @@ -0,0 +1,55 @@ + + + "选择如何接收通知" + "开发者模式" + "允许开发人员访问特性和功能。" + "自定义 Element 通话 URL" + "为 Element 通话设置根 URL。" + "URL 无效,请确保包含协议(http/https)和正确的地址。" + "禁用富文本编辑器,手动输入 Markdown。" + "已读回执" + "如果关闭,您的已读回执将不会发送给别人。您仍能收到别人的已读回执。" + "分享在线状态" + "如果关闭,您将无法发送或接收已读回执、输入通知" + "启用在时间轴中查看消息来源的选项。" + "您没有屏蔽用户" + "解封" + "你可以重新接收他们的消息。" + "解封用户" + "正在解除屏蔽……" + "显示名称" + "你的显示名称" + "遇到未知错误,无法更改信息。" + "无法更新个人资料" + "编辑个人资料" + "更新个人资料……" + "更多设置" + "音视频通话" + "配置不匹配" + "我们简化了通知设置,使选项更易于查找。您过去选择的某些自定义设置未在此处显示,但它们仍然有效。 + +如果继续,您的某些设置可能会更改。" + "直接聊天" + "各聊天室的独立设置" + "更新通知设置时出错。" + "全部消息" + "仅限提及和关键词" + "在私聊中,请通知我:" + "在群聊中,请通知我:" + "在此设备上启用通知" + "配置尚未更正,请重试。" + "群聊" + "邀请" + "您的服务器在加密房间中不支持此选项,因此在某些房间您可能无法收到通知。" + "提及" + "全部" + "提及" + "请通知我:" + "在 @room 通知我" + "要接收通知,请更改您的 %1$s。" + "系统设置" + "系统通知已关闭" + "通知" + "排查问题" + "排查通知问题" + diff --git a/features/preferences/impl/src/main/res/values/localazy.xml b/features/preferences/impl/src/main/res/values/localazy.xml index 56a5c0ba03..d9b6b583ed 100644 --- a/features/preferences/impl/src/main/res/values/localazy.xml +++ b/features/preferences/impl/src/main/res/values/localazy.xml @@ -1,10 +1,12 @@ + "Choose how to receive notifications" "Developer mode" "Enable to have access to features and functionality for developers." "Custom Element Call base URL" "Set a custom base URL for Element Call." "Invalid URL, please make sure you include the protocol (http/https) and the correct address." + "Push notification provider" "Disable the rich text editor to type Markdown manually." "Read receipts" "If turned off, your read receipts won\'t be sent to anyone. You will still receive read receipts from other users." diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenterTest.kt index 8f6c45c51d..e3ca6bf8b4 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenterTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenterTest.kt @@ -41,7 +41,6 @@ class AdvancedSettingsPresenterTest { }.test { val initialState = awaitLastSequentialItem() assertThat(initialState.isDeveloperModeEnabled).isFalse() - assertThat(initialState.isRichTextEditorEnabled).isFalse() assertThat(initialState.showChangeThemeDialog).isFalse() assertThat(initialState.isSharePresenceEnabled).isTrue() assertThat(initialState.theme).isEqualTo(Theme.System) @@ -63,21 +62,6 @@ class AdvancedSettingsPresenterTest { } } - @Test - fun `present - rich text editor on off`() = runTest { - val presenter = createAdvancedSettingsPresenter() - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { - val initialState = awaitLastSequentialItem() - assertThat(initialState.isRichTextEditorEnabled).isFalse() - initialState.eventSink.invoke(AdvancedSettingsEvents.SetRichTextEditorEnabled(true)) - assertThat(awaitItem().isRichTextEditorEnabled).isTrue() - initialState.eventSink.invoke(AdvancedSettingsEvents.SetRichTextEditorEnabled(false)) - assertThat(awaitItem().isRichTextEditorEnabled).isFalse() - } - } - @Test fun `present - share presence off on`() = runTest { val presenter = createAdvancedSettingsPresenter() diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsViewTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsViewTest.kt new file mode 100644 index 0000000000..9d581f2e56 --- /dev/null +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsViewTest.kt @@ -0,0 +1,115 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.preferences.impl.advanced + +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.compound.theme.Theme +import io.element.android.features.preferences.impl.R +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 org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class AdvancedSettingsViewTest { + @get:Rule + val rule = createAndroidComposeRule() + + @Test + fun `clicking on back invokes the expected callback`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnce { + rule.setAdvancedSettingsView( + state = aAdvancedSettingsState( + eventSink = eventsRecorder + ), + onBackClick = it + ) + rule.pressBack() + } + } + + @Test + fun `clicking on Appearance emits the expected event`() { + val eventsRecorder = EventsRecorder() + rule.setAdvancedSettingsView( + state = aAdvancedSettingsState( + eventSink = eventsRecorder + ), + ) + rule.clickOn(CommonStrings.common_appearance) + eventsRecorder.assertSingle(AdvancedSettingsEvents.ChangeTheme) + } + + @Test + fun `clicking on other theme emits the expected event`() { + val eventsRecorder = EventsRecorder() + rule.setAdvancedSettingsView( + state = aAdvancedSettingsState( + eventSink = eventsRecorder, + showChangeThemeDialog = true + ), + ) + rule.clickOn(CommonStrings.common_dark) + eventsRecorder.assertSingle(AdvancedSettingsEvents.SetTheme(Theme.Dark)) + } + + @Test + fun `clicking on View source emits the expected event`() { + val eventsRecorder = EventsRecorder() + rule.setAdvancedSettingsView( + state = aAdvancedSettingsState( + eventSink = eventsRecorder, + ), + ) + rule.clickOn(CommonStrings.action_view_source) + eventsRecorder.assertSingle(AdvancedSettingsEvents.SetDeveloperModeEnabled(true)) + } + + @Test + fun `clicking on Share presence emits the expected event`() { + val eventsRecorder = EventsRecorder() + rule.setAdvancedSettingsView( + state = aAdvancedSettingsState( + eventSink = eventsRecorder, + ), + ) + rule.clickOn(R.string.screen_advanced_settings_share_presence) + eventsRecorder.assertSingle(AdvancedSettingsEvents.SetSharePresenceEnabled(true)) + } +} + +private fun AndroidComposeTestRule.setAdvancedSettingsView( + state: AdvancedSettingsState, + onBackClick: () -> Unit = EnsureNeverCalled(), +) { + setContent { + AdvancedSettingsView( + state = state, + onBackClick = onBackClick, + ) + } +} diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUserViewTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUserViewTest.kt new file mode 100644 index 0000000000..353d505e50 --- /dev/null +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUserViewTest.kt @@ -0,0 +1,109 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.preferences.impl.blockedusers + +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.features.preferences.impl.R +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.ui.components.aMatrixUserList +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 org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class BlockedUserViewTest { + @get:Rule + val rule = createAndroidComposeRule() + + @Test + fun `clicking on back invokes back callback`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnce { callback -> + rule.setLogoutView( + aBlockedUsersState( + eventSink = eventsRecorder + ), + onBackClick = callback, + ) + rule.pressBack() + } + } + + @Test + fun `clicking on a user emits the expected Event`() { + val eventsRecorder = EventsRecorder() + val userList = aMatrixUserList() + rule.setLogoutView( + aBlockedUsersState( + blockedUsers = userList, + eventSink = eventsRecorder + ), + ) + rule.onNodeWithText(userList.first().displayName.orEmpty()).performClick() + eventsRecorder.assertSingle(BlockedUsersEvents.Unblock(userList.first().userId)) + } + + @Test + fun `clicking on cancel sends a BlockedUsersEvents`() { + val eventsRecorder = EventsRecorder() + rule.setLogoutView( + aBlockedUsersState( + unblockUserAction = AsyncAction.Confirming, + eventSink = eventsRecorder + ), + ) + rule.clickOn(CommonStrings.action_cancel) + eventsRecorder.assertSingle(BlockedUsersEvents.Cancel) + } + + @Test + fun `clicking on confirm sends a BlockedUsersEvents`() { + val eventsRecorder = EventsRecorder() + rule.setLogoutView( + aBlockedUsersState( + unblockUserAction = AsyncAction.Confirming, + eventSink = eventsRecorder + ), + ) + rule.clickOn(R.string.screen_blocked_users_unblock_alert_action) + eventsRecorder.assertSingle(BlockedUsersEvents.ConfirmUnblock) + } +} + +private fun AndroidComposeTestRule.setLogoutView( + state: BlockedUsersState, + onBackClick: () -> Unit = EnsureNeverCalled(), +) { + setContent { + BlockedUsersView( + state = state, + onBackClick = onBackClick, + ) + } +} diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersPresenterTests.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersPresenterTests.kt index 3bcd730fed..393776ed87 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersPresenterTests.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/blockedusers/BlockedUsersPresenterTests.kt @@ -21,6 +21,11 @@ import app.cash.molecule.moleculeFlow import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.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.user.MatrixUser +import io.element.android.libraries.matrix.test.AN_EXCEPTION import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.A_USER_ID_2 import io.element.android.libraries.matrix.test.FakeMatrixClient @@ -52,7 +57,7 @@ class BlockedUsersPresenterTests { presenter.present() }.test { with(awaitItem()) { - assertThat(blockedUsers).isEqualTo(persistentListOf(A_USER_ID)) + assertThat(blockedUsers).isEqualTo(persistentListOf(MatrixUser(A_USER_ID))) assertThat(unblockUserAction).isEqualTo(AsyncAction.Uninitialized) } } @@ -68,14 +73,39 @@ class BlockedUsersPresenterTests { presenter.present() }.test { with(awaitItem()) { - assertThat(blockedUsers).containsAtLeastElementsIn(persistentListOf(A_USER_ID)) - assertThat(unblockUserAction).isEqualTo(AsyncAction.Uninitialized) + assertThat(blockedUsers).isEqualTo(listOf(MatrixUser(A_USER_ID))) } - matrixClient.ignoredUsersFlow.value = persistentListOf(A_USER_ID, A_USER_ID_2) + skipItems(1) with(awaitItem()) { - assertThat(blockedUsers).isEqualTo(persistentListOf(A_USER_ID, A_USER_ID_2)) - assertThat(unblockUserAction).isEqualTo(AsyncAction.Uninitialized) + assertThat(blockedUsers).isEqualTo(listOf(MatrixUser(A_USER_ID), MatrixUser(A_USER_ID_2))) + } + } + } + + @Test + fun `present - blocked users list with data`() = runTest { + val alice = MatrixUser(A_USER_ID, displayName = "Alice", avatarUrl = "aliceAvatar") + val matrixClient = FakeMatrixClient().apply { + ignoredUsersFlow.value = persistentListOf(A_USER_ID, A_USER_ID_2) + givenGetProfileResult(A_USER_ID, Result.success(alice)) + givenGetProfileResult(A_USER_ID_2, Result.failure(AN_EXCEPTION)) + } + val presenter = aBlockedUsersPresenter( + matrixClient = matrixClient, + featureFlagService = FakeFeatureFlagService().apply { + setFeatureEnabled(FeatureFlags.ShowBlockedUsersDetails, true) + } + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + with(awaitItem()) { + assertThat(blockedUsers).isEqualTo(listOf(MatrixUser(A_USER_ID), MatrixUser(A_USER_ID_2))) + } + // Alice is resolved + with(awaitItem()) { + assertThat(blockedUsers).isEqualTo(listOf(alice, MatrixUser(A_USER_ID_2))) } } } @@ -157,5 +187,9 @@ class BlockedUsersPresenterTests { private fun aBlockedUsersPresenter( matrixClient: FakeMatrixClient = FakeMatrixClient(), - ) = BlockedUsersPresenter(matrixClient) + featureFlagService: FeatureFlagService = FakeFeatureFlagService(), + ) = BlockedUsersPresenter( + matrixClient = matrixClient, + featureFlagService = featureFlagService, + ) } 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 ea120a27c1..7288b76d3c 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 @@ -48,7 +48,7 @@ class DeveloperSettingsViewTest { state = aDeveloperSettingsState( eventSink = eventsRecorder ), - onBackPressed = it + onBackClick = it ) rule.pressBack() } @@ -82,6 +82,7 @@ class DeveloperSettingsViewTest { } } + @Config(qualifiers = "h1024dp") @Test fun `clicking on configure tracing invokes the expected callback`() { val eventsRecorder = EventsRecorder(expectEvents = false) @@ -96,7 +97,7 @@ class DeveloperSettingsViewTest { } } - @Config(qualifiers = "h1024dp") + @Config(qualifiers = "h1500dp") @Test fun `clicking on clear cache emits the expected event`() { val eventsRecorder = EventsRecorder() @@ -114,14 +115,14 @@ private fun AndroidComposeTestRule.setDevel state: DeveloperSettingsState, onOpenShowkase: () -> Unit = EnsureNeverCalled(), onOpenConfigureTracing: () -> Unit = EnsureNeverCalled(), - onBackPressed: () -> Unit = EnsureNeverCalled() + onBackClick: () -> Unit = EnsureNeverCalled() ) { setContent { DeveloperSettingsView( state = state, onOpenShowkase = onOpenShowkase, onOpenConfigureTracing = onOpenConfigureTracing, - onBackPressed = onBackPressed, + onBackClick = onBackClick, ) } } diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenterTests.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenterTests.kt index e058d4691b..d971df5623 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenterTests.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenterTests.kt @@ -19,12 +19,20 @@ package io.element.android.features.preferences.impl.notifications import app.cash.molecule.RecompositionMode import app.cash.molecule.moleculeFlow import app.cash.turbine.test -import com.element.android.libraries.pushstore.test.userpushstore.FakeUserPushStoreFactory import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.room.RoomNotificationMode import io.element.android.libraries.matrix.test.A_THROWABLE import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService +import io.element.android.libraries.push.api.PushService +import io.element.android.libraries.push.test.FakePushService +import io.element.android.libraries.pushproviders.api.Distributor +import io.element.android.libraries.pushproviders.api.PushProvider +import io.element.android.libraries.pushproviders.test.FakePushProvider +import io.element.android.libraries.pushstore.test.userpushstore.FakeUserPushStoreFactory +import io.element.android.tests.testutils.awaitLastSequentialItem import io.element.android.tests.testutils.consumeItemsUntilPredicate import kotlinx.coroutines.test.runTest import org.junit.Test @@ -230,14 +238,95 @@ class NotificationSettingsPresenterTests { } } + @Test + fun `present - change push provider`() = runTest { + val presenter = createNotificationSettingsPresenter( + pushService = createFakePushService(), + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitLastSequentialItem() + assertThat(initialState.currentPushDistributor).isEqualTo(AsyncData.Success("aDistributorName0")) + assertThat(initialState.availablePushDistributors).containsExactly("aDistributorName0", "aDistributorName1") + initialState.eventSink.invoke(NotificationSettingsEvents.ChangePushProvider) + val withDialog = awaitItem() + assertThat(withDialog.showChangePushProviderDialog).isTrue() + // Cancel + withDialog.eventSink(NotificationSettingsEvents.CancelChangePushProvider) + val withoutDialog = awaitItem() + assertThat(withoutDialog.showChangePushProviderDialog).isFalse() + withDialog.eventSink.invoke(NotificationSettingsEvents.ChangePushProvider) + assertThat(awaitItem().showChangePushProviderDialog).isTrue() + withDialog.eventSink(NotificationSettingsEvents.SetPushProvider(1)) + val withNewProvider = awaitItem() + assertThat(withNewProvider.showChangePushProviderDialog).isFalse() + assertThat(withNewProvider.currentPushDistributor).isInstanceOf(AsyncData.Loading::class.java) + skipItems(1) + val lastItem = awaitItem() + assertThat(lastItem.currentPushDistributor).isEqualTo(AsyncData.Success("aDistributorName1")) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - change push provider error`() = runTest { + val presenter = createNotificationSettingsPresenter( + pushService = createFakePushService( + registerWithLambda = { _, _, _ -> + Result.failure(Exception("An error")) + }, + ), + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitLastSequentialItem() + initialState.eventSink.invoke(NotificationSettingsEvents.ChangePushProvider) + val withDialog = awaitItem() + assertThat(withDialog.showChangePushProviderDialog).isTrue() + withDialog.eventSink(NotificationSettingsEvents.SetPushProvider(1)) + val withNewProvider = awaitItem() + assertThat(withNewProvider.showChangePushProviderDialog).isFalse() + assertThat(withNewProvider.currentPushDistributor).isInstanceOf(AsyncData.Loading::class.java) + val lastItem = awaitItem() + assertThat(lastItem.currentPushDistributor).isInstanceOf(AsyncData.Failure::class.java) + } + } + + private fun createFakePushService( + registerWithLambda: suspend (MatrixClient, PushProvider, Distributor) -> Result = { _, _, _ -> + Result.success(Unit) + } + ): PushService { + val pushProvider1 = FakePushProvider( + index = 0, + name = "aFakePushProvider0", + isAvailable = true, + distributors = listOf(Distributor("aDistributorValue0", "aDistributorName0")), + ) + val pushProvider2 = FakePushProvider( + index = 1, + name = "aFakePushProvider1", + isAvailable = true, + distributors = listOf(Distributor("aDistributorValue1", "aDistributorName1")), + ) + return FakePushService( + availablePushProviders = listOf(pushProvider1, pushProvider2), + registerWithLambda = registerWithLambda, + ) + } + private fun createNotificationSettingsPresenter( - notificationSettingsService: FakeNotificationSettingsService = FakeNotificationSettingsService() + notificationSettingsService: FakeNotificationSettingsService = FakeNotificationSettingsService(), + pushService: PushService = FakePushService(), ): NotificationSettingsPresenter { val matrixClient = FakeMatrixClient(notificationSettingsService = notificationSettingsService) return NotificationSettingsPresenter( notificationSettingsService = notificationSettingsService, userPushStoreFactory = FakeUserPushStoreFactory(), matrixClient = matrixClient, + pushService = pushService, systemNotificationsEnabledProvider = FakeSystemNotificationsEnabledProvider(), ) } diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsViewTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsViewTest.kt index 8397d5aef6..93d8422bd2 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsViewTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsViewTest.kt @@ -19,6 +19,8 @@ package io.element.android.features.preferences.impl.notifications 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.features.preferences.impl.R import io.element.android.libraries.architecture.AsyncAction @@ -50,7 +52,7 @@ class NotificationSettingsViewTest { state = aValidNotificationSettingsState( eventSink = eventsRecorder ), - onBackPressed = it + onBackClick = it ) rule.pressBack() } @@ -66,7 +68,7 @@ class NotificationSettingsViewTest { state = aValidNotificationSettingsState( eventSink = eventsRecorder ), - onTroubleshootNotificationsClicked = it + onTroubleshootNotificationsClick = it ) rule.clickOn(R.string.troubleshoot_notifications_entry_point_title) } @@ -248,20 +250,57 @@ class NotificationSettingsViewTest { ) ) } + + @Config(qualifiers = "h1024dp") + @Test + fun `clicking on Push notification provider emits the expected event`() { + val eventsRecorder = EventsRecorder() + rule.setNotificationSettingsView( + state = aValidNotificationSettingsState( + eventSink = eventsRecorder + ), + ) + rule.clickOn(R.string.screen_advanced_settings_push_provider_android) + eventsRecorder.assertList( + listOf( + NotificationSettingsEvents.RefreshSystemNotificationsEnabled, + NotificationSettingsEvents.ChangePushProvider, + ) + ) + } + + @Test + fun `clicking on a push provider emits the expected event`() { + val eventsRecorder = EventsRecorder() + rule.setNotificationSettingsView( + state = aValidNotificationSettingsState( + eventSink = eventsRecorder, + showChangePushProviderDialog = true, + availablePushDistributors = listOf("P1", "P2") + ), + ) + rule.onNodeWithText("P2").performClick() + eventsRecorder.assertList( + listOf( + NotificationSettingsEvents.RefreshSystemNotificationsEnabled, + NotificationSettingsEvents.SetPushProvider(1), + ) + ) + } } private fun AndroidComposeTestRule.setNotificationSettingsView( state: NotificationSettingsState, onOpenEditDefault: (isOneToOne: Boolean) -> Unit = EnsureNeverCalledWithParam(), - onTroubleshootNotificationsClicked: () -> Unit = EnsureNeverCalled(), - onBackPressed: () -> Unit = EnsureNeverCalled(), + onTroubleshootNotificationsClick: () -> Unit = EnsureNeverCalled(), + onBackClick: () -> Unit = EnsureNeverCalled(), ) { setContent { NotificationSettingsView( state = state, onOpenEditDefault = onOpenEditDefault, - onTroubleshootNotificationsClicked = onTroubleshootNotificationsClicked, - onBackPressed = onBackPressed, + onTroubleshootNotificationsClick = onTroubleshootNotificationsClick, + onBackClick = onBackClick, ) } } diff --git a/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/crash/CrashDetectionView.kt b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/crash/CrashDetectionView.kt index 5af36b5ad8..39bec346b3 100644 --- a/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/crash/CrashDetectionView.kt +++ b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/crash/CrashDetectionView.kt @@ -36,8 +36,8 @@ fun CrashDetectionView( if (state.crashDetected) { CrashDetectionContent( appName = state.appName, - onYesClicked = onOpenBugReport, - onNoClicked = ::onPopupDismissed, + onYesClick = onOpenBugReport, + onNoClick = ::onPopupDismissed, onDismiss = ::onPopupDismissed, ) } @@ -46,8 +46,8 @@ fun CrashDetectionView( @Composable private fun CrashDetectionContent( appName: String, - onNoClicked: () -> Unit = { }, - onYesClicked: () -> Unit = { }, + onNoClick: () -> Unit = { }, + onYesClick: () -> Unit = { }, onDismiss: () -> Unit = { }, ) { ConfirmationDialog( @@ -55,8 +55,8 @@ private fun CrashDetectionContent( content = stringResource(id = R.string.crash_detection_dialog_content, appName), submitText = stringResource(id = CommonStrings.action_yes), cancelText = stringResource(id = CommonStrings.action_no), - onCancelClicked = onNoClicked, - onSubmitClicked = onYesClicked, + onCancelClick = onNoClick, + onSubmitClick = onYesClick, onDismiss = onDismiss, ) } diff --git a/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/detection/RageshakeDetectionView.kt b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/detection/RageshakeDetectionView.kt index 6121c684cd..76fcf845be 100644 --- a/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/detection/RageshakeDetectionView.kt +++ b/features/rageshake/api/src/main/kotlin/io/element/android/features/rageshake/api/detection/RageshakeDetectionView.kt @@ -50,16 +50,16 @@ fun RageshakeDetectionView( } when { state.takeScreenshot -> TakeScreenshot( - onScreenshotTaken = { eventSink(RageshakeDetectionEvents.ProcessScreenshot(it)) } + onScreenshot = { eventSink(RageshakeDetectionEvents.ProcessScreenshot(it)) } ) state.showDialog -> { LaunchedEffect(Unit) { context.vibrate() } RageshakeDialogContent( - onNoClicked = { eventSink(RageshakeDetectionEvents.Dismiss) }, - onDisableClicked = { eventSink(RageshakeDetectionEvents.Disable) }, - onYesClicked = onOpenBugReport + onNoClick = { eventSink(RageshakeDetectionEvents.Dismiss) }, + onDisableClick = { eventSink(RageshakeDetectionEvents.Disable) }, + onYesClick = onOpenBugReport ) } } @@ -67,22 +67,22 @@ fun RageshakeDetectionView( @Composable private fun TakeScreenshot( - onScreenshotTaken: (ImageResult) -> Unit + onScreenshot: (ImageResult) -> Unit ) { val view = LocalView.current - val latestOnScreenshotTaken by rememberUpdatedState(onScreenshotTaken) + val latestOnScreenshot by rememberUpdatedState(onScreenshot) LaunchedEffect(Unit) { view.screenshot { - latestOnScreenshotTaken(it) + latestOnScreenshot(it) } } } @Composable private fun RageshakeDialogContent( - onNoClicked: () -> Unit = { }, - onDisableClicked: () -> Unit = { }, - onYesClicked: () -> Unit = { }, + onNoClick: () -> Unit = { }, + onDisableClick: () -> Unit = { }, + onYesClick: () -> Unit = { }, ) { ConfirmationDialog( title = stringResource(id = CommonStrings.action_report_bug), @@ -90,10 +90,10 @@ private fun RageshakeDialogContent( thirdButtonText = stringResource(id = CommonStrings.action_disable), submitText = stringResource(id = CommonStrings.action_yes), cancelText = stringResource(id = CommonStrings.action_no), - onCancelClicked = onNoClicked, - onThirdButtonClicked = onDisableClicked, - onSubmitClicked = onYesClicked, - onDismiss = onNoClicked, + onCancelClick = onNoClick, + onThirdButtonClick = onDisableClick, + onSubmitClick = onYesClick, + onDismiss = onNoClick, ) } diff --git a/features/rageshake/api/src/main/res/values-ka/translations.xml b/features/rageshake/api/src/main/res/values-ka/translations.xml new file mode 100644 index 0000000000..64c22d0763 --- /dev/null +++ b/features/rageshake/api/src/main/res/values-ka/translations.xml @@ -0,0 +1,7 @@ + + + "%1$s ავარიულად გაითიშა ბოლოს გამოიყენებისას. გსურთ, გამოგვიგზავნოთ ავარიული გათიშვის ჟურნალი?" + "როგორც ჩანს, იმედგაცრუებით ტელეფონს აჯანჯღალებთ. გსურთ, გახსნათ შეცდომის დარეპორტების ეკრანი?" + "Rageshake" + "გამოვლენის ზღვარი" + diff --git a/features/rageshake/api/src/main/res/values-pt-rBR/translations.xml b/features/rageshake/api/src/main/res/values-pt-rBR/translations.xml new file mode 100644 index 0000000000..8fc38566c4 --- /dev/null +++ b/features/rageshake/api/src/main/res/values-pt-rBR/translations.xml @@ -0,0 +1,7 @@ + + + "A %1$s teve uma falha da última vez que foi utilizada. Gostarias de partilhar um relatório de acidente connosco?" + "Parece que estás a abanar o telefone em sinal de frustração. Gostarias de abrir o painel de relatório de erros?" + "Rageshake" + "Limiar de deteção" + diff --git a/features/rageshake/api/src/main/res/values-zh/translations.xml b/features/rageshake/api/src/main/res/values-zh/translations.xml new file mode 100644 index 0000000000..38942e2f1a --- /dev/null +++ b/features/rageshake/api/src/main/res/values-zh/translations.xml @@ -0,0 +1,7 @@ + + + "%1$s 上次使用时崩溃了。你想和我们分享崩溃报告吗?" + "你似乎愤怒地摇晃了手机。想要打开 Bug 报告页面吗?" + "摇一摇" + "检测阈值" + diff --git a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportNode.kt b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportNode.kt index caed077228..d3505aeefb 100644 --- a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportNode.kt +++ b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportNode.kt @@ -51,8 +51,8 @@ class BugReportNode @AssistedInject constructor( BugReportView( state = state, modifier = modifier, - onBackPressed = { navigateUp() }, - onDone = { + onBackClick = { navigateUp() }, + onSuccess = { activity?.toast(CommonStrings.common_report_submitted) onDone() }, diff --git a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportView.kt b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportView.kt index f5ffd62fd6..599a48025e 100644 --- a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportView.kt +++ b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportView.kt @@ -57,8 +57,8 @@ import io.element.android.libraries.ui.strings.CommonStrings fun BugReportView( state: BugReportState, onViewLogs: () -> Unit, - onDone: () -> Unit, - onBackPressed: () -> Unit, + onSuccess: () -> Unit, + onBackClick: () -> Unit, modifier: Modifier = Modifier, ) { val eventSink = state.eventSink @@ -66,7 +66,7 @@ fun BugReportView( Box(modifier = modifier) { PreferencePage( title = stringResource(id = CommonStrings.common_report_a_problem), - onBackPressed = onBackPressed + onBackClick = onBackClick ) { val isFormEnabled = state.sending !is AsyncAction.Loading var descriptionFieldState by textFieldState( @@ -163,7 +163,7 @@ fun BugReportView( progressDialog = { }, onSuccess = { eventSink(BugReportEvents.ResetAll) - onDone() + onSuccess() }, errorMessage = { error -> when (error) { @@ -181,8 +181,8 @@ fun BugReportView( internal fun BugReportViewPreview(@PreviewParameter(BugReportStateProvider::class) state: BugReportState) = ElementPreview { BugReportView( state = state, - onDone = {}, - onBackPressed = {}, + onSuccess = {}, + onBackClick = {}, onViewLogs = {}, ) } 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 295fa17563..ddde3bd420 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 @@ -36,7 +36,9 @@ import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.ApplicationContext import io.element.android.libraries.di.SingleIn +import io.element.android.libraries.matrix.api.MatrixClientProvider import io.element.android.libraries.matrix.api.SdkMetadata +import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.network.useragent.UserAgentProvider import io.element.android.libraries.sessionstorage.api.SessionStore import kotlinx.coroutines.CancellationException @@ -79,6 +81,7 @@ class DefaultBugReporter @Inject constructor( private val buildMeta: BuildMeta, private val bugReporterUrlProvider: BugReporterUrlProvider, private val sdkMetadata: SdkMetadata, + private val matrixClientProvider: MatrixClientProvider, ) : BugReporter { companion object { // filenames @@ -145,7 +148,7 @@ class DefaultBugReporter @Inject constructor( val sessionData = sessionStore.getLatestSession() val deviceId = sessionData?.deviceId ?: "undefined" - val userId = sessionData?.userId ?: "undefined" + val userId = sessionData?.userId?.let { UserId(it) } if (!isCancelled) { // build the multi part request @@ -153,9 +156,20 @@ class DefaultBugReporter @Inject constructor( .addFormDataPart("text", bugDescription) .addFormDataPart("app", context.getString(R.string.bug_report_app_name)) .addFormDataPart("user_agent", userAgentProvider.provide()) - .addFormDataPart("user_id", userId) + .addFormDataPart("user_id", userId?.toString() ?: "undefined") .addFormDataPart("can_contact", canContact.toString()) .addFormDataPart("device_id", deviceId) + .apply { + userId?.let { + matrixClientProvider.getOrNull(it)?.let { client -> + val curveKey = client.encryptionService().deviceCurve25519() + val edKey = client.encryptionService().deviceEd25519() + if (curveKey != null && edKey != null) { + addFormDataPart("device_keys", "curve25519:$curveKey, ed25519:$edKey") + } + } + } + } .addFormDataPart("device", Build.MODEL.trim()) .addFormDataPart("locale", Locale.getDefault().toString()) .addFormDataPart("sdk_sha", sdkMetadata.sdkGitSha) diff --git a/features/rageshake/impl/src/main/res/values-es/translations.xml b/features/rageshake/impl/src/main/res/values-es/translations.xml index 3c0d78b659..7398dbc6de 100644 --- a/features/rageshake/impl/src/main/res/values-es/translations.xml +++ b/features/rageshake/impl/src/main/res/values-es/translations.xml @@ -7,9 +7,11 @@ "Describe el problema. ¿Qué has hecho? ¿Qué esperabas que ocurriera? ¿Qué ocurrió realmente? Por favor, detállalo todo lo que puedas." "Describe el problema…" "Si es posible, escriba la descripción en inglés." + "La descripción es demasiado corta. Proporcione más detalles sobre lo sucedido. ¡Gracias!" "Enviar registros de fallos" "Permitir registros" "Enviar captura de pantalla" "Los registros se incluirán con su mensaje para asegurarse de que todo funciona correctamente. Para enviar tu mensaje sin registros, desactiva esta configuración." "%1$s se cerró inesperadamente la última vez que se lo usaste. ¿Quieres compartir un informe de error con nosotros?" + "Ver los registros" diff --git a/features/rageshake/impl/src/main/res/values-ka/translations.xml b/features/rageshake/impl/src/main/res/values-ka/translations.xml new file mode 100644 index 0000000000..6f0afb86b1 --- /dev/null +++ b/features/rageshake/impl/src/main/res/values-ka/translations.xml @@ -0,0 +1,15 @@ + + + "ეკრანის ანაბეჭდის დართვა" + "შეგიძლიათ, დამიკავშირდეთ თუ გაქვთ შემდგომი კითხვები." + "დამიკავშირდით" + "ეკრანის ანაბეჭდის რედაქტირება" + "გთხოვთ, აღწეროთ პრობლემა. რა გააკეთე? რა შედეგს ელოდებოდით? რა მოხდა სინამდვილეში? გთხოვთ, ყველაფერი დაწვრილებით თქვათ." + "აღწერეთ პრობლემა…" + "თუ შესაძლებელია, გთხოვთ, დაწეროთ აღწერა ინგლისურ ენაზე." + "გაუმართაობის ჟურნალის გაგზავნა" + "ჟურნალების დაშვება" + "ეკრანის ანაბეჭდის გაგზავნა" + "ჟურნალები თქვენს შეტყობინებაში შევა იმაში დასარწმუნებლად, რომ ყველაფერი სწორად მუშაობს. ჟურნალების გარეშე გასაგზავნად გათიშეთ ეს პარამეტრი." + "%1$s ავარიულად გაითიშა ბოლოს გამოიყენებისას. გსურთ, გამოგვიგზავნოთ ავარიული გათიშვის ჟურნალი?" + diff --git a/features/rageshake/impl/src/main/res/values-pt-rBR/translations.xml b/features/rageshake/impl/src/main/res/values-pt-rBR/translations.xml new file mode 100644 index 0000000000..59a066a16e --- /dev/null +++ b/features/rageshake/impl/src/main/res/values-pt-rBR/translations.xml @@ -0,0 +1,17 @@ + + + "Anexar captura de ecrã" + "Podem contactar-me se tiverem mais questões." + "Contactar-me" + "Editar captura de ecrã" + "Descreve o problema. O que é que fizeste? O que esperavas que acontecesse? O que realmente aconteceu? Por favor, dá o máximo de detalhes que puderes." + "Descreve o problema…" + "Se possível, escreve a descrição em inglês." + "A descrição é demasiado curta. Por favor dá mais detalhes sobre o que aconteceu. Obrigado!" + "Enviar registos de falha" + "Permitir registos" + "Enviar captura de ecrã" + "Os registos serão incluídos na tua mensagem para garantir que tudo está a funcionar corretamente. Para enviares a tua mensagem sem registos, desativa esta definição." + "A %1$s teve uma falha da última vez que foi utilizada. Gostarias de partilhar um relatório de acidente connosco?" + "Ver registos" + diff --git a/features/rageshake/impl/src/main/res/values-sv/translations.xml b/features/rageshake/impl/src/main/res/values-sv/translations.xml index 0058b6519a..f04ccdf738 100644 --- a/features/rageshake/impl/src/main/res/values-sv/translations.xml +++ b/features/rageshake/impl/src/main/res/values-sv/translations.xml @@ -7,6 +7,7 @@ "Vänligen beskriv problemet. Vad gjorde du? Vad förväntade du dig skulle hända? Vad hände istället? Vänligen gå in i så mycket detaljer som möjligt." "Beskriv problemet …" "Om möjligt, skriv beskrivningen på engelska." + "Beskrivningen är för kort, vänligen ge mer information om vad som hände. Tack!" "Skicka kraschloggar" "Tillåt loggar" "Skicka skärmdump" diff --git a/features/rageshake/impl/src/main/res/values-zh/translations.xml b/features/rageshake/impl/src/main/res/values-zh/translations.xml new file mode 100644 index 0000000000..7fbf6bd644 --- /dev/null +++ b/features/rageshake/impl/src/main/res/values-zh/translations.xml @@ -0,0 +1,17 @@ + + + "附上截图" + "如果您有任何后续问题,可以与我联系。" + "联系我" + "编辑截图" + "请尽可能详细地描述问题。您做了什么?您预期会发生什么?实际发生了什么?" + "描述问题…" + "请尽可能用英文描述。" + "描述太短,请提供详细情况。谢谢!" + "发送崩溃日志" + "允许日志" + "发送屏幕截图" + "为确认一切正常运行,您的消息中将包含日志。如要发送不带日志的消息,请关闭此设置。" + "%1$s 上次使用时崩溃了。你想和我们分享崩溃报告吗?" + "查看日志" + diff --git a/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportPresenterTest.kt b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportPresenterTest.kt index e0c033afc7..9b07b1c942 100644 --- a/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportPresenterTest.kt +++ b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportPresenterTest.kt @@ -136,7 +136,7 @@ class BugReportPresenterTest { initialState.eventSink.invoke(BugReportEvents.ResetAll) val resetState = awaitItem() assertThat(resetState.hasCrashLogs).isFalse() - logFilesRemoverLambda.assertions().isCalledExactly(1) + logFilesRemoverLambda.assertions().isCalledOnce() // TODO Make it live assertThat(resetState.screenshotUri).isNull() } } @@ -144,7 +144,7 @@ class BugReportPresenterTest { @Test fun `present - send success`() = runTest { val presenter = createPresenter( - FakeBugReporter(mode = FakeBugReporterMode.Success), + FakeBugReporter(mode = FakeBugReporter.Mode.Success), FakeCrashDataStore(crashData = A_CRASH_DATA, appHasCrashed = true), FakeScreenshotHolder(screenshotUri = A_SCREENSHOT_URI), ) @@ -170,7 +170,7 @@ class BugReportPresenterTest { @Test fun `present - send failure`() = runTest { val presenter = createPresenter( - FakeBugReporter(mode = FakeBugReporterMode.Failure), + FakeBugReporter(mode = FakeBugReporter.Mode.Failure), FakeCrashDataStore(crashData = A_CRASH_DATA, appHasCrashed = true), FakeScreenshotHolder(screenshotUri = A_SCREENSHOT_URI), ) @@ -219,7 +219,7 @@ class BugReportPresenterTest { @Test fun `present - send cancel`() = runTest { val presenter = createPresenter( - FakeBugReporter(mode = FakeBugReporterMode.Cancel), + FakeBugReporter(mode = FakeBugReporter.Mode.Cancel), FakeCrashDataStore(crashData = A_CRASH_DATA, appHasCrashed = true), FakeScreenshotHolder(screenshotUri = A_SCREENSHOT_URI), ) diff --git a/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/bugreport/FakeBugReporter.kt b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/bugreport/FakeBugReporter.kt index cce2d5d144..a4d4f83da9 100644 --- a/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/bugreport/FakeBugReporter.kt +++ b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/bugreport/FakeBugReporter.kt @@ -22,7 +22,13 @@ import io.element.android.libraries.matrix.test.A_FAILURE_REASON import kotlinx.coroutines.delay import java.io.File -class FakeBugReporter(val mode: FakeBugReporterMode = FakeBugReporterMode.Success) : BugReporter { +class FakeBugReporter(val mode: Mode = Mode.Success) : BugReporter { + enum class Mode { + Success, + Failure, + Cancel + } + override suspend fun sendBugReport( withDevicesLogs: Boolean, withCrashLogs: Boolean, @@ -37,12 +43,12 @@ class FakeBugReporter(val mode: FakeBugReporterMode = FakeBugReporterMode.Succes listener?.onProgress(50) delay(100) when (mode) { - FakeBugReporterMode.Success -> Unit - FakeBugReporterMode.Failure -> { + Mode.Success -> Unit + Mode.Failure -> { listener?.onUploadFailed(A_FAILURE_REASON) return } - FakeBugReporterMode.Cancel -> { + Mode.Cancel -> { listener?.onUploadCancelled() return } @@ -64,9 +70,3 @@ class FakeBugReporter(val mode: FakeBugReporterMode = FakeBugReporterMode.Succes // No op } } - -enum class FakeBugReporterMode { - Success, - Failure, - Cancel -} 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 f2c8d0d0a8..cb28c973dc 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 @@ -20,16 +20,25 @@ import com.google.common.truth.Truth.assertThat import io.element.android.features.rageshake.api.reporter.BugReporterListener import io.element.android.features.rageshake.test.crash.FakeCrashDataStore import io.element.android.features.rageshake.test.screenshot.FakeScreenshotHolder +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.matrix.test.FakeMatrixClientProvider import io.element.android.libraries.matrix.test.FakeSdkMetadata import io.element.android.libraries.matrix.test.core.aBuildMeta +import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService import io.element.android.libraries.network.useragent.DefaultUserAgentProvider +import io.element.android.libraries.sessionstorage.api.LoginType +import io.element.android.libraries.sessionstorage.api.SessionData import io.element.android.libraries.sessionstorage.impl.memory.InMemorySessionStore import io.element.android.tests.testutils.testCoroutineDispatchers import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest +import okhttp3.MultipartReader import okhttp3.OkHttpClient import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import okio.buffer +import okio.source import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner @@ -84,6 +93,202 @@ class DefaultBugReporterTest { assertThat(onUploadSucceedCalled).isTrue() } + @Test + fun `test sendBugReport form data`() = runTest { + val server = MockWebServer() + server.enqueue( + MockResponse() + .setResponseCode(200) + ) + server.start() + + val mockSessionStore = InMemorySessionStore().apply { + storeData(mockSessionData("@foo:eample.com", "ABCDEFGH")) + } + + val buildMeta = aBuildMeta() + val fakeEncryptionService = FakeEncryptionService() + val matrixClient = FakeMatrixClient(encryptionService = fakeEncryptionService) + + fakeEncryptionService.givenDeviceKeys("CURVECURVECURVE", "EDKEYEDKEYEDKY") + val sut = DefaultBugReporter( + context = RuntimeEnvironment.getApplication(), + screenshotHolder = FakeScreenshotHolder(), + crashDataStore = FakeCrashDataStore(), + coroutineDispatchers = testCoroutineDispatchers(), + okHttpClient = { OkHttpClient.Builder().build() }, + userAgentProvider = DefaultUserAgentProvider(buildMeta, FakeSdkMetadata("123456789")), + sessionStore = mockSessionStore, + buildMeta = buildMeta, + bugReporterUrlProvider = { server.url("/") }, + sdkMetadata = FakeSdkMetadata("123456789"), + matrixClientProvider = FakeMatrixClientProvider(getClient = { Result.success(matrixClient) }) + ) + + val progressValues = mutableListOf() + sut.sendBugReport( + withDevicesLogs = true, + withCrashLogs = true, + withScreenshot = true, + theBugDescription = "a bug occurred", + canContact = true, + listener = object : BugReporterListener { + override fun onUploadCancelled() {} + + override fun onUploadFailed(reason: String?) {} + + override fun onProgress(progress: Int) { + progressValues.add(progress) + } + + override fun onUploadSucceed() {} + }, + ) + val request = server.takeRequest() + + val foundValues = collectValuesFromFormData(request) + + assertThat(foundValues["app"]).isEqualTo("element-x-android") + assertThat(foundValues["can_contact"]).isEqualTo("true") + assertThat(foundValues["device_id"]).isEqualTo("ABCDEFGH") + assertThat(foundValues["sdk_sha"]).isEqualTo("123456789") + assertThat(foundValues["user_id"]).isEqualTo("@foo:eample.com") + assertThat(foundValues["text"]).isEqualTo("a bug occurred") + assertThat(foundValues["device_keys"]).isEqualTo("curve25519:CURVECURVECURVE, ed25519:EDKEYEDKEYEDKY") + + // device_key now added given they are not null + assertThat(progressValues.size).isEqualTo(EXPECTED_NUMBER_OF_PROGRESS_VALUE + 1) + + server.shutdown() + } + + @Test + fun `test sendBugReport should not report device_keys if not known`() = runTest { + val server = MockWebServer() + server.enqueue( + MockResponse() + .setResponseCode(200) + ) + server.start() + + val mockSessionStore = InMemorySessionStore().apply { + storeData(mockSessionData("@foo:eample.com", "ABCDEFGH")) + } + + val buildMeta = aBuildMeta() + val fakeEncryptionService = FakeEncryptionService() + val matrixClient = FakeMatrixClient(encryptionService = fakeEncryptionService) + + fakeEncryptionService.givenDeviceKeys(null, null) + val sut = DefaultBugReporter( + context = RuntimeEnvironment.getApplication(), + screenshotHolder = FakeScreenshotHolder(), + crashDataStore = FakeCrashDataStore(), + coroutineDispatchers = testCoroutineDispatchers(), + okHttpClient = { OkHttpClient.Builder().build() }, + userAgentProvider = DefaultUserAgentProvider(buildMeta, FakeSdkMetadata("123456789")), + sessionStore = mockSessionStore, + buildMeta = buildMeta, + bugReporterUrlProvider = { server.url("/") }, + sdkMetadata = FakeSdkMetadata("123456789"), + matrixClientProvider = FakeMatrixClientProvider(getClient = { Result.success(matrixClient) }) + ) + + sut.sendBugReport( + withDevicesLogs = true, + withCrashLogs = true, + withScreenshot = true, + theBugDescription = "a bug occurred", + canContact = true, + listener = null + ) + val request = server.takeRequest() + + val foundValues = collectValuesFromFormData(request) + assertThat(foundValues["device_keys"]).isNull() + server.shutdown() + } + + @Test + fun `test sendBugReport no client provider no session data`() = runTest { + val server = MockWebServer() + server.enqueue( + MockResponse() + .setResponseCode(200) + ) + server.start() + + val buildMeta = aBuildMeta() + val fakeEncryptionService = FakeEncryptionService() + + fakeEncryptionService.givenDeviceKeys(null, null) + val sut = DefaultBugReporter( + context = RuntimeEnvironment.getApplication(), + screenshotHolder = FakeScreenshotHolder(), + crashDataStore = FakeCrashDataStore("I did crash", true), + coroutineDispatchers = testCoroutineDispatchers(), + okHttpClient = { OkHttpClient.Builder().build() }, + userAgentProvider = DefaultUserAgentProvider(buildMeta, FakeSdkMetadata("123456789")), + sessionStore = InMemorySessionStore(), + buildMeta = buildMeta, + bugReporterUrlProvider = { server.url("/") }, + sdkMetadata = FakeSdkMetadata("123456789"), + matrixClientProvider = FakeMatrixClientProvider(getClient = { Result.failure(Exception("Mock no client")) }) + ) + + sut.sendBugReport( + withDevicesLogs = true, + withCrashLogs = true, + withScreenshot = true, + theBugDescription = "a bug occurred", + canContact = true, + listener = null + ) + val request = server.takeRequest() + + val foundValues = collectValuesFromFormData(request) + println("## FOUND VALUES $foundValues") + assertThat(foundValues["device_keys"]).isNull() + assertThat(foundValues["device_id"]).isEqualTo("undefined") + assertThat(foundValues["user_id"]).isEqualTo("undefined") + assertThat(foundValues["label"]).isEqualTo("crash") + } + + private fun collectValuesFromFormData(request: RecordedRequest): HashMap { + val boundary = request.headers["Content-Type"]!!.split("=").last() + val foundValues = HashMap() + request.body.inputStream().source().buffer().use { + val multipartReader = MultipartReader(it, boundary) + // Just use simple parsing to detect basic properties + val regex = "form-data; name=\"(\\w*)\".*".toRegex() + multipartReader.use { + var part = multipartReader.nextPart() + while (part != null) { + part.headers["Content-Disposition"]?.let { contentDisposition -> + regex.find(contentDisposition)?.groupValues?.get(1)?.let { name -> + foundValues.put(name, part!!.body.readUtf8()) + } + } + part = multipartReader.nextPart() + } + } + } + return foundValues + } + + private fun mockSessionData(userId: String, deviceId: String) = SessionData( + userId = userId, + deviceId = deviceId, + homeserverUrl = "example.com", + accessToken = "AA", + isTokenValid = true, + loginType = LoginType.DIRECT, + loginTimestamp = null, + oidcData = null, + refreshToken = null, + slidingSyncProxy = null, + passphrase = null + ) @Test fun `test sendBugReport error`() = runTest { val server = MockWebServer() @@ -150,6 +355,7 @@ class DefaultBugReporterTest { buildMeta = buildMeta, bugReporterUrlProvider = { server.url("/") }, sdkMetadata = FakeSdkMetadata("123456789"), + matrixClientProvider = FakeMatrixClientProvider() ) } diff --git a/features/roomaliasresolver/api/src/main/kotlin/io/element/android/features/roomaliasesolver/api/RoomAliasResolverEntryPoint.kt b/features/roomaliasresolver/api/src/main/kotlin/io/element/android/features/roomaliasesolver/api/RoomAliasResolverEntryPoint.kt index 8c0cc10f64..b22a968035 100644 --- a/features/roomaliasresolver/api/src/main/kotlin/io/element/android/features/roomaliasesolver/api/RoomAliasResolverEntryPoint.kt +++ b/features/roomaliasresolver/api/src/main/kotlin/io/element/android/features/roomaliasesolver/api/RoomAliasResolverEntryPoint.kt @@ -22,7 +22,7 @@ import com.bumble.appyx.core.plugin.Plugin import io.element.android.libraries.architecture.FeatureEntryPoint import io.element.android.libraries.architecture.NodeInputs 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.room.alias.ResolvedRoomAlias interface RoomAliasResolverEntryPoint : FeatureEntryPoint { fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder @@ -34,7 +34,7 @@ interface RoomAliasResolverEntryPoint : FeatureEntryPoint { } interface Callback : Plugin { - fun onAliasResolved(roomId: RoomId) + fun onAliasResolved(data: ResolvedRoomAlias) } data class Params( diff --git a/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverNode.kt b/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverNode.kt index a4dbcc40b5..38f7a01190 100644 --- a/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverNode.kt +++ b/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverNode.kt @@ -28,7 +28,7 @@ import io.element.android.anvilannotations.ContributesNode import io.element.android.features.roomaliasesolver.api.RoomAliasResolverEntryPoint import io.element.android.libraries.architecture.inputs import io.element.android.libraries.di.SessionScope -import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias @ContributesNode(SessionScope::class) class RoomAliasResolverNode @AssistedInject constructor( @@ -42,8 +42,8 @@ class RoomAliasResolverNode @AssistedInject constructor( inputs.roomAlias ) - private fun onAliasResolved(roomId: RoomId) { - plugins().forEach { it.onAliasResolved(roomId) } + private fun onAliasResolved(data: ResolvedRoomAlias) { + plugins().forEach { it.onAliasResolved(data) } } @Composable @@ -51,8 +51,8 @@ class RoomAliasResolverNode @AssistedInject constructor( val state = presenter.present() RoomAliasResolverView( state = state, - onAliasResolved = ::onAliasResolved, - onBackPressed = ::navigateUp, + onSuccess = ::onAliasResolved, + onBackClick = ::navigateUp, modifier = modifier ) } diff --git a/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverPresenter.kt b/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverPresenter.kt index 775be7d6ff..5e3671f564 100644 --- a/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverPresenter.kt +++ b/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverPresenter.kt @@ -29,7 +29,7 @@ import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.runCatchingUpdatingState import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.RoomAlias -import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -46,7 +46,7 @@ class RoomAliasResolverPresenter @AssistedInject constructor( @Composable override fun present(): RoomAliasResolverState { val coroutineScope = rememberCoroutineScope() - val resolveState: MutableState> = remember { mutableStateOf(AsyncData.Uninitialized) } + val resolveState: MutableState> = remember { mutableStateOf(AsyncData.Uninitialized) } LaunchedEffect(Unit) { resolveAlias(resolveState) } @@ -64,7 +64,7 @@ class RoomAliasResolverPresenter @AssistedInject constructor( ) } - private fun CoroutineScope.resolveAlias(resolveState: MutableState>) = launch { + private fun CoroutineScope.resolveAlias(resolveState: MutableState>) = launch { suspend { matrixClient.resolveRoomAlias(roomAlias).getOrThrow() }.runCatchingUpdatingState(resolveState) diff --git a/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverState.kt b/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverState.kt index 638214da3f..12cbb64c00 100644 --- a/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverState.kt +++ b/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverState.kt @@ -19,11 +19,11 @@ package io.element.android.features.roomaliasresolver.impl import androidx.compose.runtime.Immutable import io.element.android.libraries.architecture.AsyncData 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.room.alias.ResolvedRoomAlias @Immutable data class RoomAliasResolverState( val roomAlias: RoomAlias, - val resolveState: AsyncData, + val resolveState: AsyncData, val eventSink: (RoomAliasResolverEvents) -> Unit ) diff --git a/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverStateProvider.kt b/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverStateProvider.kt index 3c5599628c..e7e5cfc686 100644 --- a/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverStateProvider.kt +++ b/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverStateProvider.kt @@ -19,7 +19,7 @@ package io.element.android.features.roomaliasresolver.impl import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.libraries.architecture.AsyncData 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.room.alias.ResolvedRoomAlias open class RoomAliasResolverStateProvider : PreviewParameterProvider { override val values: Sequence @@ -36,7 +36,7 @@ open class RoomAliasResolverStateProvider : PreviewParameterProvider = AsyncData.Uninitialized, + resolveState: AsyncData = AsyncData.Uninitialized, eventSink: (RoomAliasResolverEvents) -> Unit = {} ) = RoomAliasResolverState( roomAlias = roomAlias, diff --git a/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverView.kt b/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverView.kt index cd5bd042c8..933817a37a 100644 --- a/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverView.kt +++ b/features/roomaliasresolver/impl/src/main/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverView.kt @@ -49,20 +49,20 @@ import io.element.android.libraries.designsystem.theme.components.ButtonSize import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.TopAppBar -import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias import io.element.android.libraries.ui.strings.CommonStrings @Composable fun RoomAliasResolverView( state: RoomAliasResolverState, - onBackPressed: () -> Unit, - onAliasResolved: (RoomId) -> Unit, + onBackClick: () -> Unit, + onSuccess: (ResolvedRoomAlias) -> Unit, modifier: Modifier = Modifier, ) { - val latestOnAliasResolved by rememberUpdatedState(onAliasResolved) + val latestOnSuccess by rememberUpdatedState(onSuccess) LaunchedEffect(state.resolveState) { if (state.resolveState is AsyncData.Success) { - latestOnAliasResolved(state.resolveState.data) + latestOnSuccess(state.resolveState.data) } } Box( @@ -73,7 +73,7 @@ fun RoomAliasResolverView( containerColor = Color.Transparent, paddingValues = PaddingValues(16.dp), topBar = { - RoomAliasResolverTopBar(onBackClicked = onBackPressed) + RoomAliasResolverTopBar(onBackClick = onBackClick) }, content = { RoomAliasResolverContent(state = state) @@ -148,11 +148,11 @@ private fun RoomAliasResolverContent( @OptIn(ExperimentalMaterial3Api::class) @Composable private fun RoomAliasResolverTopBar( - onBackClicked: () -> Unit, + onBackClick: () -> Unit, ) { TopAppBar( navigationIcon = { - BackButton(onClick = onBackClicked) + BackButton(onClick = onBackClick) }, title = {}, ) @@ -163,7 +163,7 @@ private fun RoomAliasResolverTopBar( internal fun RoomAliasResolverViewPreview(@PreviewParameter(RoomAliasResolverStateProvider::class) state: RoomAliasResolverState) = ElementPreview { RoomAliasResolverView( state = state, - onAliasResolved = { }, - onBackPressed = { } + onSuccess = { }, + onBackClick = { } ) } diff --git a/features/roomaliasresolver/impl/src/main/res/values-it/translations.xml b/features/roomaliasresolver/impl/src/main/res/values-it/translations.xml new file mode 100644 index 0000000000..e52051ae93 --- /dev/null +++ b/features/roomaliasresolver/impl/src/main/res/values-it/translations.xml @@ -0,0 +1,4 @@ + + + "Impossibile risolvere l\'alias della stanza." + diff --git a/features/roomaliasresolver/impl/src/main/res/values-pt-rBR/translations.xml b/features/roomaliasresolver/impl/src/main/res/values-pt-rBR/translations.xml new file mode 100644 index 0000000000..aa469c7158 --- /dev/null +++ b/features/roomaliasresolver/impl/src/main/res/values-pt-rBR/translations.xml @@ -0,0 +1,4 @@ + + + "Não foi possível encontrar esse endereço de sala" + diff --git a/features/roomaliasresolver/impl/src/main/res/values-ro/translations.xml b/features/roomaliasresolver/impl/src/main/res/values-ro/translations.xml new file mode 100644 index 0000000000..fbec806c6a --- /dev/null +++ b/features/roomaliasresolver/impl/src/main/res/values-ro/translations.xml @@ -0,0 +1,4 @@ + + + "Nu s-a putut rezolva alias-ul camerei." + diff --git a/features/roomaliasresolver/impl/src/main/res/values-zh/translations.xml b/features/roomaliasresolver/impl/src/main/res/values-zh/translations.xml new file mode 100644 index 0000000000..420d74e4a4 --- /dev/null +++ b/features/roomaliasresolver/impl/src/main/res/values-zh/translations.xml @@ -0,0 +1,4 @@ + + + "无法解析房间别名。" + diff --git a/features/roomaliasresolver/impl/src/test/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverPresenterTest.kt b/features/roomaliasresolver/impl/src/test/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverPresenterTest.kt index 2c64690600..c3cd566432 100644 --- a/features/roomaliasresolver/impl/src/test/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverPresenterTest.kt +++ b/features/roomaliasresolver/impl/src/test/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverPresenterTest.kt @@ -22,9 +22,12 @@ import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import io.element.android.libraries.matrix.api.MatrixClient 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.room.alias.ResolvedRoomAlias import io.element.android.libraries.matrix.test.AN_EXCEPTION 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.A_SERVER_LIST import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.tests.testutils.WarmUpRule import kotlinx.coroutines.test.runTest @@ -48,8 +51,9 @@ class RoomAliasResolverPresenterTest { @Test fun `present - resolve alias to roomId`() = runTest { + val result = aResolvedRoomAlias() val client = FakeMatrixClient( - resolveRoomAliasResult = { Result.success(A_ROOM_ID) } + resolveRoomAliasResult = { Result.success(result) } ) val presenter = createPresenter(matrixClient = client) moleculeFlow(RecompositionMode.Immediate) { @@ -59,7 +63,7 @@ class RoomAliasResolverPresenterTest { assertThat(awaitItem().resolveState.isLoading()).isTrue() val resultState = awaitItem() assertThat(resultState.roomAlias).isEqualTo(A_ROOM_ALIAS) - assertThat(resultState.resolveState.dataOrNull()).isEqualTo(A_ROOM_ID) + assertThat(resultState.resolveState.dataOrNull()).isEqualTo(result) } } @@ -92,3 +96,11 @@ class RoomAliasResolverPresenterTest { matrixClient = matrixClient, ) } + +internal fun aResolvedRoomAlias( + roomId: RoomId = A_ROOM_ID, + servers: List = A_SERVER_LIST, +) = ResolvedRoomAlias( + roomId = roomId, + servers = servers, +) diff --git a/features/roomaliasresolver/impl/src/test/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverViewTest.kt b/features/roomaliasresolver/impl/src/test/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverViewTest.kt index 6df8a7849e..3dc5052011 100644 --- a/features/roomaliasresolver/impl/src/test/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverViewTest.kt +++ b/features/roomaliasresolver/impl/src/test/kotlin/io/element/android/features/roomaliasresolver/impl/RoomAliasResolverViewTest.kt @@ -21,8 +21,7 @@ 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.AsyncData -import io.element.android.libraries.matrix.api.core.RoomId -import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.matrix.api.room.alias.ResolvedRoomAlias import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.tests.testutils.EnsureNeverCalled import io.element.android.tests.testutils.EnsureNeverCalledWithParam @@ -48,7 +47,7 @@ class RoomAliasResolverViewTest { aRoomAliasResolverState( eventSink = eventsRecorder, ), - onBackPressed = it + onBackClick = it ) rule.pressBack() } @@ -69,11 +68,12 @@ class RoomAliasResolverViewTest { @Test fun `success state invokes the expected Callback`() { + val result = aResolvedRoomAlias() val eventsRecorder = EventsRecorder(expectEvents = false) - ensureCalledOnceWithParam(A_ROOM_ID) { + ensureCalledOnceWithParam(result) { rule.setRoomAliasResolverView( aRoomAliasResolverState( - resolveState = AsyncData.Success(A_ROOM_ID), + resolveState = AsyncData.Success(result), eventSink = eventsRecorder, ), onAliasResolved = it, @@ -84,14 +84,14 @@ class RoomAliasResolverViewTest { private fun AndroidComposeTestRule.setRoomAliasResolverView( state: RoomAliasResolverState, - onBackPressed: () -> Unit = EnsureNeverCalled(), - onAliasResolved: (RoomId) -> Unit = EnsureNeverCalledWithParam(), + onBackClick: () -> Unit = EnsureNeverCalled(), + onAliasResolved: (ResolvedRoomAlias) -> Unit = EnsureNeverCalledWithParam(), ) { setContent { RoomAliasResolverView( state = state, - onBackPressed = onBackPressed, - onAliasResolved = onAliasResolved, + onBackClick = onBackClick, + onSuccess = onAliasResolved, ) } } diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsNode.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsNode.kt index 83eb19dc37..d55966315e 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsNode.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsNode.kt @@ -124,7 +124,7 @@ class RoomDetailsNode @AssistedInject constructor( lifecycleScope.onShareRoom(context) } - fun onActionClicked(action: RoomDetailsAction) { + fun onActionClick(action: RoomDetailsAction) { when (action) { RoomDetailsAction.Edit -> onEditRoomDetails() RoomDetailsAction.AddTopic -> onEditRoomDetails() @@ -135,7 +135,7 @@ class RoomDetailsNode @AssistedInject constructor( state = state, modifier = modifier, goBack = this::navigateUp, - onActionClicked = ::onActionClicked, + onActionClick = ::onActionClick, onShareRoom = ::onShareRoom, openRoomMemberList = ::openRoomMemberList, openRoomNotificationSettings = ::openRoomNotificationSettings, @@ -143,7 +143,7 @@ class RoomDetailsNode @AssistedInject constructor( openAvatarPreview = ::openAvatarPreview, openPollHistory = ::openPollHistory, openAdminSettings = this::openAdminSettings, - onJoinCallClicked = ::onJoinCall, + onJoinCallClick = ::onJoinCall, ) } } 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 76807c07e6..078e1f6def 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 @@ -89,7 +89,7 @@ import io.element.android.libraries.ui.strings.CommonStrings fun RoomDetailsView( state: RoomDetailsState, goBack: () -> Unit, - onActionClicked: (RoomDetailsAction) -> Unit, + onActionClick: (RoomDetailsAction) -> Unit, onShareRoom: () -> Unit, openRoomMemberList: () -> Unit, openRoomNotificationSettings: () -> Unit, @@ -97,7 +97,7 @@ fun RoomDetailsView( openAvatarPreview: (name: String, url: String) -> Unit, openPollHistory: () -> Unit, openAdminSettings: () -> Unit, - onJoinCallClicked: () -> Unit, + onJoinCallClick: () -> Unit, modifier: Modifier = Modifier, ) { Scaffold( @@ -106,7 +106,7 @@ fun RoomDetailsView( RoomDetailsTopBar( goBack = goBack, showEdit = state.canEdit, - onActionClicked = onActionClicked + onActionClick = onActionClick ) }, ) { padding -> @@ -135,7 +135,7 @@ fun RoomDetailsView( state = state, onShareRoom = onShareRoom, onInvitePeople = invitePeople, - onCall = onJoinCallClicked, + onCall = onJoinCallClick, ) } @@ -153,7 +153,7 @@ fun RoomDetailsView( state = state, onShareRoom = onShareRoom, onInvitePeople = invitePeople, - onCall = onJoinCallClicked, + onCall = onJoinCallClick, ) } } @@ -162,7 +162,7 @@ fun RoomDetailsView( if (state.roomTopic !is RoomTopicState.Hidden) { TopicSection( roomTopic = state.roomTopic, - onActionClicked = onActionClicked, + onActionClick = onActionClick, ) } @@ -226,7 +226,7 @@ fun RoomDetailsView( @Composable private fun RoomDetailsTopBar( goBack: () -> Unit, - onActionClicked: (RoomDetailsAction) -> Unit, + onActionClick: (RoomDetailsAction) -> Unit, showEdit: Boolean, ) { var showMenu by remember { mutableStateOf(false) } @@ -249,7 +249,7 @@ private fun RoomDetailsTopBar( // Explicitly close the menu before handling the action, as otherwise it stays open during the // transition and renders really badly. showMenu = false - onActionClicked(RoomDetailsAction.Edit) + onActionClick(RoomDetailsAction.Edit) }, ) } @@ -397,14 +397,17 @@ private fun BadgeList( @Composable private fun TopicSection( roomTopic: RoomTopicState, - onActionClicked: (RoomDetailsAction) -> Unit, + onActionClick: (RoomDetailsAction) -> Unit, ) { - PreferenceCategory(title = stringResource(CommonStrings.common_topic)) { + PreferenceCategory( + title = stringResource(CommonStrings.common_topic), + showTopDivider = false, + ) { if (roomTopic is RoomTopicState.CanAddTopic) { PreferenceText( title = stringResource(R.string.screen_room_details_add_topic_title), icon = Icons.Outlined.Add, - onClick = { onActionClicked(RoomDetailsAction.AddTopic) }, + onClick = { onActionClick(RoomDetailsAction.AddTopic) }, ) } else if (roomTopic is RoomTopicState.ExistingTopic) { ClickableLinkText( @@ -489,7 +492,7 @@ private fun SecuritySection() { @Composable private fun OtherActionsSection(isDm: Boolean, onLeaveRoom: () -> Unit) { - PreferenceCategory(showDivider = false) { + PreferenceCategory(showTopDivider = true) { ListItem( headlineContent = { val leaveText = stringResource( @@ -524,7 +527,7 @@ private fun ContentToPreview(state: RoomDetailsState) { RoomDetailsView( state = state, goBack = {}, - onActionClicked = {}, + onActionClick = {}, onShareRoom = {}, openRoomMemberList = {}, openRoomNotificationSettings = {}, @@ -532,6 +535,6 @@ private fun ContentToPreview(state: RoomDetailsState) { openAvatarPreview = { _, _ -> }, openPollHistory = {}, openAdminSettings = {}, - onJoinCallClicked = {}, + onJoinCallClick = {}, ) } diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditNode.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditNode.kt index 34f6be1b7b..95f10e1859 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditNode.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditNode.kt @@ -49,8 +49,8 @@ class RoomDetailsEditNode @AssistedInject constructor( val state = presenter.present() RoomDetailsEditView( state = state, - onBackPressed = ::navigateUp, - onRoomEdited = ::navigateUp, + onBackClick = ::navigateUp, + onRoomEditSuccess = ::navigateUp, modifier = modifier, ) } diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditPresenter.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditPresenter.kt index 6213f2cf1a..67d333c41d 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditPresenter.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditPresenter.kt @@ -37,6 +37,9 @@ import io.element.android.libraries.matrix.api.room.MatrixRoom 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.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.MediaPreProcessor import io.element.android.libraries.permissions.api.PermissionsEvents @@ -61,23 +64,35 @@ class RoomDetailsEditPresenter @Inject constructor( val cameraPermissionState = cameraPermissionPresenter.present() val roomSyncUpdateFlow = room.syncUpdateFlow.collectAsState() - // Since there is no way to obtain the new avatar uri after uploading a new avatar, - // just erase the local value when the room field has changed - var roomAvatarUri by rememberSaveable(room.avatarUrl) { mutableStateOf(room.avatarUrl?.toUri()) } + val roomAvatarUri = room.avatarUrl()?.toUri() + var roomAvatarUriEdited by rememberSaveable { mutableStateOf(null) } + LaunchedEffect(roomAvatarUri) { + // Every time the roomAvatar change (from sync), we can set the new avatar. + roomAvatarUriEdited = roomAvatarUri + } - var roomName by rememberSaveable { mutableStateOf(room.displayName.trim()) } - var roomTopic by rememberSaveable { mutableStateOf(room.topic?.trim()) } + val roomRawNameTrimmed = room.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() + var roomTopicEdited by rememberSaveable { mutableStateOf("") } + LaunchedEffect(roomTopicTrimmed) { + // Every time the topic change (from sync), we can set the new topic. + roomTopicEdited = roomTopicTrimmed + } val saveButtonEnabled by remember( - roomSyncUpdateFlow.value, - roomName, - roomTopic, + roomRawNameTrimmed, + roomTopicTrimmed, roomAvatarUri, ) { derivedStateOf { - roomAvatarUri?.toString()?.trim() != room.avatarUrl?.toUri()?.toString()?.trim() || - roomName.trim() != room.displayName.trim() || - roomTopic.orEmpty().trim() != room.topic.orEmpty().trim() + roomRawNameTrimmed != roomRawNameEdited.trim() || + roomTopicTrimmed != roomTopicEdited.trim() || + roomAvatarUri != roomAvatarUriEdited } } @@ -85,17 +100,17 @@ class RoomDetailsEditPresenter @Inject constructor( var canChangeTopic by remember { mutableStateOf(false) } var canChangeAvatar by remember { mutableStateOf(false) } - LaunchedEffect(Unit) { + 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 cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker( - onResult = { uri -> if (uri != null) roomAvatarUri = uri } + onResult = { uri -> if (uri != null) roomAvatarUriEdited = uri } ) val galleryImagePicker = mediaPickerProvider.registerGalleryImagePicker( - onResult = { uri -> if (uri != null) roomAvatarUri = uri } + onResult = { uri -> if (uri != null) roomAvatarUriEdited = uri } ) LaunchedEffect(cameraPermissionState.permissionGranted) { @@ -105,12 +120,12 @@ class RoomDetailsEditPresenter @Inject constructor( } } - val avatarActions by remember(roomAvatarUri) { + val avatarActions by remember(roomAvatarUriEdited) { derivedStateOf { listOfNotNull( AvatarAction.TakePhoto, AvatarAction.ChoosePhoto, - AvatarAction.Remove.takeIf { roomAvatarUri != null }, + AvatarAction.Remove.takeIf { roomAvatarUriEdited != null }, ).toImmutableList() } } @@ -119,7 +134,15 @@ class RoomDetailsEditPresenter @Inject constructor( val localCoroutineScope = rememberCoroutineScope() fun handleEvents(event: RoomDetailsEditEvents) { when (event) { - is RoomDetailsEditEvents.Save -> localCoroutineScope.saveChanges(roomName, roomTopic, roomAvatarUri, saveAction) + is RoomDetailsEditEvents.Save -> localCoroutineScope.saveChanges( + currentNameTrimmed = roomRawNameTrimmed, + newNameTrimmed = roomRawNameEdited.trim(), + currentTopicTrimmed = roomTopicTrimmed, + newTopicTrimmed = roomTopicEdited.trim(), + currentAvatar = roomAvatarUri, + newAvatarUri = roomAvatarUriEdited, + action = saveAction, + ) is RoomDetailsEditEvents.HandleAvatarAction -> { when (event.action) { AvatarAction.ChoosePhoto -> galleryImagePicker.launch() @@ -129,23 +152,23 @@ class RoomDetailsEditPresenter @Inject constructor( pendingPermissionRequest = true cameraPermissionState.eventSink(PermissionsEvents.RequestPermissions) } - AvatarAction.Remove -> roomAvatarUri = null + AvatarAction.Remove -> roomAvatarUriEdited = null } } - is RoomDetailsEditEvents.UpdateRoomName -> roomName = event.name - is RoomDetailsEditEvents.UpdateRoomTopic -> roomTopic = event.topic.takeUnless { it.isEmpty() } + is RoomDetailsEditEvents.UpdateRoomName -> roomRawNameEdited = event.name + is RoomDetailsEditEvents.UpdateRoomTopic -> roomTopicEdited = event.topic RoomDetailsEditEvents.CancelSaveChanges -> saveAction.value = AsyncAction.Uninitialized } } return RoomDetailsEditState( - roomId = room.roomId.value, - roomName = roomName, + roomId = room.roomId, + roomRawName = roomRawNameEdited, canChangeName = canChangeName, - roomTopic = roomTopic.orEmpty(), + roomTopic = roomTopicEdited, canChangeTopic = canChangeTopic, - roomAvatarUrl = roomAvatarUri, + roomAvatarUrl = roomAvatarUriEdited, canChangeAvatar = canChangeAvatar, avatarActions = avatarActions, saveButtonEnabled = saveButtonEnabled, @@ -156,25 +179,28 @@ class RoomDetailsEditPresenter @Inject constructor( } private fun CoroutineScope.saveChanges( - name: String, - topic: String?, - avatarUri: Uri?, + currentNameTrimmed: String, + newNameTrimmed: String, + currentTopicTrimmed: String, + newTopicTrimmed: String, + currentAvatar: Uri?, + newAvatarUri: Uri?, action: MutableState>, ) = launch { val results = mutableListOf>() suspend { - if (topic.orEmpty().trim() != room.topic.orEmpty().trim()) { - results.add(room.setTopic(topic.orEmpty()).onFailure { + if (newTopicTrimmed != currentTopicTrimmed) { + results.add(room.setTopic(newTopicTrimmed).onFailure { Timber.e(it, "Failed to set room topic") }) } - if (name.isNotEmpty() && name.trim() != room.displayName.trim()) { - results.add(room.setName(name).onFailure { + if (newNameTrimmed.isNotEmpty() && newNameTrimmed != currentNameTrimmed) { + results.add(room.setName(newNameTrimmed).onFailure { Timber.e(it, "Failed to set room name") }) } - if (avatarUri?.toString()?.trim() != room.avatarUrl?.trim()) { - results.add(updateAvatar(avatarUri).onFailure { + if (newAvatarUri != currentAvatar) { + results.add(updateAvatar(newAvatarUri).onFailure { Timber.e(it, "Failed to update avatar") }) } diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditState.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditState.kt index d85450b59f..ec74905e38 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditState.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditState.kt @@ -18,13 +18,15 @@ package io.element.android.features.roomdetails.impl.edit import android.net.Uri import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.ui.media.AvatarAction import io.element.android.libraries.permissions.api.PermissionsState import kotlinx.collections.immutable.ImmutableList data class RoomDetailsEditState( - val roomId: String, - val roomName: String, + val roomId: RoomId, + /** The raw room name (i.e. the room name from the state event `m.room.name`), not the display name. */ + val roomRawName: String, val canChangeName: Boolean, val roomTopic: String, val canChangeTopic: Boolean, diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditStateProvider.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditStateProvider.kt index d70c83b9a0..abf4f64b69 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditStateProvider.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditStateProvider.kt @@ -19,33 +19,50 @@ package io.element.android.features.roomdetails.impl.edit import android.net.Uri import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.ui.media.AvatarAction +import io.element.android.libraries.permissions.api.PermissionsState import io.element.android.libraries.permissions.api.aPermissionsState -import kotlinx.collections.immutable.persistentListOf +import kotlinx.collections.immutable.toImmutableList open class RoomDetailsEditStateProvider : PreviewParameterProvider { override val values: Sequence get() = sequenceOf( aRoomDetailsEditState(), - aRoomDetailsEditState().copy(roomTopic = ""), - aRoomDetailsEditState().copy(roomAvatarUrl = Uri.parse("example://uri")), - aRoomDetailsEditState().copy(canChangeName = true, canChangeTopic = false, canChangeAvatar = true, saveButtonEnabled = false), - aRoomDetailsEditState().copy(canChangeName = false, canChangeTopic = true, canChangeAvatar = false, saveButtonEnabled = false), - aRoomDetailsEditState().copy(saveAction = AsyncAction.Loading), - aRoomDetailsEditState().copy(saveAction = AsyncAction.Failure(Throwable("Whelp"))) + aRoomDetailsEditState(roomTopic = ""), + aRoomDetailsEditState(roomRawName = ""), + aRoomDetailsEditState(roomAvatarUrl = Uri.parse("example://uri")), + aRoomDetailsEditState(canChangeName = true, canChangeTopic = false, canChangeAvatar = true, saveButtonEnabled = false), + aRoomDetailsEditState(canChangeName = false, canChangeTopic = true, canChangeAvatar = false, saveButtonEnabled = false), + aRoomDetailsEditState(saveAction = AsyncAction.Loading), + aRoomDetailsEditState(saveAction = AsyncAction.Failure(Throwable("Whelp"))), ) } -fun aRoomDetailsEditState() = RoomDetailsEditState( - roomId = "a room id", - roomName = "Marketing", - canChangeName = true, - roomTopic = "a room topic that is quite long so should wrap onto multiple lines", - canChangeTopic = true, - roomAvatarUrl = null, - canChangeAvatar = true, - avatarActions = persistentListOf(), - saveButtonEnabled = true, - saveAction = AsyncAction.Uninitialized, - cameraPermissionState = aPermissionsState(showDialog = false), - eventSink = {} +fun aRoomDetailsEditState( + roomId: RoomId = RoomId("!aRoomId:aDomain"), + roomRawName: String = "Marketing", + canChangeName: Boolean = true, + roomTopic: String = "a room topic that is quite long so should wrap onto multiple lines", + canChangeTopic: Boolean = true, + roomAvatarUrl: Uri? = null, + canChangeAvatar: Boolean = true, + avatarActions: List = emptyList(), + saveButtonEnabled: Boolean = true, + saveAction: AsyncAction = AsyncAction.Uninitialized, + cameraPermissionState: PermissionsState = aPermissionsState(showDialog = false), + eventSink: (RoomDetailsEditEvents) -> Unit = {}, +) = RoomDetailsEditState( + roomId = roomId, + roomRawName = roomRawName, + canChangeName = canChangeName, + roomTopic = roomTopic, + canChangeTopic = canChangeTopic, + roomAvatarUrl = roomAvatarUrl, + canChangeAvatar = canChangeAvatar, + avatarActions = avatarActions.toImmutableList(), + saveButtonEnabled = saveButtonEnabled, + saveAction = saveAction, + cameraPermissionState = cameraPermissionState, + eventSink = eventSink, ) diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditView.kt index b93bbd1f47..a074d1a7b2 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditView.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/edit/RoomDetailsEditView.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -@file:OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterialApi::class) +@file:OptIn(ExperimentalMaterial3Api::class) package io.element.android.features.roomdetails.impl.edit @@ -29,13 +29,11 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.verticalScroll -import androidx.compose.material.ExperimentalMaterialApi -import androidx.compose.material.ModalBottomSheetValue -import androidx.compose.material.rememberModalBottomSheetState import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable -import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.stringResource @@ -61,27 +59,20 @@ import io.element.android.libraries.matrix.ui.components.AvatarActionBottomSheet import io.element.android.libraries.matrix.ui.components.EditableAvatarView import io.element.android.libraries.permissions.api.PermissionsView import io.element.android.libraries.ui.strings.CommonStrings -import kotlinx.coroutines.launch -@OptIn(ExperimentalMaterialApi::class, ExperimentalMaterial3Api::class) @Composable fun RoomDetailsEditView( state: RoomDetailsEditState, - onBackPressed: () -> Unit, - onRoomEdited: () -> Unit, + onBackClick: () -> Unit, + onRoomEditSuccess: () -> Unit, modifier: Modifier = Modifier, ) { - val coroutineScope = rememberCoroutineScope() val focusManager = LocalFocusManager.current - val itemActionsBottomSheetState = rememberModalBottomSheetState( - initialValue = ModalBottomSheetValue.Hidden, - ) + val isAvatarActionsSheetVisible = remember { mutableStateOf(false) } - fun onAvatarClicked() { + fun onAvatarClick() { focusManager.clearFocus() - coroutineScope.launch { - itemActionsBottomSheetState.show() - } + isAvatarActionsSheetVisible.value = true } Scaffold( @@ -94,7 +85,7 @@ fun RoomDetailsEditView( style = ElementTheme.typography.aliasScreenTitle, ) }, - navigationIcon = { BackButton(onClick = onBackPressed) }, + navigationIcon = { BackButton(onClick = onBackClick) }, actions = { TextButton( text = stringResource(CommonStrings.action_save), @@ -118,11 +109,12 @@ fun RoomDetailsEditView( ) { Spacer(modifier = Modifier.height(24.dp)) EditableAvatarView( - userId = state.roomId, - displayName = state.roomName, + 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, - onAvatarClicked = ::onAvatarClicked, + onAvatarClick = ::onAvatarClick, modifier = Modifier.fillMaxWidth(), ) Spacer(modifier = Modifier.height(60.dp)) @@ -130,7 +122,7 @@ fun RoomDetailsEditView( if (state.canChangeName) { LabelledTextField( label = stringResource(id = R.string.screen_room_details_room_name_label), - value = state.roomName, + value = state.roomRawName, placeholder = stringResource(CommonStrings.common_room_name_placeholder), singleLine = true, onValueChange = { state.eventSink(RoomDetailsEditEvents.UpdateRoomName(it)) }, @@ -138,7 +130,7 @@ fun RoomDetailsEditView( } else { LabelledReadOnlyField( title = stringResource(R.string.screen_room_details_room_name_label), - value = state.roomName + value = state.roomRawName ) } @@ -166,8 +158,9 @@ fun RoomDetailsEditView( AvatarActionBottomSheet( actions = state.avatarActions, - modalBottomSheetState = itemActionsBottomSheetState, - onActionSelected = { state.eventSink(RoomDetailsEditEvents.HandleAvatarAction(it)) } + isVisible = isAvatarActionsSheetVisible.value, + onDismiss = { isAvatarActionsSheetVisible.value = false }, + onSelectAction = { state.eventSink(RoomDetailsEditEvents.HandleAvatarAction(it)) } ) AsyncActionView( @@ -177,7 +170,7 @@ fun RoomDetailsEditView( progressText = stringResource(R.string.screen_room_details_updating_room), ) }, - onSuccess = { onRoomEdited() }, + onSuccess = { onRoomEditSuccess() }, errorMessage = { stringResource(R.string.screen_room_details_edition_error) }, onErrorDismiss = { state.eventSink(RoomDetailsEditEvents.CancelSaveChanges) } ) @@ -216,7 +209,7 @@ private fun LabelledReadOnlyField( internal fun RoomDetailsEditViewPreview(@PreviewParameter(RoomDetailsEditStateProvider::class) state: RoomDetailsEditState) = ElementPreview { RoomDetailsEditView( state = state, - onBackPressed = {}, - onRoomEdited = {}, + onBackClick = {}, + onRoomEditSuccess = {}, ) } diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/invite/RoomInviteMembersNode.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/invite/RoomInviteMembersNode.kt index 34fe6273e7..5de3f9b4a7 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/invite/RoomInviteMembersNode.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/invite/RoomInviteMembersNode.kt @@ -65,8 +65,8 @@ class RoomInviteMembersNode @AssistedInject constructor( RoomInviteMembersView( state = state, modifier = modifier, - onBackPressed = { navigateUp() }, - onSubmitPressed = { users -> + onBackClick = { navigateUp() }, + onSubmitClick = { users -> navigateUp() coroutineScope.launch { diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/invite/RoomInviteMembersView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/invite/RoomInviteMembersView.kt index f6e8f07cce..51ab1886f9 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/invite/RoomInviteMembersView.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/invite/RoomInviteMembersView.kt @@ -57,22 +57,22 @@ import kotlinx.collections.immutable.ImmutableList @Composable fun RoomInviteMembersView( state: RoomInviteMembersState, - onBackPressed: () -> Unit, - onSubmitPressed: (List) -> Unit, + onBackClick: () -> Unit, + onSubmitClick: (List) -> Unit, modifier: Modifier = Modifier, ) { Scaffold( modifier = modifier, topBar = { RoomInviteMembersTopBar( - onBackPressed = { + onBackClick = { if (state.isSearchActive) { state.eventSink(RoomInviteMembersEvents.OnSearchActiveChanged(false)) } else { - onBackPressed() + onBackClick() } }, - onSubmitPressed = { onSubmitPressed(state.selectedUsers) }, + onSubmitClick = { onSubmitClick(state.selectedUsers) }, canSend = state.canInvite, ) } @@ -91,9 +91,9 @@ fun RoomInviteMembersView( selectedUsers = state.selectedUsers, state = state.searchResults, active = state.isSearchActive, - onActiveChanged = { state.eventSink(RoomInviteMembersEvents.OnSearchActiveChanged(it)) }, - onTextChanged = { state.eventSink(RoomInviteMembersEvents.UpdateSearchQuery(it)) }, - onUserToggled = { state.eventSink(RoomInviteMembersEvents.ToggleUser(it)) }, + onActiveChange = { state.eventSink(RoomInviteMembersEvents.OnSearchActiveChanged(it)) }, + onTextChange = { state.eventSink(RoomInviteMembersEvents.UpdateSearchQuery(it)) }, + onToggleUser = { state.eventSink(RoomInviteMembersEvents.ToggleUser(it)) }, ) if (!state.isSearchActive) { @@ -101,7 +101,7 @@ fun RoomInviteMembersView( modifier = Modifier.fillMaxWidth(), selectedUsers = state.selectedUsers, autoScroll = true, - onUserRemoved = { state.eventSink(RoomInviteMembersEvents.ToggleUser(it)) }, + onUserRemove = { state.eventSink(RoomInviteMembersEvents.ToggleUser(it)) }, contentPadding = PaddingValues(16.dp), ) } @@ -113,8 +113,8 @@ fun RoomInviteMembersView( @Composable private fun RoomInviteMembersTopBar( canSend: Boolean, - onBackPressed: () -> Unit, - onSubmitPressed: () -> Unit, + onBackClick: () -> Unit, + onSubmitClick: () -> Unit, ) { TopAppBar( title = { @@ -123,11 +123,11 @@ private fun RoomInviteMembersTopBar( style = ElementTheme.typography.aliasScreenTitle, ) }, - navigationIcon = { BackButton(onClick = onBackPressed) }, + navigationIcon = { BackButton(onClick = onBackClick) }, actions = { TextButton( text = stringResource(CommonStrings.action_invite), - onClick = onSubmitPressed, + onClick = onSubmitClick, enabled = canSend, ) } @@ -142,17 +142,17 @@ private fun RoomInviteMembersSearchBar( showLoader: Boolean, selectedUsers: ImmutableList, active: Boolean, - onActiveChanged: (Boolean) -> Unit, - onTextChanged: (String) -> Unit, - onUserToggled: (MatrixUser) -> Unit, + onActiveChange: (Boolean) -> Unit, + onTextChange: (String) -> Unit, + onToggleUser: (MatrixUser) -> Unit, modifier: Modifier = Modifier, placeHolderTitle: String = stringResource(CommonStrings.common_search_for_someone), ) { SearchBar( query = query, - onQueryChange = onTextChanged, + onQueryChange = onTextChange, active = active, - onActiveChange = onActiveChanged, + onActiveChange = onActiveChange, modifier = modifier, placeHolderTitle = placeHolderTitle, contentPrefix = { @@ -161,7 +161,7 @@ private fun RoomInviteMembersSearchBar( modifier = Modifier.fillMaxWidth(), selectedUsers = selectedUsers, autoScroll = true, - onUserRemoved = onUserToggled, + onUserRemove = onToggleUser, contentPadding = PaddingValues(16.dp), ) } @@ -210,7 +210,7 @@ private fun RoomInviteMembersSearchBar( checked = invitableUser.isSelected, enabled = enabled, data = data, - onCheckedChange = { onUserToggled(invitableUser.matrixUser) }, + onCheckedChange = { onToggleUser(invitableUser.matrixUser) }, modifier = Modifier.fillMaxWidth() ) @@ -228,7 +228,7 @@ private fun RoomInviteMembersSearchBar( internal fun RoomInviteMembersViewPreview(@PreviewParameter(RoomInviteMembersStateProvider::class) state: RoomInviteMembersState) = ElementPreview { RoomInviteMembersView( state = state, - onBackPressed = {}, - onSubmitPressed = {}, + onBackClick = {}, + onSubmitClick = {}, ) } diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListView.kt index c111245ee0..488286ce00 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListView.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/RoomMemberListView.kt @@ -85,7 +85,7 @@ fun RoomMemberListView( modifier: Modifier = Modifier, initialSelectedSectionIndex: Int = 0, ) { - fun onUserSelected(roomMember: RoomMember) { + fun onSelectUser(roomMember: RoomMember) { state.eventSink(RoomMemberListEvents.RoomMemberSelected(roomMember)) } @@ -95,8 +95,8 @@ fun RoomMemberListView( if (!state.isSearchActive) { RoomMemberListTopBar( canInvite = state.canInvite, - onBackPressed = navigator::exitRoomMemberList, - onInvitePressed = navigator::openInviteMembers, + onBackClick = navigator::exitRoomMemberList, + onInviteClick = navigator::openInviteMembers, ) } } @@ -119,9 +119,9 @@ fun RoomMemberListView( state = state.searchResults, active = state.isSearchActive, placeHolderTitle = stringResource(CommonStrings.common_search_for_someone), - onActiveChanged = { state.eventSink(RoomMemberListEvents.OnSearchActiveChanged(it)) }, - onTextChanged = { state.eventSink(RoomMemberListEvents.UpdateSearchQuery(it)) }, - onUserSelected = ::onUserSelected, + onActiveChange = { state.eventSink(RoomMemberListEvents.OnSearchActiveChanged(it)) }, + onTextChange = { state.eventSink(RoomMemberListEvents.UpdateSearchQuery(it)) }, + onSelectUser = ::onSelectUser, selectedSection = selectedSection, modifier = Modifier.fillMaxWidth(), ) @@ -133,8 +133,8 @@ fun RoomMemberListView( showMembersCount = true, canDisplayBannedUsersControls = state.moderationState.canDisplayBannedUsers, selectedSection = selectedSection, - onSelectedSectionChanged = { selectedSection = it }, - onUserSelected = ::onUserSelected, + onSelectedSectionChange = { selectedSection = it }, + onSelectUser = ::onSelectUser, ) } } @@ -153,9 +153,9 @@ private fun RoomMemberList( roomMembers: RoomMembers, showMembersCount: Boolean, selectedSection: SelectedSection, - onSelectedSectionChanged: (SelectedSection) -> Unit, + onSelectedSectionChange: (SelectedSection) -> Unit, canDisplayBannedUsersControls: Boolean, - onUserSelected: (RoomMember) -> Unit, + onSelectUser: (RoomMember) -> Unit, ) { LazyColumn(modifier = Modifier.fillMaxWidth(), state = rememberLazyListState()) { stickyHeader { @@ -176,7 +176,7 @@ private fun RoomMemberList( index = index, count = segmentedButtonTitles.size, selected = selectedSection.ordinal == index, - onClick = { onSelectedSectionChanged(SelectedSection.entries[index]) }, + onClick = { onSelectedSectionChange(SelectedSection.entries[index]) }, text = title, ) } @@ -197,7 +197,7 @@ private fun RoomMemberList( roomMemberListSection( headerText = { stringResource(id = R.string.screen_room_member_list_pending_header_title) }, members = roomMembers.invited, - onMemberSelected = { onUserSelected(it) } + onMemberSelected = { onSelectUser(it) } ) } if (roomMembers.joined.isNotEmpty()) { @@ -211,7 +211,7 @@ private fun RoomMemberList( } }, members = roomMembers.joined, - onMemberSelected = { onUserSelected(it) } + onMemberSelected = { onSelectUser(it) } ) } } @@ -220,7 +220,7 @@ private fun RoomMemberList( roomMemberListSection( headerText = null, members = roomMembers.banned, - onMemberSelected = { onUserSelected(it) } + onMemberSelected = { onSelectUser(it) } ) } else { item { @@ -298,8 +298,8 @@ private fun RoomMemberListItem( @Composable private fun RoomMemberListTopBar( canInvite: Boolean, - onBackPressed: () -> Unit, - onInvitePressed: () -> Unit, + onBackClick: () -> Unit, + onInviteClick: () -> Unit, ) { TopAppBar( title = { @@ -308,12 +308,12 @@ private fun RoomMemberListTopBar( style = ElementTheme.typography.aliasScreenTitle, ) }, - navigationIcon = { BackButton(onClick = onBackPressed) }, + navigationIcon = { BackButton(onClick = onBackClick) }, actions = { if (canInvite) { TextButton( text = stringResource(CommonStrings.action_invite), - onClick = onInvitePressed, + onClick = onInviteClick, ) } } @@ -327,17 +327,17 @@ private fun RoomMemberSearchBar( state: SearchBarResultState, active: Boolean, placeHolderTitle: String, - onActiveChanged: (Boolean) -> Unit, - onTextChanged: (String) -> Unit, - onUserSelected: (RoomMember) -> Unit, + onActiveChange: (Boolean) -> Unit, + onTextChange: (String) -> Unit, + onSelectUser: (RoomMember) -> Unit, selectedSection: SelectedSection, modifier: Modifier = Modifier, ) { SearchBar( query = query, - onQueryChange = onTextChanged, + onQueryChange = onTextChange, active = active, - onActiveChange = onActiveChanged, + onActiveChange = onActiveChange, modifier = modifier, placeHolderTitle = placeHolderTitle, resultState = state, @@ -346,10 +346,10 @@ private fun RoomMemberSearchBar( isLoading = false, roomMembers = results, showMembersCount = false, - onUserSelected = { onUserSelected(it) }, + onSelectUser = { onSelectUser(it) }, canDisplayBannedUsersControls = false, selectedSection = selectedSection, - onSelectedSectionChanged = {}, + onSelectedSectionChange = {}, ) }, ) diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsNode.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsNode.kt index caccbc97be..eebfec967f 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsNode.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/details/RoomMemberDetailsNode.kt @@ -93,7 +93,7 @@ class RoomMemberDetailsNode @AssistedInject constructor( modifier = modifier, goBack = this::navigateUp, onShareUser = ::onShareUser, - onDmStarted = ::onStartDM, + onOpenDm = ::onStartDM, onStartCall = ::onStartCall, openAvatarPreview = callback::openAvatarPreview, ) diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/moderation/RoomMembersModerationView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/moderation/RoomMembersModerationView.kt index 54060b959d..03c602fdd4 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/moderation/RoomMembersModerationView.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/members/moderation/RoomMembersModerationView.kt @@ -75,7 +75,7 @@ fun RoomMembersModerationView( RoomMemberActionsBottomSheet( roomMember = state.selectedRoomMember, actions = state.actions, - onActionSelected = { action -> + onSelectAction = { action -> when (action) { is ModerationAction.DisplayProfile -> { onDisplayMemberProfile(action.userId) @@ -126,7 +126,7 @@ fun RoomMembersModerationView( title = stringResource(R.string.screen_room_member_list_ban_member_confirmation_title), content = stringResource(R.string.screen_room_member_list_ban_member_confirmation_description), submitText = stringResource(R.string.screen_room_member_list_ban_member_confirmation_action), - onSubmitClicked = { state.selectedRoomMember?.userId?.let { state.eventSink(RoomMembersModerationEvents.BanUser) } }, + onSubmitClick = { state.selectedRoomMember?.userId?.let { state.eventSink(RoomMembersModerationEvents.BanUser) } }, onDismiss = { state.eventSink(RoomMembersModerationEvents.Reset) } ) } @@ -161,7 +161,7 @@ fun RoomMembersModerationView( title = stringResource(R.string.screen_room_member_list_manage_member_unban_title), content = stringResource(R.string.screen_room_member_list_manage_member_unban_message), submitText = stringResource(R.string.screen_room_member_list_manage_member_unban_action), - onSubmitClicked = { state.eventSink(RoomMembersModerationEvents.UnbanUser) }, + onSubmitClick = { state.eventSink(RoomMembersModerationEvents.UnbanUser) }, onDismiss = { state.eventSink(RoomMembersModerationEvents.Reset) }, ) } @@ -197,7 +197,7 @@ fun RoomMembersModerationView( private fun RoomMemberActionsBottomSheet( roomMember: RoomMember?, actions: ImmutableList, - onActionSelected: (ModerationAction) -> Unit, + onSelectAction: (ModerationAction) -> Unit, onDismiss: () -> Unit, ) { val coroutineScope = rememberCoroutineScope() @@ -260,7 +260,7 @@ private fun RoomMemberActionsBottomSheet( leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Info())), onClick = { coroutineScope.launch { - onActionSelected(action) + onSelectAction(action) bottomSheetState.hide() } } @@ -273,7 +273,7 @@ private fun RoomMemberActionsBottomSheet( onClick = { coroutineScope.launch { bottomSheetState.hide() - onActionSelected(action) + onSelectAction(action) } } ) @@ -286,7 +286,7 @@ private fun RoomMemberActionsBottomSheet( onClick = { coroutineScope.launch { bottomSheetState.hide() - onActionSelected(action) + onSelectAction(action) } } ) diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsNode.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsNode.kt index 3b65bdf2fd..1b76daf431 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsNode.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsNode.kt @@ -68,7 +68,7 @@ class RoomNotificationSettingsNode @AssistedInject constructor( state = state, modifier = modifier, onShowGlobalNotifications = this::openGlobalNotificationSettings, - onBackPressed = this::navigateUp, + onBackClick = this::navigateUp, ) } } diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsOption.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsOption.kt index 15e450502a..25d9fd929c 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsOption.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsOption.kt @@ -31,7 +31,7 @@ import io.element.android.libraries.matrix.api.room.RoomNotificationMode @Composable fun RoomNotificationSettingsOption( roomNotificationSettingsItem: RoomNotificationSettingsItem, - onOptionSelected: (RoomNotificationSettingsItem) -> Unit, + onSelectOption: (RoomNotificationSettingsItem) -> Unit, displayMentionsOnlyDisclaimer: Boolean, modifier: Modifier = Modifier, enabled: Boolean = true, @@ -51,7 +51,7 @@ fun RoomNotificationSettingsOption( headlineContent = { Text(title) }, supportingContent = subtitle?.let { { Text(it) } }, trailingContent = ListItemContent.RadioButton(selected = isSelected), - onClick = { onOptionSelected(roomNotificationSettingsItem) }, + onClick = { onSelectOption(roomNotificationSettingsItem) }, ) } @@ -62,7 +62,7 @@ internal fun RoomNotificationSettingsOptionPreview() = ElementPreview { for ((index, item) in roomNotificationSettingsItems().withIndex()) { RoomNotificationSettingsOption( roomNotificationSettingsItem = item, - onOptionSelected = {}, + onSelectOption = {}, isSelected = index == 0, enabled = index != 2, displayMentionsOnlyDisclaimer = index == 1, diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsOptions.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsOptions.kt index 37bff2ab88..f84e19b2e9 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsOptions.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsOptions.kt @@ -26,7 +26,7 @@ import io.element.android.libraries.matrix.api.room.RoomNotificationMode fun RoomNotificationSettingsOptions( selected: RoomNotificationMode?, enabled: Boolean, - onOptionSelected: (RoomNotificationSettingsItem) -> Unit, + onSelectOption: (RoomNotificationSettingsItem) -> Unit, displayMentionsOnlyDisclaimer: Boolean, modifier: Modifier = Modifier, ) { @@ -36,7 +36,7 @@ fun RoomNotificationSettingsOptions( RoomNotificationSettingsOption( roomNotificationSettingsItem = item, isSelected = selected == item.mode, - onOptionSelected = onOptionSelected, + onSelectOption = onSelectOption, displayMentionsOnlyDisclaimer = displayMentionsOnlyDisclaimer, enabled = enabled ) diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsView.kt index ec3d436752..2022c87de0 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsView.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/RoomNotificationSettingsView.kt @@ -51,21 +51,21 @@ import io.element.android.libraries.ui.strings.CommonStrings fun RoomNotificationSettingsView( state: RoomNotificationSettingsState, onShowGlobalNotifications: () -> Unit, - onBackPressed: () -> Unit, + onBackClick: () -> Unit, modifier: Modifier = Modifier, ) { if (state.showUserDefinedSettingStyle) { UserDefinedRoomNotificationSettingsView( state = state, modifier = modifier, - onBackPressed = onBackPressed, + onBackClick = onBackClick, ) } else { RoomSpecificNotificationSettingsView( state = state, modifier = modifier, onShowGlobalNotifications = onShowGlobalNotifications, - onBackPressed = onBackPressed, + onBackClick = onBackClick, ) } } @@ -74,14 +74,14 @@ fun RoomNotificationSettingsView( private fun RoomSpecificNotificationSettingsView( state: RoomNotificationSettingsState, onShowGlobalNotifications: () -> Unit, - onBackPressed: () -> Unit, + onBackClick: () -> Unit, modifier: Modifier = Modifier, ) { Scaffold( modifier = modifier, topBar = { RoomNotificationSettingsTopBar( - onBackPressed = { onBackPressed() } + onBackClick = { onBackClick() } ) } ) { padding -> @@ -136,7 +136,7 @@ private fun RoomSpecificNotificationSettingsView( RoomNotificationSettingsOption( roomNotificationSettingsItem = RoomNotificationSettingsItem(state.defaultRoomNotificationMode, defaultModeTitle), isSelected = true, - onOptionSelected = { }, + onSelectOption = { }, displayMentionsOnlyDisclaimer = displayMentionsOnlyDisclaimer, enabled = true ) @@ -148,7 +148,7 @@ private fun RoomSpecificNotificationSettingsView( selected = state.displayNotificationMode, enabled = !state.displayIsDefault.orTrue(), displayMentionsOnlyDisclaimer = state.displayMentionsOnlyDisclaimer, - onOptionSelected = { + onSelectOption = { state.eventSink(RoomNotificationSettingsEvents.RoomNotificationModeChanged(it.mode)) }, ) @@ -175,7 +175,7 @@ private fun RoomSpecificNotificationSettingsView( @OptIn(ExperimentalMaterial3Api::class) @Composable private fun RoomNotificationSettingsTopBar( - onBackPressed: () -> Unit, + onBackClick: () -> Unit, ) { TopAppBar( title = { @@ -184,7 +184,7 @@ private fun RoomNotificationSettingsTopBar( style = ElementTheme.typography.aliasScreenTitle, ) }, - navigationIcon = { BackButton(onClick = onBackPressed) }, + navigationIcon = { BackButton(onClick = onBackClick) }, ) } @@ -196,6 +196,6 @@ internal fun RoomNotificationSettingsViewPreview( RoomNotificationSettingsView( state = state, onShowGlobalNotifications = {}, - onBackPressed = {}, + onBackClick = {}, ) } diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/UserDefinedRoomNotificationSettingsView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/UserDefinedRoomNotificationSettingsView.kt index 925ea23401..81f0c5f9d9 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/UserDefinedRoomNotificationSettingsView.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/notificationsettings/UserDefinedRoomNotificationSettingsView.kt @@ -42,7 +42,7 @@ import io.element.android.libraries.designsystem.theme.components.TopAppBar @Composable fun UserDefinedRoomNotificationSettingsView( state: RoomNotificationSettingsState, - onBackPressed: () -> Unit, + onBackClick: () -> Unit, modifier: Modifier = Modifier, ) { Scaffold( @@ -50,7 +50,7 @@ fun UserDefinedRoomNotificationSettingsView( topBar = { UserDefinedRoomNotificationSettingsTopBar( roomName = state.roomName, - onBackPressed = { onBackPressed() } + onBackClick = { onBackClick() } ) } ) { padding -> @@ -67,7 +67,7 @@ fun UserDefinedRoomNotificationSettingsView( selected = state.displayNotificationMode, enabled = !state.displayIsDefault.orTrue(), displayMentionsOnlyDisclaimer = state.displayMentionsOnlyDisclaimer, - onOptionSelected = { + onSelectOption = { state.eventSink(RoomNotificationSettingsEvents.RoomNotificationModeChanged(it.mode)) }, ) @@ -90,7 +90,7 @@ fun UserDefinedRoomNotificationSettingsView( AsyncActionView( async = state.restoreDefaultAction, - onSuccess = { onBackPressed() }, + onSuccess = { onBackClick() }, errorMessage = { stringResource(R.string.screen_notification_settings_edit_failed_updating_default_mode) }, onErrorDismiss = { state.eventSink(RoomNotificationSettingsEvents.ClearRestoreDefaultError) }, ) @@ -102,7 +102,7 @@ fun UserDefinedRoomNotificationSettingsView( @Composable private fun UserDefinedRoomNotificationSettingsTopBar( roomName: String, - onBackPressed: () -> Unit, + onBackClick: () -> Unit, ) { TopAppBar( title = { @@ -110,7 +110,7 @@ private fun UserDefinedRoomNotificationSettingsTopBar( text = roomName, ) }, - navigationIcon = { BackButton(onClick = onBackPressed) }, + navigationIcon = { BackButton(onClick = onBackClick) }, ) } @@ -121,6 +121,6 @@ internal fun UserDefinedRoomNotificationSettingsViewPreview( ) = ElementPreview { UserDefinedRoomNotificationSettingsView( state = state, - onBackPressed = {}, + onBackClick = {}, ) } diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/RolesAndPermissionsNode.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/RolesAndPermissionsNode.kt index af04465c9f..52b77f6ac6 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/RolesAndPermissionsNode.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/RolesAndPermissionsNode.kt @@ -49,14 +49,14 @@ class RolesAndPermissionsNode @AssistedInject constructor( override fun openEditRoomDetailsPermissions() override fun openMessagesAndContentPermissions() override fun openModerationPermissions() - override fun onBackPressed() {} + override fun onBackClick() {} } private val callback = plugins().first() @Stable private val navigator = object : RolesAndPermissionsNavigator by callback { - override fun onBackPressed() { + override fun onBackClick() { navigateUp() } } @@ -88,7 +88,7 @@ class RolesAndPermissionsNode @AssistedInject constructor( } interface RolesAndPermissionsNavigator { - fun onBackPressed() {} + fun onBackClick() {} fun openAdminList() {} fun openModeratorList() {} fun openEditRoomDetailsPermissions() {} diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/RolesAndPermissionsView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/RolesAndPermissionsView.kt index 268e7ca478..0732c5c845 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/RolesAndPermissionsView.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/RolesAndPermissionsView.kt @@ -61,7 +61,7 @@ fun RolesAndPermissionsView( PreferencePage( modifier = modifier, title = stringResource(R.string.screen_room_roles_and_permissions_title), - onBackPressed = rolesAndPermissionsNavigator::onBackPressed, + onBackClick = rolesAndPermissionsNavigator::onBackClick, ) { ListSectionHeader(title = stringResource(R.string.screen_room_roles_and_permissions_roles_header), hasDivider = false) ListItem( @@ -113,7 +113,7 @@ fun RolesAndPermissionsView( content = stringResource(R.string.screen_room_roles_and_permissions_reset_confirm_description), submitText = stringResource(CommonStrings.action_reset), destructiveSubmit = true, - onSubmitClicked = { state.eventSink(RolesAndPermissionsEvents.ResetPermissions) }, + onSubmitClick = { state.eventSink(RolesAndPermissionsEvents.ResetPermissions) }, onDismiss = { state.eventSink(RolesAndPermissionsEvents.CancelPendingAction) }, ) }, diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/changeroles/ChangeRolesView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/changeroles/ChangeRolesView.kt index 450005ae4a..f5b1873717 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/changeroles/ChangeRolesView.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/changeroles/ChangeRolesView.kt @@ -91,7 +91,7 @@ fun ChangeRolesView( navigateUp: () -> Unit, modifier: Modifier = Modifier, ) { - val updatedNavigateUp by rememberUpdatedState(newValue = navigateUp) + val latestNavigateUp by rememberUpdatedState(newValue = navigateUp) BackHandler(enabled = !state.isSearchActive) { state.eventSink(ChangeRolesEvent.Exit) } @@ -150,7 +150,7 @@ fun ChangeRolesView( searchResults = members, selectedUsers = state.selectedUsers, canRemoveMember = state.canChangeMemberRole, - onSelectionToggled = { state.eventSink(ChangeRolesEvent.UserSelectionToggled(it.toMatrixUser())) }, + onToggleSelection = { state.eventSink(ChangeRolesEvent.UserSelectionToggled(it.toMatrixUser())) }, selectedUsersList = {}, ) } @@ -166,12 +166,12 @@ fun ChangeRolesView( searchResults = (state.searchResults as? SearchBarResultState.Results)?.results ?: MembersByRole(emptyList()), selectedUsers = state.selectedUsers, canRemoveMember = state.canChangeMemberRole, - onSelectionToggled = { state.eventSink(ChangeRolesEvent.UserSelectionToggled(it.toMatrixUser())) }, + onToggleSelection = { state.eventSink(ChangeRolesEvent.UserSelectionToggled(it.toMatrixUser())) }, selectedUsersList = { users -> SelectedUsersRowList( contentPadding = PaddingValues(start = 16.dp, end = 16.dp, bottom = 16.dp), selectedUsers = users, - onUserRemoved = { + onUserRemove = { state.eventSink(ChangeRolesEvent.UserSelectionToggled(it)) }, canDeselect = { state.canChangeMemberRole(it.userId) }, @@ -188,12 +188,12 @@ fun ChangeRolesView( AsyncActionView( async = state.exitState, - onSuccess = { updatedNavigateUp() }, + onSuccess = { latestNavigateUp() }, confirmationDialog = { ConfirmationDialog( title = stringResource(CommonStrings.dialog_unsaved_changes_title), content = stringResource(CommonStrings.dialog_unsaved_changes_description_android), - onSubmitClicked = { state.eventSink(ChangeRolesEvent.Exit) }, + onSubmitClick = { state.eventSink(ChangeRolesEvent.Exit) }, onDismiss = { state.eventSink(ChangeRolesEvent.CancelExit) } ) }, @@ -207,7 +207,7 @@ fun ChangeRolesView( ConfirmationDialog( title = stringResource(R.string.screen_room_change_role_confirm_add_admin_title), content = stringResource(R.string.screen_room_change_role_confirm_add_admin_description), - onSubmitClicked = { state.eventSink(ChangeRolesEvent.Save) }, + onSubmitClick = { state.eventSink(ChangeRolesEvent.Save) }, onDismiss = { state.eventSink(ChangeRolesEvent.ClearError) } ) } @@ -240,7 +240,7 @@ private fun SearchResultsList( searchResults: MembersByRole, selectedUsers: ImmutableList, canRemoveMember: (UserId) -> Boolean, - onSelectionToggled: (RoomMember) -> Unit, + onToggleSelection: (RoomMember) -> Unit, lazyListState: LazyListState, selectedUsersList: @Composable (ImmutableList) -> Unit, ) { @@ -268,7 +268,7 @@ private fun SearchResultsList( ListMemberItem( roomMember = roomMember, canRemoveMember = canRemoveMember, - onSelectionToggled = onSelectionToggled, + onToggleSelection = onToggleSelection, selectedUsers = selectedUsers ) } @@ -279,7 +279,7 @@ private fun SearchResultsList( ListMemberItem( roomMember = roomMember, canRemoveMember = canRemoveMember, - onSelectionToggled = onSelectionToggled, + onToggleSelection = onToggleSelection, selectedUsers = selectedUsers ) } @@ -290,7 +290,7 @@ private fun SearchResultsList( ListMemberItem( roomMember = roomMember, canRemoveMember = canRemoveMember, - onSelectionToggled = onSelectionToggled, + onToggleSelection = onToggleSelection, selectedUsers = selectedUsers ) } @@ -314,19 +314,19 @@ private fun ListSectionHeader(text: String) { private fun ListMemberItem( roomMember: RoomMember, canRemoveMember: (UserId) -> Boolean, - onSelectionToggled: (RoomMember) -> Unit, + onToggleSelection: (RoomMember) -> Unit, selectedUsers: ImmutableList, ) { val canToggle = canRemoveMember(roomMember.userId) val trailingContent: @Composable (() -> Unit) = { Checkbox( checked = selectedUsers.any { it.userId == roomMember.userId }, - onCheckedChange = { onSelectionToggled(roomMember) }, + onCheckedChange = { onToggleSelection(roomMember) }, enabled = canToggle, ) } MemberRow( - modifier = Modifier.clickable(enabled = canToggle, onClick = { onSelectionToggled(roomMember) }), + modifier = Modifier.clickable(enabled = canToggle, onClick = { onToggleSelection(roomMember) }), avatarData = AvatarData(roomMember.userId.value, roomMember.displayName, roomMember.avatarUrl, AvatarSize.UserListItem), name = roomMember.getBestName(), userId = roomMember.userId.value.takeIf { roomMember.displayName?.isNotBlank() == true }, diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/permissions/ChangeRoomPermissionsNode.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/permissions/ChangeRoomPermissionsNode.kt index 18b369a38b..3ab9ca3e5a 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/permissions/ChangeRoomPermissionsNode.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/permissions/ChangeRoomPermissionsNode.kt @@ -53,7 +53,7 @@ class ChangeRoomPermissionsNode @AssistedInject constructor( ChangeRoomPermissionsView( modifier = modifier, state = state, - onBackPressed = this::navigateUp, + onBackClick = this::navigateUp, ) } } diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/permissions/ChangeRoomPermissionsView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/permissions/ChangeRoomPermissionsView.kt index c9e09c7c68..f997561218 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/permissions/ChangeRoomPermissionsView.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/rolesandpermissions/permissions/ChangeRoomPermissionsView.kt @@ -52,7 +52,7 @@ import io.element.android.libraries.ui.strings.CommonStrings @Composable fun ChangeRoomPermissionsView( state: ChangeRoomPermissionsState, - onBackPressed: () -> Unit, + onBackClick: () -> Unit, modifier: Modifier = Modifier, ) { BackHandler { @@ -117,20 +117,20 @@ fun ChangeRoomPermissionsView( AsyncActionView( async = state.saveAction, - onSuccess = { onBackPressed() }, + onSuccess = { onBackClick() }, onErrorDismiss = { state.eventSink(ChangeRoomPermissionsEvent.ResetPendingActions) } ) AsyncActionView( async = state.confirmExitAction, - onSuccess = { onBackPressed() }, + onSuccess = { onBackClick() }, 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), - onSubmitClicked = { state.eventSink(ChangeRoomPermissionsEvent.Save) }, + onSubmitClick = { state.eventSink(ChangeRoomPermissionsEvent.Save) }, onDismiss = { state.eventSink(ChangeRoomPermissionsEvent.Exit) } ) }, @@ -193,7 +193,7 @@ internal fun ChangeRoomPermissionsViewPreview(@PreviewParameter(ChangeRoomPermis ElementPreview { ChangeRoomPermissionsView( state = state, - onBackPressed = {}, + onBackClick = {}, ) } } 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 cb2dc4ce7c..13d5dedf0a 100644 --- a/features/roomdetails/impl/src/main/res/values-be/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-be/translations.xml @@ -35,6 +35,9 @@ "Дадаць тэму" "Ужо ўдзельнік" "Ужо запрасілі" + "Зашыфравана" + "Не зашыфравана" + "Публічны пакой" "Рэдагаваць пакой" "Адбылася невядомая памылка, і інфармацыю нельга было змяніць." "Немагчыма абнавіць пакой" @@ -63,7 +66,7 @@ "Блакіроўка %1$s" "%1$d удзельнік" - "%1$d удзельніка" + "%1$d удзельнікі" "%1$d удзельнікаў" "Выдаліць і заблакіраваць удзельніка" 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 a8bbc85973..09003dc610 100644 --- a/features/roomdetails/impl/src/main/res/values-cs/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-cs/translations.xml @@ -35,6 +35,9 @@ "Přidat téma" "Již členem" "Již pozván(a)" + "Šifrováno" + "Není šifrováno" + "Veřejná místnost" "Upravit místnost" "Došlo k neznámé chybě a informace nebylo možné změnit." "Nelze aktualizovat místnost" 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 ae0366a6b1..f3bb984173 100644 --- a/features/roomdetails/impl/src/main/res/values-de/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-de/translations.xml @@ -35,6 +35,9 @@ "Thema hinzufügen" "Bereits Mitglied" "Bereits eingeladen" + "Verschlüsselt" + "Nicht verschlüsselt" + "Öffentlicher Raum" "Raum bearbeiten" "Es ist ein unbekannter Fehler aufgetreten und die Informationen konnten nicht geändert werden." "Raum kann nicht aktualisiert werden" 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 16e6a55cfd..17a0249f09 100644 --- a/features/roomdetails/impl/src/main/res/values-es/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-es/translations.xml @@ -3,7 +3,32 @@ "Se ha producido un error al actualizar la configuración de notificaciones." "Tu servidor principal no admite esta opción en salas cifradas, puede que no recibas notificaciones en algunas salas." "Encuestas" + "Solo administradores" + "Prohibir personas" + "Eliminar mensajes" "Todos" + "Invitar a personas" + "Moderación de miembros" + "Mensajes y contenido" + "Administradores y moderadores" + "Eliminar personas" + "Cambiar el avatar de la sala" + "Detalles de la sala" + "Cambiar el nombre de la sala" + "Cambiar el tema de la sala" + "Enviar mensajes" + "Editar administradores" + "No podrás deshacer esta acción. Estás promocionando al usuario para que tenga el mismo nivel de poder que tú." + "¿Agregar Admin?" + "Degradar" + "No podrás deshacer este cambio ya que te estás degradando. Si eres el último usuario privilegiado en la sala será imposible recuperar los privilegios." + "¿Degradarte?" + "Editar moderadores" + "Administradores" + "Moderadores" + "Miembros" + "Tienes cambios sin guardar." + "¿Guardar cambios?" "Añadir tema" "Ya eres miembro" "Ya estás invitado" @@ -16,21 +41,43 @@ "No se ha podido silenciar esta sala, inténtalo de nuevo." "Error al dejar de silenciar esta sala, por favor inténtalo de nuevo." "Invitar personas" + "Salir de la conversación" "Salir de la sala" "Personalizado" "Por defecto" "Notificaciones" + "Roles y permisos" "Nombre de la sala" "Seguridad" "Compartir sala" "Tema" "Actualizando la sala…" + "Prohibir" + "No podrán volver a unirse a esta sala si son invitados." + "¿Estás seguro de que quieres prohibir a este miembro?" + "No hay usuarios prohibidos en esta sala." + "Prohibiendo %1$s" "Una persona" "%1$d personas" + "Eliminar y prohibir a un miembro" + "Remover de la sala" + "Eliminar y prohibir miembro" + "Solo eliminar miembro" + "¿Eliminar al miembro y prohibirle unirse en el futuro?" + "Anular la prohibición" + "Podrán volver a unirse a esta sala si son invitados de nuevo." + "Desprohibir al usuario" + "Ver perfil" + "Prohibidos" + "Miembros" "Pendiente" + "Eliminando %1$s…" + "Admin" + "Moderador" "Miembros de la sala" + "Dejando de prohibir %1$s" "Permitir configuración personalizada" "Si activas esta opción, anularás tu configuración por defecto" "Notificarme en este chat para" @@ -45,4 +92,18 @@ "Todos los mensajes" "Únicamente Menciones y Palabras clave" "En esta sala, notificarme por" + "Administradores" + "Cambiar mi rol" + "Degradar a miembro" + "Degradar a moderador" + "Moderación de miembros" + "Mensajes y contenido" + "Moderadores" + "Permisos" + "Restablecer permisos" + "Una vez que restablezca los permisos, perderá la configuración actual." + "¿Restablecer los permisos?" + "Roles" + "Detalles de la sala" + "Roles y permisos" 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 71bc11c618..b9f3eed338 100644 --- a/features/roomdetails/impl/src/main/res/values-fr/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-fr/translations.xml @@ -35,6 +35,9 @@ "Ajouter un sujet" "Déjà membre" "Déjà invité(e)" + "Chiffré" + "Non chiffré" + "Salon public" "Modifier le salon" "Une erreur inconnue s’est produite et les informations n’ont pas pu être modifiées." "Impossible de mettre à jour le salon" @@ -85,7 +88,7 @@ "Autoriser les paramètres personnalisés" "L’activation de cette option annulera votre paramètre par défaut" "Prévenez-moi dans ce salon pour" - "Vous pouvez le modifier dans votre %1$s." + "Vous pouvez le modifier dans vos %1$s." "paramètres globaux" "Paramètre par défaut" "Supprimer le paramètre personnalisé" 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 883a67e014..db9013b6cc 100644 --- a/features/roomdetails/impl/src/main/res/values-hu/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-hu/translations.xml @@ -35,6 +35,9 @@ "Téma hozzáadása" "Már tag" "Már meghívták" + "Titkosítva" + "Nincs titkosítva" + "Nyilvános szoba" "Szoba szerkesztése" "Ismeretlen hiba történt, és az információkat nem lehetett megváltoztatni." "Nem sikerült frissíteni a szobát" 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 ab3ec27fcc..d1ae1c380c 100644 --- a/features/roomdetails/impl/src/main/res/values-it/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-it/translations.xml @@ -24,6 +24,8 @@ "Non potrai annullare questa modifica perché ti stai declassando, se sei l\'ultimo utente privilegiato nella stanza, sarà impossibile riottenere i privilegi." "Declassare te stesso?" "%1$s (In attesa)" + "(In attesa)" + "Gli amministratori hanno automaticamente i privilegi di moderatore" "Modifica moderatori" "Amministratori" "Moderatori" @@ -51,6 +53,7 @@ "Nome stanza" "Sicurezza" "Condividi stanza" + "Informazioni sulla stanza" "Argomento" "Aggiornamento della stanza…" "Escludi" diff --git a/features/roomdetails/impl/src/main/res/values-ka/translations.xml b/features/roomdetails/impl/src/main/res/values-ka/translations.xml new file mode 100644 index 0000000000..75f7121b45 --- /dev/null +++ b/features/roomdetails/impl/src/main/res/values-ka/translations.xml @@ -0,0 +1,45 @@ + + + "შეტყობინებების პარამეტრის განახლებისას მოხდა შეცდომა." + "ყველა" + "თემის დამატება" + "უკვე წევრია" + "უკვე მოწვეულია" + "ოთახის რედაქტირება" + "უცნობი შეცდომა მოხდა. ინფორმაციის შეცვლა ვერ მოხერხდა." + "ოთახის განახლება შეუძლებელია" + "შეტყობინებები დაცულია საკეტებით. მხოლოდ თქვენ და მიმღებებს გაქვთ მათი განშიფვრის უნიკალური გასაღებები." + "შეტყობინების დაშიფვრა ჩართულია" + "შეტყობინებების პარამეტრების ჩატვირთვისას მოხდა შეცდომა." + "ამ ოთახის დადუმება ვერ მოხერხდა. გთხოვთ, სცადოთ ხელახლა." + "ამ ოთახის დადუმების მოხსნა ვერ მოხერხდა. გთხოვთ, სცადოთ ხელახლა." + "ხალხის მოწვევა" + "ოთახის დატოვება" + "მორგებული" + "ნაგულისხმევი" + "შეტყობინებები" + "ოთახის სახელი" + "უსაფრთხოება" + "ოთახის გაზიარება" + "თემა" + "ოთახის განახლება…" + + "%1$d ადამიანი" + "%1$d ადამიანი" + + "მომლოდინე" + "ოთახის წევრები" + "მორგებული პარამეტრის დაშვება" + "ამის ჩართვა უგულებელყოფს თქვენს ნაგულისხმევ პარამეტრს" + "ამ ჩატში ჩემი შეტყობინება:" + "თქვენ შეგიძლიათ შეცვალოთ იგი თქვენს %1$s ." + "გლობალური პარამეტრები" + "Სტანდარტული პარამეტრები" + "მორგებული პარამეტრის წაშლა" + "შეტყობინებების პარამეტრების ჩატვირთვისას მოხდა შეცდომა." + "ნაგულისხმევი რეჟიმის აღდგენა ვერ მოხერხდა, გთხოვთ, სცადოთ ხელახლა." + "რეჟიმის დაყენება ვერ მოხერხდა, გთხოვთ, სცადოთ ხელახლა." + "ყველა შეტყობინება" + "მხოლოდ ხსენებები და საკვანძო სიტყვები" + "ამ ოთახში, შემატყობინეთ:" + 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 new file mode 100644 index 0000000000..dedc033298 --- /dev/null +++ b/features/roomdetails/impl/src/main/res/values-pt-rBR/translations.xml @@ -0,0 +1,116 @@ + + + "Erro ao atualizar a configuração de notificação." + "O teu servidor não suporta esta opção em salas cifradas, pelo que poderás não ser notificado em algumas salas." + "Sondagens" + "Apenas administradores" + "Banir pessoas" + "Remover mensagens" + "Toda a gente" + "Convida pessoas" + "Moderação de participantes" + "Mensagens e conteúdo" + "Administradores e moderadores" + "Remover pessoas" + "Alterar o ícone da sala" + "Detalhes da sala" + "Altera o nome da sala" + "Alterar a descrição da sala" + "Enviar mensagens" + "Editar administradores" + "Não poderás desfazer esta ação. Estás a promover o utilizador para ter o mesmo nível de poder que tu." + "Adicionar administrador?" + "Despromover" + "Não poderás desfazer esta alteração, uma vez que te estás a despromover. Se fores o último utilizador privilegiado na sala, será impossível recuperar os privilégios." + "Despromover-te?" + "%1$s (pendente)" + "(pendente)" + "Os administradores têm automaticamente privilégios de moderador" + "Editar moderadores" + "Administradores" + "Moderadores" + "Participantes" + "Tens alterações por guardar." + "Guardar alterações?" + "Adicionar descrição" + "Já é participante" + "Já foi convidado" + "Cifrada" + "Não cifrada" + "Sala pública" + "Editar sala" + "Ocorreu um erro desconhecido e não foi possível alterar a informação." + "Não foi possível atualizar a sala" + "As mensagens são protegidas por cadeados. Apenas tu e os destinatários têm as chaves únicas para os desbloquear." + "Cifragem de mensagens ativada" + "Erro ao carregar as configurações de notificação." + "Não foi possível silenciar esta sala, por favor tenta novamente." + "Não foi possível dessilenciar esta sala, por favor tenta novamente." + "Convidar pessoas" + "Sair da conversa" + "Sair da sala" + "Personalizado" + "Predefinição" + "Notificações" + "Cargos e permissões" + "Nome da sala" + "Segurança" + "Partilhar sala" + "Informação da sala" + "Descrição" + "A atualizar sala…" + "Banir" + "Não poderão voltar a entrar nesta sala, mesmo se forem convidados." + "Tens a certeza que queres banir este participante?" + "Não há nenhum utilizador banido desta sala." + "A banir %1$s" + + "%1$d pessoa" + "%1$d pessoas" + + "Remover e banir participante" + "Remover da sala" + "Remover e banir" + "Remover apenas" + "Remover participante e proibir de se juntar no futuro?" + "Anular banimento" + "Poderão juntar-se novamente a esta sala se forem convidados." + "Anular banimento do utilizador" + "Ver perfil" + "Banidos" + "Participantes" + "Pendente" + "A remover %1$s…" + "Administrador" + "Moderador" + "Participantes" + "A anular banimento de %1$s" + "Permitir configuração personalizada" + "Ativar esta opção substitui a tua configuração predefinida" + "Nesta conversa, notifica-me se" + "Podes alterá-lo nas tuas %1$s." + "configurações globais" + "Predefinição" + "Remover configuração personalizada" + "Erro ao carregar as configurações de notificação." + "Falha ao restaurar o modo predefinido, tenta novamente." + "Falha ao definir o modo, tenta novamente." + "O teu servidor não suporta esta opção em salas cifradas, pelo que não serás notificado nesta sala." + "Todas as mensagens" + "Menções ou palavras-chave" + "Nesta sala, notifica-me se" + "Administradores" + "Alterar o meu cargo" + "Despromover para participante" + "Despromover para moderador" + "Moderação de participantes" + "Mensagens e conteúdo" + "Moderadores" + "Permissões" + "Repor permissões" + "Ao repores as permissões, perderás as configurações atuais." + "Repor as permissões?" + "Cargos" + "Detalhes da sala" + "Cargos e permissões" + 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 ef8edf43be..05cd542f3b 100644 --- a/features/roomdetails/impl/src/main/res/values-ro/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-ro/translations.xml @@ -3,11 +3,41 @@ "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" + "Interziceți persoane" + "Eliminați mesaje" "Toți" + "Invitați persoane" + "Moderarea membrilor" + "Mesaje și conținut" + "Administratori și moderatori" + "Îndepărtați persoane" + "Schimbați avatarul camerei" + "Detaliile camerei" + "Schimbă numele camerei" + "Schimbați subiectul camerei" + "Trimiteți mesaje" + "Editați administratorii" + "Promovați utilizatorul să aibă același nivel de putere ca dumneavoastră. Nu veți putea anula această acțiune." + "Adăugați administrator?" + "Retrogradare" + "Nu veți putea anula această modificare, deoarece vă retrogradați. Dacă sunteți ultimul utilizator privilegiat din cameră, va fi imposibil să recâștigați privilegiile." + "Vreți să vă retrogradați?" + "%1$s (În așteptare)" + "(În așteptare)" + "Administratorii au automat privilegii de moderator" + "Editați moderatorii" + "Administratori" + "Moderatori" "Membri" + "Aveți modificări nesalvate." + "Salvați modificările?" "Adăugare subiect" "Deja membru" "Deja invitat" + "Criptat" + "Necriptat" + "Cameră publică" "Editați camera" "A apărut o eroare la actualizarea detaliilor camerei" "Nu s-a putut actualiza camera" @@ -22,27 +52,39 @@ "Personalizat" "Implicit" "Notificări" + "Roluri și permisiuni" "Numele camerei" "Securitate" "Partajați camera" + "Informatii camera" "Subiect" "Se actualizează camera…" + "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 există utilizatori interziși în această cameră." + "Se interzice %1$s" "o persoană" "%1$d persoane" + "Eliminați și interziceți membrul" "Înlăturați membrul" "Înlăturați și interziceți membrul" "Doar înlăturare" "Înlăturați membrul și interziceți-i să se alăture în viitor?" "Anulare excludere" - "Vedeți informații despre utilizator" + "Se vor putea alătura din nou acestei săli dacă sunt invitați." + "Anulați interzicerea utilizatorului" + "Vizualizare profil" "Excluși" "Membri" "În așteptare" + "Se elimină %1$s" "Administrator" "Moderator" "Membrii camerei" + "Se anulează interzicerea lui %1$s" "Permiteți setări personalizate" "Activarea acestei opțiuni va anula setările implicite." "Anunțați-mă în acestă cameră pentru" @@ -57,4 +99,18 @@ "Toate mesajele" "Numai mențiuni și cuvinte cheie" "În această cameră, anunțați-mă pentru" + "Administratori" + "Schimbare rol" + "Degradare la membru" + "Degradare la moderator" + "Moderarea membrilor" + "Mesaje și conținut" + "Moderatori" + "Permisiuni" + "Resetați permisiunile" + "După ce resetați permisiunile, veți pierde setările curente." + "Resetați permisiunile?" + "Roluri" + "Detaliile camerei" + "Roluri și permisiuni" 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 1b0bc8d193..d439cff033 100644 --- a/features/roomdetails/impl/src/main/res/values-ru/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-ru/translations.xml @@ -35,6 +35,9 @@ "Добавить тему" "Уже зарегистрирован" "Уже приглашены" + "Зашифровано" + "Не зашифровано" + "Общественная комната" "Редактировать комнату" "Произошла неизвестная ошибка и информацию не удалось изменить." "Не удалось обновить комнату" 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 467ba5578a..c905333c2c 100644 --- a/features/roomdetails/impl/src/main/res/values-sk/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-sk/translations.xml @@ -35,6 +35,9 @@ "Pridať tému" "Už ste členom" "Už ste pozvaní" + "Zašifrované" + "Nešifrované" + "Verejná miestnosť" "Upraviť miestnosť" "Vyskytla sa neznáma chyba a informácie nebolo možné zmeniť." "Nepodarilo sa aktualizovať miestnosť" 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 b8161c4108..416a22c81b 100644 --- a/features/roomdetails/impl/src/main/res/values-sv/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-sv/translations.xml @@ -4,6 +4,16 @@ "Din hemserver stöder inte det här alternativet i krypterade rum, du kanske inte aviseras i vissa rum." "Omröstningar" "Alla" + "Meddelanden och innehåll" + "Rumsdetaljer" + "Du kommer inte att kunna ångra den här åtgärden. Du befordrar användaren till att ha samma behörighetsnivå som du." + "Lägg till Admin?" + "Degradera" + "Du kommer inte att kunna ångra denna ändring eftersom du degraderar dig själv, om du är den sista privilegierade användaren i rummet kommer det att vara omöjligt att återfå privilegier." + "Degradera dig själv?" + "Administratörer" + "Moderatorer" + "Medlemmar" "Lägg till ämne" "Redan medlem" "Redan inbjuden" @@ -21,17 +31,33 @@ "Anpassad" "Förval" "Aviseringar" + "Roller och behörigheter" "Rumsnamn" "Säkerhet" "Dela rum" "Ämne" "Uppdaterar rummet …" + "Bannar %1$s" "%1$d person" "%1$d personer" + "Ta bort från rummet" + "Ta bort och banna medlem" + "Ta bara bort medlem" + "Ta bort medlem och banna från att gå med i framtiden?" + "Avbanna" + "Denne kommer kunna gå med i rummet igen om denne bjuds in" + "Avbanna användare" + "Visa profil" + "Bannade" + "Medlemmar" "Väntar" + "Tar bort %1$s …" + "Admin" + "Moderator" "Rumsmedlemmar" + "Avbannar %1$s" "Tillåt anpassad inställning" "Om du aktiverar detta åsidosätts din standardinställning" "Meddela mig i den här chatten för" @@ -46,4 +72,11 @@ "Alla meddelanden" "Endast omnämnanden och nyckelord" "I det här rummet, meddela mig för" + "Administratörer" + "Meddelanden och innehåll" + "Moderatorer" + "Behörigheter" + "Roller" + "Rumsdetaljer" + "Roller och behörigheter" 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 e61a7052a3..b06ad30097 100644 --- a/features/roomdetails/impl/src/main/res/values-uk/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-uk/translations.xml @@ -12,10 +12,10 @@ "Повідомлення та зміст" "Адміністратори та модератори" "Вилучати людей" - "Змінити аватар кімнати" + "Змінювати аватар кімнати" "Деталі кімнати" - "Змінити назву кімнати" - "Змінити тему кімнати" + "Змінювати назву кімнати" + "Змінювати тему кімнати" "Надсилати повідомлення" "Керувати адмінами" "Ви не зможете скасувати цю дію. Ви просуваєте користувача, щоб він мав такий же рівень прав, як і ви." @@ -74,7 +74,7 @@ "Заблоковані" "Учасники" "На розгляді" - "Видаляємо %1$s…" + "Вилучаємо %1$s…" "Адміністратор" "Модератор" "Учасники кімнати" 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 11769b3176..e2101473cf 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 @@ -2,10 +2,27 @@ "更新通知設定時發生錯誤。" "所有投票" + "僅限管理員" + "移除訊息" "所有人" + "成員管理" + "訊息與內容" + "管理員和版主" + "聊天室資訊" + "傳送訊息" + "編輯管理員" + "編輯版主" + "管理員" + "版主" + "成員" + "您有尚未儲存的變更" + "是否儲存變更?" "新增主題" "已是成員" "已邀請" + "已加密" + "未加密" + "公開的聊天室" "編輯聊天室" "無法更新聊天室" "訊息已加密" @@ -18,15 +35,22 @@ "自訂" "預設" "通知" + "身份與權限" "聊天室名稱" "安全性" "分享聊天室" "主題" "正在更新聊天室…" + "此聊天室沒有黑名單。" "%1$d 位夥伴" + "查看個人檔案" + "黑名單" + "成員" "待定" + "管理員" + "版主" "聊天室成員" "全域設定" "預設" @@ -34,4 +58,18 @@ "無法設定模式,請再試一次。" "所有訊息" "僅限提及與關鍵字" + "管理員" + "變更我的身份" + "降級為普通成員" + "降級為版主" + "成員管理" + "訊息與內容" + "版主" + "權限" + "重設權限" + "重設之後,您會遺失當前的設定。" + "確定要重設權限嗎?" + "身份" + "聊天室資訊" + "身份與權限" diff --git a/features/roomdetails/impl/src/main/res/values-zh/translations.xml b/features/roomdetails/impl/src/main/res/values-zh/translations.xml new file mode 100644 index 0000000000..b4314fa71a --- /dev/null +++ b/features/roomdetails/impl/src/main/res/values-zh/translations.xml @@ -0,0 +1,112 @@ + + + "更新通知设置时出错。" + "您的服务器在加密房间中不支持此选项,因此在某些房间您可能无法收到通知。" + "投票" + "仅限管理员" + "封禁成员" + "移除消息" + "所有人" + "邀请成员" + "成员权限" + "消息和内容" + "管理员和协管员" + "移除成员" + "更改聊天室头像" + "聊天室详情" + "更改聊天室名称" + "更改聊天室话题" + "发送消息" + "编辑管理员" + "您将无法撤消此操作。您正在提升用户的权限,使其拥有与您平权。" + "添加管理员?" + "降级" + "由于您正在降级,您将无法撤消此更改。如果您是房间中的最后一个特权用户,则无法重新获得权限。" + "降级自己?" + "%1$s(待处理)" + "(已邀请)" + "管理员自动拥有协管员权限" + "编辑协管员" + "管理员" + "协管员" + "成员" + "您有未保存的更改。" + "保存更改?" + "添加主题" + "已经是成员" + "已邀请" + "编辑聊天室" + "出现未知错误,无法更改信息。" + "无法更新聊天室" + "你的消息受加密保护,并且只有你和消息接收者拥有唯一解密密钥。" + "消息加密已启用" + "加载通知设置时出错。" + "无法将此房间静音,请重试。" + "无法取消此房间的静音,请重试。" + "邀请朋友" + "离开聊天" + "离开房间" + "自定义" + "默认" + "通知" + "角色与权限" + "房间名称" + "安全" + "分享房间" + "聊天室信息" + "话题" + "正在更新房间……" + "封禁" + "即使受到邀请,他们也无法再次加入房间。" + "您确定要封禁该成员吗?" + "这个房间里没有被封禁的用户。" + "封禁 %1$s" + + "%1$d 人" + + "移除并封禁成员" + "从房间移除" + "移除并封禁成员" + "仅移除成员" + "删除成员并禁止重新加入?" + "取消封禁" + "如果受到邀请,他们可以重新加入房间。" + "解封用户" + "查看个人资料" + "已封禁用户" + "成员" + "待处理" + "正在移除 %1$s……" + "管理员" + "协管员" + "聊天室成员" + "解除封禁 %1$s" + "允许自定义设置" + "开启此功能将覆盖您的默认设置" + "在此聊天中通知我以下内容" + "你可以在你的 %1$s 中更改这一项。" + "全局设置" + "默认设置" + "撤销独立设置" + "加载通知设置时出错。" + "恢复默认模式失败,请重试。" + "设置模式失败,请重试。" + "您的服务器在加密房间中不支持此选项,您无法在此房间收到通知。" + "所有消息" + "仅限提及和关键词" + "在这个聊天室,通知我:" + "管理员" + "更改我的角色" + "降级为成员" + "降级为协管员" + "成员权限" + "消息和内容" + "协管员" + "权限" + "重置权限" + "重置权限后,您将丢失当前设置。" + "重置权限?" + "角色" + "聊天室详情" + "角色与权限" + diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/MatrixRoomFixture.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/MatrixRoomFixture.kt new file mode 100644 index 0000000000..cffd2ae1f1 --- /dev/null +++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/MatrixRoomFixture.kt @@ -0,0 +1,59 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.roomdetails + +import io.element.android.libraries.matrix.api.core.RoomId +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.notificationsettings.FakeNotificationSettingsService +import io.element.android.libraries.matrix.test.room.FakeMatrixRoom +import io.element.android.libraries.matrix.test.room.aRoomInfo + +fun aMatrixRoom( + roomId: RoomId = A_ROOM_ID, + displayName: String = A_ROOM_NAME, + rawName: String? = displayName, + topic: String? = "A topic", + avatarUrl: String? = "https://matrix.org/avatar.jpg", + isEncrypted: Boolean = true, + isPublic: Boolean = true, + isDirect: Boolean = false, + notificationSettingsService: FakeNotificationSettingsService = FakeNotificationSettingsService(), + emitRoomInfo: Boolean = false, +) = FakeMatrixRoom( + roomId = roomId, + displayName = displayName, + topic = topic, + avatarUrl = avatarUrl, + isEncrypted = isEncrypted, + isPublic = isPublic, + isDirect = isDirect, + notificationSettingsService = notificationSettingsService +).apply { + if (emitRoomInfo) { + givenRoomInfo( + aRoomInfo( + name = displayName, + rawName = rawName, + topic = topic, + avatarUrl = avatarUrl, + isDirect = isDirect, + isPublic = isPublic, + ) + ) + } +} diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/RoomDetailsPresenterTests.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/RoomDetailsPresenterTests.kt index 998193690e..857422ca51 100644 --- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/RoomDetailsPresenterTests.kt +++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/RoomDetailsPresenterTests.kt @@ -37,14 +37,11 @@ import io.element.android.features.roomdetails.impl.members.details.RoomMemberDe import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.featureflag.test.FakeFeatureFlagService -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.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState import io.element.android.libraries.matrix.api.room.RoomNotificationMode import io.element.android.libraries.matrix.api.room.StateEventType -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_USER_ID_2 import io.element.android.libraries.matrix.test.FakeMatrixClient @@ -473,23 +470,3 @@ class RoomDetailsPresenterTests { } } } - -fun aMatrixRoom( - roomId: RoomId = A_ROOM_ID, - displayName: String = A_ROOM_NAME, - topic: String? = "A topic", - avatarUrl: String? = "https://matrix.org/avatar.jpg", - isEncrypted: Boolean = true, - isPublic: Boolean = true, - isDirect: Boolean = false, - notificationSettingsService: FakeNotificationSettingsService = FakeNotificationSettingsService() -) = FakeMatrixRoom( - roomId = roomId, - displayName = displayName, - topic = topic, - avatarUrl = avatarUrl, - isEncrypted = isEncrypted, - isPublic = isPublic, - isDirect = isDirect, - notificationSettingsService = notificationSettingsService -) diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/edit/RoomDetailsEditPresenterTest.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/edit/RoomDetailsEditPresenterTest.kt index 8b0dcd5b0a..f287fe4ab4 100644 --- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/edit/RoomDetailsEditPresenterTest.kt +++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/edit/RoomDetailsEditPresenterTest.kt @@ -19,6 +19,7 @@ package io.element.android.features.roomdetails.edit import android.net.Uri 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 import io.element.android.features.roomdetails.aMatrixRoom @@ -28,6 +29,8 @@ import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.matrix.api.room.MatrixRoom 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.ui.media.AvatarAction import io.element.android.libraries.mediapickers.test.FakePickerProvider import io.element.android.libraries.mediaupload.api.MediaUploadInfo @@ -90,15 +93,19 @@ class RoomDetailsEditPresenterTest { @Test fun `present - initial state is created from room info`() = runTest { - val room = aMatrixRoom(avatarUrl = AN_AVATAR_URL) + val room = aMatrixRoom( + avatarUrl = AN_AVATAR_URL, + displayName = A_ROOM_NAME, + rawName = A_ROOM_RAW_NAME, + emitRoomInfo = true, + ) val presenter = createRoomDetailsEditPresenter(room) - moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { - val initialState = awaitItem() - assertThat(initialState.roomId).isEqualTo(room.roomId.value) - assertThat(initialState.roomName).isEqualTo(room.displayName) + val initialState = awaitFirstItem() + assertThat(initialState.roomId).isEqualTo(room.roomId) + assertThat(initialState.roomRawName).isEqualTo(A_ROOM_RAW_NAME) assertThat(initialState.roomAvatarUrl).isEqualTo(roomAvatarUri) assertThat(initialState.roomTopic).isEqualTo(room.topic.orEmpty()) assertThat(initialState.avatarActions).containsExactly( @@ -119,7 +126,6 @@ class RoomDetailsEditPresenterTest { givenCanSendStateResult(StateEventType.ROOM_TOPIC, Result.failure(Throwable("Oops"))) } val presenter = createRoomDetailsEditPresenter(room) - moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { @@ -128,7 +134,6 @@ class RoomDetailsEditPresenterTest { assertThat(initialState.canChangeName).isFalse() assertThat(initialState.canChangeAvatar).isFalse() assertThat(initialState.canChangeTopic).isFalse() - // When the asynchronous check completes, the single field we can edit is true val settledState = awaitItem() assertThat(settledState.canChangeName).isTrue() @@ -145,7 +150,6 @@ class RoomDetailsEditPresenterTest { givenCanSendStateResult(StateEventType.ROOM_TOPIC, Result.failure(Throwable("Oops"))) } val presenter = createRoomDetailsEditPresenter(room) - moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { @@ -154,7 +158,6 @@ class RoomDetailsEditPresenterTest { assertThat(initialState.canChangeName).isFalse() assertThat(initialState.canChangeAvatar).isFalse() assertThat(initialState.canChangeTopic).isFalse() - // When the asynchronous check completes, the single field we can edit is true val settledState = awaitItem() assertThat(settledState.canChangeName).isFalse() @@ -171,7 +174,6 @@ class RoomDetailsEditPresenterTest { givenCanSendStateResult(StateEventType.ROOM_TOPIC, Result.success(true)) } val presenter = createRoomDetailsEditPresenter(room) - moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { @@ -180,7 +182,6 @@ class RoomDetailsEditPresenterTest { assertThat(initialState.canChangeName).isFalse() assertThat(initialState.canChangeAvatar).isFalse() assertThat(initialState.canChangeTopic).isFalse() - // When the asynchronous check completes, the single field we can edit is true val settledState = awaitItem() assertThat(settledState.canChangeName).isFalse() @@ -191,42 +192,42 @@ class RoomDetailsEditPresenterTest { @Test fun `present - updates state in response to changes`() = runTest { - val room = aMatrixRoom(topic = "My topic", displayName = "Name", avatarUrl = AN_AVATAR_URL) + val room = aMatrixRoom( + topic = "My topic", + displayName = "Name", + avatarUrl = AN_AVATAR_URL, + emitRoomInfo = true, + ) val presenter = createRoomDetailsEditPresenter(room) - moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { - val initialState = awaitItem() + val initialState = awaitFirstItem() assertThat(initialState.roomTopic).isEqualTo("My topic") - assertThat(initialState.roomName).isEqualTo("Name") + assertThat(initialState.roomRawName).isEqualTo("Name") assertThat(initialState.roomAvatarUrl).isEqualTo(roomAvatarUri) - initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName("Name II")) awaitItem().apply { assertThat(roomTopic).isEqualTo("My topic") - assertThat(roomName).isEqualTo("Name II") + assertThat(roomRawName).isEqualTo("Name II") assertThat(roomAvatarUrl).isEqualTo(roomAvatarUri) } - initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName("Name III")) awaitItem().apply { assertThat(roomTopic).isEqualTo("My topic") - assertThat(roomName).isEqualTo("Name III") + assertThat(roomRawName).isEqualTo("Name III") assertThat(roomAvatarUrl).isEqualTo(roomAvatarUri) } - initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic("Another topic")) awaitItem().apply { assertThat(roomTopic).isEqualTo("Another topic") - assertThat(roomName).isEqualTo("Name III") + assertThat(roomRawName).isEqualTo("Name III") assertThat(roomAvatarUrl).isEqualTo(roomAvatarUri) } - initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.Remove)) awaitItem().apply { assertThat(roomTopic).isEqualTo("Another topic") - assertThat(roomName).isEqualTo("Name III") + assertThat(roomRawName).isEqualTo("Name III") assertThat(roomAvatarUrl).isNull() } } @@ -234,18 +235,19 @@ class RoomDetailsEditPresenterTest { @Test fun `present - obtains avatar uris from gallery`() = runTest { - val room = aMatrixRoom(topic = "My topic", displayName = "Name", avatarUrl = AN_AVATAR_URL) - + val room = aMatrixRoom( + topic = "My topic", + displayName = "Name", + avatarUrl = AN_AVATAR_URL, + emitRoomInfo = true, + ) fakePickerProvider.givenResult(anotherAvatarUri) - val presenter = createRoomDetailsEditPresenter(room) - moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { - val initialState = awaitItem() + val initialState = awaitFirstItem() assertThat(initialState.roomAvatarUrl).isEqualTo(roomAvatarUri) - initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.ChoosePhoto)) awaitItem().apply { assertThat(roomAvatarUrl).isEqualTo(anotherAvatarUri) @@ -255,19 +257,22 @@ class RoomDetailsEditPresenterTest { @Test fun `present - obtains avatar uris from camera`() = runTest { - val room = aMatrixRoom(topic = "My topic", displayName = "Name", avatarUrl = AN_AVATAR_URL) - + val room = aMatrixRoom( + topic = "My topic", + displayName = "Name", + avatarUrl = AN_AVATAR_URL, + emitRoomInfo = true, + ) fakePickerProvider.givenResult(anotherAvatarUri) val fakePermissionsPresenter = FakePermissionsPresenter() val presenter = createRoomDetailsEditPresenter( room = room, permissionsPresenter = fakePermissionsPresenter, ) - moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { - val initialState = awaitItem() + val initialState = awaitFirstItem() assertThat(initialState.roomAvatarUrl).isEqualTo(roomAvatarUri) assertThat(initialState.cameraPermissionState.permissionGranted).isFalse() initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.TakePhoto)) @@ -288,48 +293,44 @@ class RoomDetailsEditPresenterTest { @Test fun `present - updates save button state`() = runTest { - val room = aMatrixRoom(topic = "My topic", displayName = "Name", avatarUrl = AN_AVATAR_URL) - + val room = aMatrixRoom( + topic = "My topic", + displayName = "Name", + avatarUrl = AN_AVATAR_URL, + emitRoomInfo = true, + ) fakePickerProvider.givenResult(roomAvatarUri) - val presenter = createRoomDetailsEditPresenter(room) - moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { - val initialState = awaitItem() + val initialState = awaitFirstItem() assertThat(initialState.saveButtonEnabled).isFalse() - // Once a change is made, the save button is enabled initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName("Name II")) awaitItem().apply { assertThat(saveButtonEnabled).isTrue() } - // If it's reverted then the save disables again initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName("Name")) awaitItem().apply { assertThat(saveButtonEnabled).isFalse() } - // Make a change... initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic("Another topic")) awaitItem().apply { assertThat(saveButtonEnabled).isTrue() } - // Revert it... initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic("My topic")) awaitItem().apply { assertThat(saveButtonEnabled).isFalse() } - // Make a change... initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.Remove)) awaitItem().apply { assertThat(saveButtonEnabled).isTrue() } - // Revert it... initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.ChoosePhoto)) awaitItem().apply { @@ -340,48 +341,44 @@ class RoomDetailsEditPresenterTest { @Test fun `present - updates save button state when initial values are null`() = runTest { - val room = aMatrixRoom(topic = null, displayName = "fallback", avatarUrl = null) - + val room = aMatrixRoom( + topic = null, + displayName = "fallback", + avatarUrl = null, + emitRoomInfo = true, + ) fakePickerProvider.givenResult(roomAvatarUri) - val presenter = createRoomDetailsEditPresenter(room) - moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { - val initialState = awaitItem() + val initialState = awaitFirstItem() assertThat(initialState.saveButtonEnabled).isFalse() - // Once a change is made, the save button is enabled initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName("Name II")) awaitItem().apply { assertThat(saveButtonEnabled).isTrue() } - // If it's reverted then the save disables again initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName("fallback")) awaitItem().apply { assertThat(saveButtonEnabled).isFalse() } - // Make a change... initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic("Another topic")) awaitItem().apply { assertThat(saveButtonEnabled).isTrue() } - // Revert it... initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic("")) awaitItem().apply { assertThat(saveButtonEnabled).isFalse() } - // Make a change... initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.ChoosePhoto)) awaitItem().apply { assertThat(saveButtonEnabled).isTrue() } - // Revert it... initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.Remove)) awaitItem().apply { @@ -392,15 +389,17 @@ class RoomDetailsEditPresenterTest { @Test fun `present - save changes room details if different`() = runTest { - val room = aMatrixRoom(topic = "My topic", displayName = "Name", avatarUrl = AN_AVATAR_URL) - + val room = aMatrixRoom( + topic = "My topic", + displayName = "Name", + avatarUrl = AN_AVATAR_URL, + emitRoomInfo = true, + ) val presenter = createRoomDetailsEditPresenter(room) - moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { - val initialState = awaitItem() - + val initialState = awaitFirstItem() initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName("New name")) initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic("New topic")) initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.Remove)) @@ -410,31 +409,24 @@ class RoomDetailsEditPresenterTest { assertThat(room.newTopic).isEqualTo("New topic") assertThat(room.newAvatarData).isNull() assertThat(room.removedAvatar).isTrue() - - cancelAndIgnoreRemainingEvents() } } @Test fun `present - save doesn't change room details if they're the same trimmed`() = runTest { val room = aMatrixRoom(topic = "My topic", displayName = "Name", avatarUrl = AN_AVATAR_URL) - val presenter = createRoomDetailsEditPresenter(room) - moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { val initialState = awaitItem() - initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName(" Name ")) initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic(" My topic ")) initialState.eventSink(RoomDetailsEditEvents.Save) - assertThat(room.newName).isNull() assertThat(room.newTopic).isNull() assertThat(room.newAvatarData).isNull() assertThat(room.removedAvatar).isFalse() - cancelAndIgnoreRemainingEvents() } } @@ -442,22 +434,17 @@ class RoomDetailsEditPresenterTest { @Test fun `present - save doesn't change topic if it was unset and is now blank`() = runTest { val room = aMatrixRoom(topic = null, displayName = "Name", avatarUrl = AN_AVATAR_URL) - val presenter = createRoomDetailsEditPresenter(room) - moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { val initialState = awaitItem() - initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic("")) initialState.eventSink(RoomDetailsEditEvents.Save) - assertThat(room.newName).isNull() assertThat(room.newTopic).isNull() assertThat(room.newAvatarData).isNull() assertThat(room.removedAvatar).isFalse() - cancelAndIgnoreRemainingEvents() } } @@ -465,22 +452,17 @@ class RoomDetailsEditPresenterTest { @Test fun `present - save doesn't change name if it's now empty`() = runTest { val room = aMatrixRoom(topic = "My topic", displayName = "Name", avatarUrl = AN_AVATAR_URL) - val presenter = createRoomDetailsEditPresenter(room) - moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { val initialState = awaitItem() - initialState.eventSink(RoomDetailsEditEvents.UpdateRoomName("")) initialState.eventSink(RoomDetailsEditEvents.Save) - assertThat(room.newName).isNull() assertThat(room.newTopic).isNull() assertThat(room.newAvatarData).isNull() assertThat(room.removedAvatar).isFalse() - cancelAndIgnoreRemainingEvents() } } @@ -488,20 +470,15 @@ class RoomDetailsEditPresenterTest { @Test fun `present - save processes and sets avatar when processor returns successfully`() = runTest { val room = aMatrixRoom(topic = "My topic", displayName = "Name", avatarUrl = AN_AVATAR_URL) - givenPickerReturnsFile() - val presenter = createRoomDetailsEditPresenter(room) - moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { val initialState = awaitItem() - initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.ChoosePhoto)) initialState.eventSink(RoomDetailsEditEvents.Save) skipItems(3) - assertThat(room.newName).isNull() assertThat(room.newTopic).isNull() assertThat(room.newAvatarData).isSameInstanceAs(fakeFileContents) @@ -512,89 +489,92 @@ class RoomDetailsEditPresenterTest { @Test fun `present - save does not set avatar data if processor fails`() = runTest { val room = aMatrixRoom(topic = "My topic", displayName = "Name", avatarUrl = AN_AVATAR_URL) - fakePickerProvider.givenResult(anotherAvatarUri) fakeMediaPreProcessor.givenResult(Result.failure(Throwable("Oh no"))) - val presenter = createRoomDetailsEditPresenter(room) - moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { val initialState = awaitItem() - initialState.eventSink(RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.ChoosePhoto)) initialState.eventSink(RoomDetailsEditEvents.Save) skipItems(2) - assertThat(room.newName).isNull() assertThat(room.newTopic).isNull() assertThat(room.newAvatarData).isNull() assertThat(room.removedAvatar).isFalse() - assertThat(awaitItem().saveAction).isInstanceOf(AsyncAction.Failure::class.java) } } @Test fun `present - sets save action to failure if name update fails`() = runTest { - val room = aMatrixRoom(topic = "My topic", displayName = "Name", avatarUrl = AN_AVATAR_URL).apply { + val room = aMatrixRoom( + topic = "My topic", + displayName = "Name", + avatarUrl = AN_AVATAR_URL, + emitRoomInfo = true, + ).apply { givenSetNameResult(Result.failure(Throwable("!"))) } - saveAndAssertFailure(room, RoomDetailsEditEvents.UpdateRoomName("New name")) } @Test fun `present - sets save action to failure if topic update fails`() = runTest { - val room = aMatrixRoom(topic = "My topic", displayName = "Name", avatarUrl = AN_AVATAR_URL).apply { + val room = aMatrixRoom( + topic = "My topic", + displayName = "Name", + avatarUrl = AN_AVATAR_URL, + emitRoomInfo = true, + ).apply { givenSetTopicResult(Result.failure(Throwable("!"))) } - saveAndAssertFailure(room, RoomDetailsEditEvents.UpdateRoomTopic("New topic")) } @Test fun `present - sets save action to failure if removing avatar fails`() = runTest { - val room = aMatrixRoom(topic = "My topic", displayName = "Name", avatarUrl = AN_AVATAR_URL).apply { + val room = aMatrixRoom( + topic = "My topic", + displayName = "Name", + avatarUrl = AN_AVATAR_URL, + emitRoomInfo = true, + ).apply { givenRemoveAvatarResult(Result.failure(Throwable("!"))) } - saveAndAssertFailure(room, RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.Remove)) } @Test fun `present - sets save action to failure if setting avatar fails`() = runTest { givenPickerReturnsFile() - - val room = aMatrixRoom(topic = "My topic", displayName = "Name", avatarUrl = AN_AVATAR_URL).apply { + val room = aMatrixRoom( + topic = "My topic", + displayName = "Name", + avatarUrl = AN_AVATAR_URL, + emitRoomInfo = true, + ).apply { givenUpdateAvatarResult(Result.failure(Throwable("!"))) } - saveAndAssertFailure(room, RoomDetailsEditEvents.HandleAvatarAction(AvatarAction.ChoosePhoto)) } @Test fun `present - CancelSaveChanges resets save action state`() = runTest { givenPickerReturnsFile() - val room = aMatrixRoom(topic = "My topic", displayName = "Name", avatarUrl = AN_AVATAR_URL).apply { givenSetTopicResult(Result.failure(Throwable("!"))) } - val presenter = createRoomDetailsEditPresenter(room) - moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { val initialState = awaitItem() - initialState.eventSink(RoomDetailsEditEvents.UpdateRoomTopic("foo")) initialState.eventSink(RoomDetailsEditEvents.Save) skipItems(2) - assertThat(awaitItem().saveAction).isInstanceOf(AsyncAction.Failure::class.java) - initialState.eventSink(RoomDetailsEditEvents.CancelSaveChanges) assertThat(awaitItem().saveAction).isInstanceOf(AsyncAction.Uninitialized::class.java) } @@ -602,16 +582,13 @@ class RoomDetailsEditPresenterTest { private suspend fun saveAndAssertFailure(room: MatrixRoom, event: RoomDetailsEditEvents) { val presenter = createRoomDetailsEditPresenter(room) - moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { - val initialState = awaitItem() - + val initialState = awaitFirstItem() initialState.eventSink(event) initialState.eventSink(RoomDetailsEditEvents.Save) skipItems(1) - assertThat(awaitItem().saveAction).isInstanceOf(AsyncAction.Loading::class.java) assertThat(awaitItem().saveAction).isInstanceOf(AsyncAction.Failure::class.java) } @@ -622,7 +599,6 @@ class RoomDetailsEditPresenterTest { val processedFile: File = mockk { every { readBytes() } returns fakeFileContents } - fakePickerProvider.givenResult(anotherAvatarUri) fakeMediaPreProcessor.givenResult( Result.success( @@ -638,3 +614,8 @@ class RoomDetailsEditPresenterTest { private const val ANOTHER_AVATAR_URL = "example://camera/foo.jpg" } } + +private suspend fun ReceiveTurbine.awaitFirstItem(): T { + skipItems(2) + return awaitItem() +} diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/edit/RoomDetailsEditViewTest.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/edit/RoomDetailsEditViewTest.kt new file mode 100644 index 0000000000..13fefa144d --- /dev/null +++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/edit/RoomDetailsEditViewTest.kt @@ -0,0 +1,243 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.roomdetails.edit + +import androidx.activity.ComponentActivity +import androidx.annotation.StringRes +import androidx.compose.ui.test.assertHasClickAction +import androidx.compose.ui.test.assertHasNoClickAction +import androidx.compose.ui.test.hasTestTag +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.compose.ui.test.performTextInput +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.features.roomdetails.impl.edit.RoomDetailsEditEvents +import io.element.android.features.roomdetails.impl.edit.RoomDetailsEditState +import io.element.android.features.roomdetails.impl.edit.RoomDetailsEditView +import io.element.android.features.roomdetails.impl.edit.aRoomDetailsEditState +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.ui.media.AvatarAction +import io.element.android.libraries.testtags.TestTags +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 org.junit.Ignore +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class RoomDetailsEditViewTest { + @get:Rule val rule = createAndroidComposeRule() + + @Test + fun `clicking on back invoke back callback`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnce { callback -> + rule.setRoomDetailsEditView( + aRoomDetailsEditState( + eventSink = eventsRecorder + ), + onBackClick = callback, + ) + rule.pressBack() + } + } + + @Test + fun `when edition is successful, the expected callback is invoked`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnce { callback -> + rule.setRoomDetailsEditView( + aRoomDetailsEditState( + eventSink = eventsRecorder, + saveAction = AsyncAction.Success(Unit) + ), + onRoomEdited = callback, + ) + } + } + + @Test + fun `when name is changed, the expected Event is emitted`() { + val eventsRecorder = EventsRecorder() + rule.setRoomDetailsEditView( + aRoomDetailsEditState( + eventSink = eventsRecorder, + roomRawName = "Marketing", + ), + ) + rule.onNodeWithText("Marketing").assertHasClickAction() + rule.onNodeWithText("Marketing").performTextInput("A") + eventsRecorder.assertSingle(RoomDetailsEditEvents.UpdateRoomName("AMarketing")) + } + + @Test + fun `when user cannot change name, nothing happen`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + rule.setRoomDetailsEditView( + aRoomDetailsEditState( + eventSink = eventsRecorder, + roomRawName = "Marketing", + canChangeName = false, + ), + ) + rule.onNodeWithText("Marketing").assertHasNoClickAction() + } + + @Test + fun `when topic is changed, the expected Event is emitted`() { + val eventsRecorder = EventsRecorder() + rule.setRoomDetailsEditView( + aRoomDetailsEditState( + eventSink = eventsRecorder, + roomTopic = "My Topic", + ), + ) + rule.onNodeWithText("My Topic").assertHasClickAction() + rule.onNodeWithText("My Topic").performTextInput("A") + eventsRecorder.assertSingle(RoomDetailsEditEvents.UpdateRoomTopic("AMy Topic")) + } + + @Test + fun `when user cannot change topic, nothing happen`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + rule.setRoomDetailsEditView( + aRoomDetailsEditState( + eventSink = eventsRecorder, + roomTopic = "My Topic", + canChangeTopic = false, + ), + ) + rule.onNodeWithText("My Topic").assertHasNoClickAction() + } + + @Ignore("This test is failing because the bottom sheet does not open") + @Test + 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), + ) + } + + @Ignore("This test is failing because the bottom sheet does not open") + @Test + 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), + ) + } + + @Ignore("This test is failing because the bottom sheet does not open") + @Test + 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), + ) + } + + private fun testAvatarChange( + @StringRes stringActionRes: Int, + expectedEvent: RoomDetailsEditEvents.HandleAvatarAction, + ) { + val eventsRecorder = EventsRecorder() + rule.setRoomDetailsEditView( + aRoomDetailsEditState( + eventSink = eventsRecorder, + ), + ) + // Open the bottom sheet + rule.onNode(hasTestTag(TestTags.editAvatar.value)).performClick() + rule.onNodeWithText(rule.activity.getString(stringActionRes)).assertExists() + rule.clickOn(stringActionRes) + eventsRecorder.assertSingle(expectedEvent) + } + + @Test + fun `when user cannot change avatar, nothing happen`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + rule.setRoomDetailsEditView( + aRoomDetailsEditState( + eventSink = eventsRecorder, + canChangeAvatar = false, + ), + ) + rule.onNode(hasTestTag(TestTags.editAvatar.value)).performClick() + rule.onNodeWithText(rule.activity.getString(CommonStrings.action_take_photo)).assertDoesNotExist() + } + + @Test + fun `when save is clicked, the expected Event is emitted`() { + val eventsRecorder = EventsRecorder() + rule.setRoomDetailsEditView( + aRoomDetailsEditState( + eventSink = eventsRecorder, + saveButtonEnabled = true, + ), + ) + rule.clickOn(CommonStrings.action_save) + eventsRecorder.assertSingle(RoomDetailsEditEvents.Save) + } + + @Test + fun `when save is clicked, but nothing need to be saved, nothing happens`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + rule.setRoomDetailsEditView( + aRoomDetailsEditState( + eventSink = eventsRecorder, + saveButtonEnabled = false, + ), + ) + rule.clickOn(CommonStrings.action_save) + } + + @Test + fun `when error is shown, closing the dialog emit the expected Event`() { + val eventsRecorder = EventsRecorder() + rule.setRoomDetailsEditView( + aRoomDetailsEditState( + eventSink = eventsRecorder, + saveAction = AsyncAction.Failure(Throwable("Whelp")), + ), + ) + rule.clickOn(CommonStrings.action_ok) + eventsRecorder.assertSingle(RoomDetailsEditEvents.CancelSaveChanges) + } +} + +private fun AndroidComposeTestRule.setRoomDetailsEditView( + state: RoomDetailsEditState, + onBackClick: () -> Unit = EnsureNeverCalled(), + onRoomEdited: () -> Unit = EnsureNeverCalled(), +) { + setContent { + RoomDetailsEditView( + state = state, + onBackClick = onBackClick, + onRoomEditSuccess = onRoomEdited, + ) + } +} diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsViewTest.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsViewTest.kt index febd213e0c..62dbac449c 100644 --- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsViewTest.kt +++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsViewTest.kt @@ -120,7 +120,7 @@ class RoomDetailsViewTest { eventSink = EventsRecorder(expectEvents = false), canInvite = true, ), - onJoinCallClicked = callback, + onJoinCallClick = callback, ) rule.clickOn(CommonStrings.action_call) } @@ -134,7 +134,7 @@ class RoomDetailsViewTest { eventSink = EventsRecorder(expectEvents = false), roomTopic = RoomTopicState.CanAddTopic, ), - onActionClicked = callback, + onActionClick = callback, ) rule.clickOn(R.string.screen_room_details_add_topic_title) } @@ -148,7 +148,7 @@ class RoomDetailsViewTest { eventSink = EventsRecorder(expectEvents = false), canEdit = true, ), - onActionClicked = callback, + onActionClick = callback, ) val menuContentDescription = rule.activity.getString(CommonStrings.a11y_user_menu) rule.onNodeWithContentDescription(menuContentDescription).performClick() @@ -248,7 +248,7 @@ private fun AndroidComposeTestRule.setRoomD eventSink = EventsRecorder(expectEvents = false), ), goBack: () -> Unit = EnsureNeverCalled(), - onActionClicked: (RoomDetailsAction) -> Unit = EnsureNeverCalledWithParam(), + onActionClick: (RoomDetailsAction) -> Unit = EnsureNeverCalledWithParam(), onShareRoom: () -> Unit = EnsureNeverCalled(), openRoomMemberList: () -> Unit = EnsureNeverCalled(), openRoomNotificationSettings: () -> Unit = EnsureNeverCalled(), @@ -256,13 +256,13 @@ private fun AndroidComposeTestRule.setRoomD openAvatarPreview: (name: String, url: String) -> Unit = EnsureNeverCalledWithTwoParams(), openPollHistory: () -> Unit = EnsureNeverCalled(), openAdminSettings: () -> Unit = EnsureNeverCalled(), - onJoinCallClicked: () -> Unit = EnsureNeverCalled(), + onJoinCallClick: () -> Unit = EnsureNeverCalled(), ) { setContent { RoomDetailsView( state = state, goBack = goBack, - onActionClicked = onActionClicked, + onActionClick = onActionClick, onShareRoom = onShareRoom, openRoomMemberList = openRoomMemberList, openRoomNotificationSettings = openRoomNotificationSettings, @@ -270,7 +270,7 @@ private fun AndroidComposeTestRule.setRoomD openAvatarPreview = openAvatarPreview, openPollHistory = openPollHistory, openAdminSettings = openAdminSettings, - onJoinCallClicked = onJoinCallClicked, + onJoinCallClick = onJoinCallClick, ) } } diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/rolesandpermissions/RolesAndPermissionsViewTests.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/rolesandpermissions/RolesAndPermissionsViewTests.kt index be6411b373..bc4e5cc74b 100644 --- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/rolesandpermissions/RolesAndPermissionsViewTests.kt +++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/rolesandpermissions/RolesAndPermissionsViewTests.kt @@ -184,7 +184,7 @@ private fun AndroidComposeTestRule.setRoles RolesAndPermissionsView( state = state, rolesAndPermissionsNavigator = object : RolesAndPermissionsNavigator { - override fun onBackPressed() = goBack() + override fun onBackClick() = goBack() override fun openAdminList() = openAdminList() override fun openModeratorList() = openModeratorList() override fun openEditRoomDetailsPermissions() = openPermissionScreens() diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/rolesandpermissions/changeroles/ChangeRolesViewTest.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/rolesandpermissions/changeroles/ChangeRolesViewTest.kt index 93cbad4d58..79c1cdb433 100644 --- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/rolesandpermissions/changeroles/ChangeRolesViewTest.kt +++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/rolesandpermissions/changeroles/ChangeRolesViewTest.kt @@ -297,12 +297,12 @@ class ChangeRolesViewTest { private fun AndroidComposeTestRule.setChangeRolesContent( state: ChangeRolesState, - onBackPressed: () -> Unit = EnsureNeverCalled(), + onBackClick: () -> Unit = EnsureNeverCalled(), ) { setContent { ChangeRolesView( state = state, - navigateUp = onBackPressed, + navigateUp = onBackClick, ) } } diff --git a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/rolesandpermissions/permissions/ChangeRoomPermissionsViewTests.kt b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/rolesandpermissions/permissions/ChangeRoomPermissionsViewTests.kt index 942b40ff7a..be4bf49872 100644 --- a/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/rolesandpermissions/permissions/ChangeRoomPermissionsViewTests.kt +++ b/features/roomdetails/impl/src/test/kotlin/io/element/android/features/roomdetails/rolesandpermissions/permissions/ChangeRoomPermissionsViewTests.kt @@ -161,7 +161,7 @@ class ChangeRoomPermissionsViewTests { hasChanges = true, saveAction = AsyncAction.Success(Unit), ), - onBackPressed = callback + onBackClick = callback ) rule.clickOn(CommonStrings.action_save) } @@ -190,12 +190,12 @@ private fun AndroidComposeTestRule.setChang section = ChangeRoomPermissionsSection.RoomDetails, eventSink = eventsRecorder, ), - onBackPressed: () -> Unit = EnsureNeverCalled(), + onBackClick: () -> Unit = EnsureNeverCalled(), ) { setContent { ChangeRoomPermissionsView( state = state, - onBackPressed = onBackPressed, + onBackClick = onBackClick, ) } } diff --git a/features/roomdirectory/api/src/main/kotlin/io/element/android/features/roomdirectory/api/RoomDirectoryEntryPoint.kt b/features/roomdirectory/api/src/main/kotlin/io/element/android/features/roomdirectory/api/RoomDirectoryEntryPoint.kt index 4c90b82543..a811717946 100644 --- a/features/roomdirectory/api/src/main/kotlin/io/element/android/features/roomdirectory/api/RoomDirectoryEntryPoint.kt +++ b/features/roomdirectory/api/src/main/kotlin/io/element/android/features/roomdirectory/api/RoomDirectoryEntryPoint.kt @@ -20,7 +20,6 @@ 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 -import io.element.android.libraries.matrix.api.core.RoomId interface RoomDirectoryEntryPoint : FeatureEntryPoint { fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder @@ -31,7 +30,6 @@ interface RoomDirectoryEntryPoint : FeatureEntryPoint { } interface Callback : Plugin { - fun onRoomJoined(roomId: RoomId) - fun onResultClicked(roomDescription: RoomDescription) + fun onResultClick(roomDescription: RoomDescription) } } diff --git a/features/roomdirectory/impl/build.gradle.kts b/features/roomdirectory/impl/build.gradle.kts index 49638ece40..71bfa5ff89 100644 --- a/features/roomdirectory/impl/build.gradle.kts +++ b/features/roomdirectory/impl/build.gradle.kts @@ -46,9 +46,11 @@ dependencies { implementation(projects.libraries.designsystem) implementation(projects.libraries.uiStrings) implementation(projects.libraries.testtags) + implementation(projects.services.analytics.api) testImplementation(libs.test.junit) testImplementation(libs.androidx.compose.ui.test.junit) + testReleaseImplementation(libs.androidx.compose.ui.test.manifest) testImplementation(libs.test.robolectric) testImplementation(libs.coroutines.test) testImplementation(libs.molecule.runtime) diff --git a/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryEvents.kt b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryEvents.kt index 37e0ffb3c6..f105a19c53 100644 --- a/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryEvents.kt +++ b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryEvents.kt @@ -16,11 +16,7 @@ package io.element.android.features.roomdirectory.impl.root -import io.element.android.libraries.matrix.api.core.RoomId - sealed interface RoomDirectoryEvents { - data class JoinRoom(val roomId: RoomId) : RoomDirectoryEvents data class Search(val query: String) : RoomDirectoryEvents data object LoadMore : RoomDirectoryEvents - data object JoinRoomDismissError : RoomDirectoryEvents } diff --git a/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryNode.kt b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryNode.kt index 32f9571d44..3b6b4ddf46 100644 --- a/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryNode.kt +++ b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryNode.kt @@ -28,7 +28,6 @@ import io.element.android.anvilannotations.ContributesNode import io.element.android.features.roomdirectory.api.RoomDescription import io.element.android.features.roomdirectory.api.RoomDirectoryEntryPoint import io.element.android.libraries.di.SessionScope -import io.element.android.libraries.matrix.api.core.RoomId @ContributesNode(SessionScope::class) class RoomDirectoryNode @AssistedInject constructor( @@ -36,15 +35,9 @@ class RoomDirectoryNode @AssistedInject constructor( @Assisted plugins: List, private val presenter: RoomDirectoryPresenter, ) : Node(buildContext, plugins = plugins) { - private fun onResultClicked(roomDescription: RoomDescription) { + private fun onResultClick(roomDescription: RoomDescription) { plugins().forEach { - it.onResultClicked(roomDescription) - } - } - - private fun onRoomJoined(roomId: RoomId) { - plugins().forEach { - it.onRoomJoined(roomId) + it.onResultClick(roomDescription) } } @@ -53,9 +46,8 @@ class RoomDirectoryNode @AssistedInject constructor( val state = presenter.present() RoomDirectoryView( state = state, - onRoomJoined = ::onRoomJoined, - onResultClicked = ::onResultClicked, - onBackPressed = ::navigateUp, + onResultClick = ::onResultClick, + onBackClick = ::navigateUp, modifier = modifier ) } diff --git a/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryPresenter.kt b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryPresenter.kt index 4f9130613f..dad3616f0c 100644 --- a/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryPresenter.kt +++ b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryPresenter.kt @@ -18,7 +18,6 @@ package io.element.android.features.roomdirectory.impl.root 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 @@ -26,27 +25,20 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue -import io.element.android.features.roomdirectory.impl.root.di.JoinRoom import io.element.android.features.roomdirectory.impl.root.model.RoomDirectoryListState import io.element.android.features.roomdirectory.impl.root.model.toFeatureModel -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.RoomId import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryList import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryService import kotlinx.collections.immutable.toImmutableList -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map -import kotlinx.coroutines.launch import javax.inject.Inject class RoomDirectoryPresenter @Inject constructor( private val dispatchers: CoroutineDispatchers, - private val joinRoom: JoinRoom, private val roomDirectoryService: RoomDirectoryService, ) : Presenter { @Composable @@ -62,9 +54,6 @@ class RoomDirectoryPresenter @Inject constructor( roomDirectoryService.createRoomDirectoryList(coroutineScope) } val listState by roomDirectoryList.collectState() - val joinRoomAction: MutableState> = remember { - mutableStateOf(AsyncAction.Uninitialized) - } LaunchedEffect(searchQuery) { if (searchQuery == null) return@LaunchedEffect // cancel load more right away @@ -87,12 +76,6 @@ class RoomDirectoryPresenter @Inject constructor( is RoomDirectoryEvents.Search -> { searchQuery = event.query } - is RoomDirectoryEvents.JoinRoom -> { - coroutineScope.joinRoom(joinRoomAction, event.roomId) - } - RoomDirectoryEvents.JoinRoomDismissError -> { - joinRoomAction.value = AsyncAction.Uninitialized - } } } @@ -100,18 +83,10 @@ class RoomDirectoryPresenter @Inject constructor( query = searchQuery.orEmpty(), roomDescriptions = listState.items, displayLoadMoreIndicator = listState.hasMoreToLoad, - joinRoomAction = joinRoomAction.value, eventSink = ::handleEvents ) } - private fun CoroutineScope.joinRoom(state: MutableState>, roomId: RoomId) = launch { - state.runUpdatingState { - joinRoom(roomId) - .map { roomId } - } - } - @Composable private fun RoomDirectoryList.collectState() = remember { state.map { diff --git a/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryState.kt b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryState.kt index 526139338d..0488757f0b 100644 --- a/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryState.kt +++ b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryState.kt @@ -17,15 +17,12 @@ package io.element.android.features.roomdirectory.impl.root import io.element.android.features.roomdirectory.api.RoomDescription -import io.element.android.libraries.architecture.AsyncAction -import io.element.android.libraries.matrix.api.core.RoomId import kotlinx.collections.immutable.ImmutableList data class RoomDirectoryState( val query: String, val roomDescriptions: ImmutableList, val displayLoadMoreIndicator: Boolean, - val joinRoomAction: AsyncAction, val eventSink: (RoomDirectoryEvents) -> Unit ) { val displayEmptyState = roomDescriptions.isEmpty() && !displayLoadMoreIndicator diff --git a/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryStateProvider.kt b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryStateProvider.kt index bf682fc15b..42acefa534 100644 --- a/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryStateProvider.kt +++ b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryStateProvider.kt @@ -18,7 +18,6 @@ package io.element.android.features.roomdirectory.impl.root import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.features.roomdirectory.api.RoomDescription -import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.matrix.api.core.RoomAlias import io.element.android.libraries.matrix.api.core.RoomId import kotlinx.collections.immutable.ImmutableList @@ -37,16 +36,6 @@ open class RoomDirectoryStateProvider : PreviewParameterProvider = persistentListOf(), - joinRoomAction: AsyncAction = AsyncAction.Uninitialized, eventSink: (RoomDirectoryEvents) -> Unit = {}, ) = RoomDirectoryState( query = query, roomDescriptions = roomDescriptions, displayLoadMoreIndicator = displayLoadMoreIndicator, - joinRoomAction = joinRoomAction, eventSink = eventSink, ) diff --git a/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryView.kt b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryView.kt index a79f0d6e70..838b83e9f6 100644 --- a/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryView.kt +++ b/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryView.kt @@ -47,7 +47,6 @@ import io.element.android.compound.theme.ElementTheme import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.features.roomdirectory.api.RoomDescription import io.element.android.features.roomdirectory.impl.R -import io.element.android.libraries.designsystem.components.async.AsyncActionView import io.element.android.libraries.designsystem.components.avatar.Avatar import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.button.BackButton @@ -61,7 +60,6 @@ import io.element.android.libraries.designsystem.theme.components.Scaffold import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.TextField import io.element.android.libraries.designsystem.theme.components.TopAppBar -import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.testtags.TestTags import io.element.android.libraries.ui.strings.CommonStrings import kotlinx.collections.immutable.ImmutableList @@ -69,48 +67,37 @@ import kotlinx.collections.immutable.ImmutableList @Composable fun RoomDirectoryView( state: RoomDirectoryState, - onResultClicked: (RoomDescription) -> Unit, - onRoomJoined: (RoomId) -> Unit, - onBackPressed: () -> Unit, + onResultClick: (RoomDescription) -> Unit, + onBackClick: () -> Unit, modifier: Modifier = Modifier, ) { Scaffold( modifier = modifier, topBar = { - RoomDirectoryTopBar(onBackPressed = onBackPressed) + RoomDirectoryTopBar(onBackClick = onBackClick) }, content = { padding -> RoomDirectoryContent( state = state, - onResultClicked = onResultClicked, + onResultClick = onResultClick, modifier = Modifier - .padding(padding) - .consumeWindowInsets(padding) + .padding(padding) + .consumeWindowInsets(padding) ) } ) - AsyncActionView( - async = state.joinRoomAction, - onSuccess = onRoomJoined, - onErrorDismiss = { - state.eventSink(RoomDirectoryEvents.JoinRoomDismissError) - }, - errorMessage = { - stringResource(id = CommonStrings.error_unknown) - } - ) } @OptIn(ExperimentalMaterial3Api::class) @Composable private fun RoomDirectoryTopBar( - onBackPressed: () -> Unit, + onBackClick: () -> Unit, modifier: Modifier = Modifier, ) { TopAppBar( modifier = modifier, navigationIcon = { - BackButton(onClick = onBackPressed) + BackButton(onClick = onBackClick) }, title = { Text( @@ -124,7 +111,7 @@ private fun RoomDirectoryTopBar( @Composable private fun RoomDirectoryContent( state: RoomDirectoryState, - onResultClicked: (RoomDescription) -> Unit, + onResultClick: (RoomDescription) -> Unit, modifier: Modifier = Modifier, ) { Column(modifier = modifier) { @@ -138,7 +125,7 @@ private fun RoomDirectoryContent( roomDescriptions = state.roomDescriptions, displayLoadMoreIndicator = state.displayLoadMoreIndicator, displayEmptyState = state.displayEmptyState, - onResultClicked = onResultClicked, + onResultClick = onResultClick, onReachedLoadMore = { state.eventSink(RoomDirectoryEvents.LoadMore) }, ) } @@ -149,7 +136,7 @@ private fun RoomDirectoryRoomList( roomDescriptions: ImmutableList, displayLoadMoreIndicator: Boolean, displayEmptyState: Boolean, - onResultClicked: (RoomDescription) -> Unit, + onResultClick: (RoomDescription) -> Unit, onReachedLoadMore: () -> Unit, modifier: Modifier = Modifier, ) { @@ -158,7 +145,7 @@ private fun RoomDirectoryRoomList( RoomDirectoryRoomRow( roomDescription = roomDescription, onClick = { - onResultClicked(roomDescription) + onResultClick(roomDescription) }, ) } @@ -186,10 +173,10 @@ private fun RoomDirectoryRoomList( @Composable private fun LoadMoreIndicator(modifier: Modifier = Modifier) { Box( - modifier - .fillMaxWidth() - .wrapContentHeight() - .padding(24.dp), + modifier = modifier + .fillMaxWidth() + .wrapContentHeight() + .padding(24.dp), contentAlignment = Alignment.Center, ) { CircularProgressIndicator( @@ -259,14 +246,14 @@ private fun RoomDirectoryRoomRow( ) { Row( modifier = modifier - .fillMaxWidth() - .clickable(onClick = onClick) - .padding( - top = 12.dp, - bottom = 12.dp, - start = 16.dp, - ) - .height(IntrinsicSize.Min), + .fillMaxWidth() + .clickable(onClick = onClick) + .padding( + top = 12.dp, + bottom = 12.dp, + start = 16.dp, + ) + .height(IntrinsicSize.Min), ) { Avatar( avatarData = roomDescription.avatarData(AvatarSize.RoomDirectoryItem), @@ -274,8 +261,8 @@ private fun RoomDirectoryRoomRow( ) Column( modifier = Modifier - .weight(1f) - .padding(horizontal = 16.dp) + .weight(1f) + .padding(horizontal = 16.dp) ) { Text( text = roomDescription.computedName, @@ -300,8 +287,7 @@ private fun RoomDirectoryRoomRow( internal fun RoomDirectoryViewPreview(@PreviewParameter(RoomDirectoryStateProvider::class) state: RoomDirectoryState) = ElementPreview { RoomDirectoryView( state = state, - onResultClicked = {}, - onRoomJoined = {}, - onBackPressed = {}, + onResultClick = {}, + onBackClick = {}, ) } diff --git a/features/roomdirectory/impl/src/main/res/values-pt-rBR/translations.xml b/features/roomdirectory/impl/src/main/res/values-pt-rBR/translations.xml new file mode 100644 index 0000000000..a6a24f3dbd --- /dev/null +++ b/features/roomdirectory/impl/src/main/res/values-pt-rBR/translations.xml @@ -0,0 +1,5 @@ + + + "Falha ao carregar" + "Diretório de salas" + diff --git a/features/roomdirectory/impl/src/main/res/values-ro/translations.xml b/features/roomdirectory/impl/src/main/res/values-ro/translations.xml new file mode 100644 index 0000000000..c996f87f8a --- /dev/null +++ b/features/roomdirectory/impl/src/main/res/values-ro/translations.xml @@ -0,0 +1,5 @@ + + + "Încărcare eșuată" + "Director de camere" + diff --git a/features/roomdirectory/impl/src/main/res/values-zh/translations.xml b/features/roomdirectory/impl/src/main/res/values-zh/translations.xml new file mode 100644 index 0000000000..705b8e353f --- /dev/null +++ b/features/roomdirectory/impl/src/main/res/values-zh/translations.xml @@ -0,0 +1,5 @@ + + + "加载失败" + "房间目录" + diff --git a/features/roomdirectory/impl/src/test/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryPresenterTest.kt b/features/roomdirectory/impl/src/test/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryPresenterTest.kt index 3af102146b..87cb8b8c5d 100644 --- a/features/roomdirectory/impl/src/test/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryPresenterTest.kt +++ b/features/roomdirectory/impl/src/test/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryPresenterTest.kt @@ -17,12 +17,8 @@ package io.element.android.features.roomdirectory.impl.root import com.google.common.truth.Truth.assertThat -import io.element.android.features.roomdirectory.impl.root.di.JoinRoom -import io.element.android.libraries.architecture.AsyncAction -import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryList import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryService -import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.roomdirectory.FakeRoomDirectoryList import io.element.android.libraries.matrix.test.roomdirectory.FakeRoomDirectoryService import io.element.android.libraries.matrix.test.roomdirectory.aRoomDescription @@ -47,7 +43,6 @@ import org.junit.Test val initialState = awaitItem() assertThat(initialState.query).isEmpty() assertThat(initialState.displayEmptyState).isFalse() - assertThat(initialState.joinRoomAction).isEqualTo(AsyncAction.Uninitialized) assertThat(initialState.roomDescriptions).isEmpty() assertThat(initialState.displayLoadMoreIndicator).isTrue() } @@ -136,46 +131,13 @@ import org.junit.Test .withNoParameter() } - @Test - fun `present - emit join room event`() = runTest { - val joinRoomSuccess = lambdaRecorder { _: RoomId -> - Result.success(Unit) - } - val joinRoomFailure = lambdaRecorder { roomId: RoomId -> - Result.failure(RuntimeException("Failed to join room $roomId")) - } - val fakeJoinRoom = FakeJoinRoom(joinRoomSuccess) - val presenter = createRoomDirectoryPresenter(joinRoom = fakeJoinRoom) - presenter.test { - awaitItem().also { state -> - state.eventSink(RoomDirectoryEvents.JoinRoom(A_ROOM_ID)) - } - awaitItem().also { state -> - assertThat(state.joinRoomAction).isEqualTo(AsyncAction.Success(A_ROOM_ID)) - fakeJoinRoom.lambda = joinRoomFailure - state.eventSink(RoomDirectoryEvents.JoinRoom(A_ROOM_ID)) - } - awaitItem().also { state -> - assertThat(state.joinRoomAction).isInstanceOf(AsyncAction.Failure::class.java) - } - } - assert(joinRoomSuccess) - .isCalledOnce() - .with(value(A_ROOM_ID)) - assert(joinRoomFailure) - .isCalledOnce() - .with(value(A_ROOM_ID)) - } - private fun TestScope.createRoomDirectoryPresenter( roomDirectoryService: RoomDirectoryService = FakeRoomDirectoryService( createRoomDirectoryListFactory = { FakeRoomDirectoryList() } ), - joinRoom: JoinRoom = FakeJoinRoom { Result.success(Unit) }, ): RoomDirectoryPresenter { return RoomDirectoryPresenter( dispatchers = testCoroutineDispatchers(), - joinRoom = joinRoom, roomDirectoryService = roomDirectoryService, ) } diff --git a/features/roomdirectory/impl/src/test/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryViewTest.kt b/features/roomdirectory/impl/src/test/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryViewTest.kt index 2535f6f421..c971e84118 100644 --- a/features/roomdirectory/impl/src/test/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryViewTest.kt +++ b/features/roomdirectory/impl/src/test/kotlin/io/element/android/features/roomdirectory/impl/root/RoomDirectoryViewTest.kt @@ -25,8 +25,6 @@ import androidx.compose.ui.test.performClick import androidx.compose.ui.test.performTextInput import androidx.test.ext.junit.runners.AndroidJUnit4 import io.element.android.features.roomdirectory.api.RoomDescription -import io.element.android.libraries.architecture.AsyncAction -import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.testtags.TestTags import io.element.android.tests.testutils.EnsureNeverCalled import io.element.android.tests.testutils.EnsureNeverCalledWithParam @@ -56,7 +54,7 @@ class RoomDirectoryViewTest { } @Test - fun `clicking on room item then onResultClicked lambda is called once`() { + fun `clicking on room item then onResultClick lambda is called once`() { val eventsRecorder = EventsRecorder() val state = aRoomDirectoryState( roomDescriptions = aRoomDescriptionList(), @@ -66,7 +64,7 @@ class RoomDirectoryViewTest { ensureCalledOnceWithParam(clickedRoom) { callback -> rule.setRoomDirectoryView( state = state, - onResultClicked = callback, + onResultClick = callback, ) rule.onNodeWithText(clickedRoom.computedName).performClick() } @@ -82,38 +80,18 @@ class RoomDirectoryViewTest { rule.setRoomDirectoryView(state = state) eventsRecorder.assertSingle(RoomDirectoryEvents.LoadMore) } - - @Test - fun `when joining room with success then onRoomJoined lambda is called once`() { - val eventsRecorder = EventsRecorder(expectEvents = false) - val roomDescriptions = aRoomDescriptionList() - val joinedRoomId = roomDescriptions.first().roomId - val state = aRoomDirectoryState( - joinRoomAction = AsyncAction.Success(joinedRoomId), - roomDescriptions = roomDescriptions, - eventSink = eventsRecorder, - ) - ensureCalledOnceWithParam(joinedRoomId) { callback -> - rule.setRoomDirectoryView( - state = state, - onRoomJoined = callback, - ) - } - } } private fun AndroidComposeTestRule.setRoomDirectoryView( state: RoomDirectoryState, - onBackPressed: () -> Unit = EnsureNeverCalled(), - onResultClicked: (RoomDescription) -> Unit = EnsureNeverCalledWithParam(), - onRoomJoined: (RoomId) -> Unit = EnsureNeverCalledWithParam(), + onBackClick: () -> Unit = EnsureNeverCalled(), + onResultClick: (RoomDescription) -> Unit = EnsureNeverCalledWithParam(), ) { setContent { RoomDirectoryView( state = state, - onResultClicked = onResultClicked, - onRoomJoined = onRoomJoined, - onBackPressed = onBackPressed, + onResultClick = onResultClick, + onBackClick = onBackClick, ) } } diff --git a/features/roomlist/api/src/main/kotlin/io/element/android/features/roomlist/api/RoomListEntryPoint.kt b/features/roomlist/api/src/main/kotlin/io/element/android/features/roomlist/api/RoomListEntryPoint.kt index f4e2ca7b01..86d3e7cd1a 100644 --- a/features/roomlist/api/src/main/kotlin/io/element/android/features/roomlist/api/RoomListEntryPoint.kt +++ b/features/roomlist/api/src/main/kotlin/io/element/android/features/roomlist/api/RoomListEntryPoint.kt @@ -30,12 +30,12 @@ interface RoomListEntryPoint : FeatureEntryPoint { } interface Callback : Plugin { - fun onRoomClicked(roomId: RoomId) - fun onCreateRoomClicked() - fun onSettingsClicked() - fun onSessionConfirmRecoveryKeyClicked() - fun onRoomSettingsClicked(roomId: RoomId) - fun onReportBugClicked() - fun onRoomDirectorySearchClicked() + fun onRoomClick(roomId: RoomId) + fun onCreateRoomClick() + fun onSettingsClick() + fun onSessionConfirmRecoveryKeyClick() + fun onRoomSettingsClick(roomId: RoomId) + fun onReportBugClick() + fun onRoomDirectorySearchClick() } } diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListContextMenu.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListContextMenu.kt index 4d7a6b3855..582c5e083b 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListContextMenu.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListContextMenu.kt @@ -44,30 +44,30 @@ import io.element.android.libraries.ui.strings.CommonStrings fun RoomListContextMenu( contextMenu: RoomListState.ContextMenu.Shown, eventSink: (RoomListEvents.ContextMenuEvents) -> Unit, - onRoomSettingsClicked: (roomId: RoomId) -> Unit, + onRoomSettingsClick: (roomId: RoomId) -> Unit, ) { ModalBottomSheet( onDismissRequest = { eventSink(RoomListEvents.HideContextMenu) }, ) { RoomListModalBottomSheetContent( contextMenu = contextMenu, - onRoomMarkReadClicked = { + onRoomMarkReadClick = { eventSink(RoomListEvents.HideContextMenu) eventSink(RoomListEvents.MarkAsRead(contextMenu.roomId)) }, - onRoomMarkUnreadClicked = { + onRoomMarkUnreadClick = { eventSink(RoomListEvents.HideContextMenu) eventSink(RoomListEvents.MarkAsUnread(contextMenu.roomId)) }, - onRoomSettingsClicked = { + onRoomSettingsClick = { eventSink(RoomListEvents.HideContextMenu) - onRoomSettingsClicked(contextMenu.roomId) + onRoomSettingsClick(contextMenu.roomId) }, - onLeaveRoomClicked = { + onLeaveRoomClick = { eventSink(RoomListEvents.HideContextMenu) eventSink(RoomListEvents.LeaveRoom(contextMenu.roomId)) }, - onFavoriteChanged = { isFavorite -> + onFavoriteChange = { isFavorite -> eventSink(RoomListEvents.SetRoomIsFavorite(contextMenu.roomId, isFavorite)) }, ) @@ -77,11 +77,11 @@ fun RoomListContextMenu( @Composable private fun RoomListModalBottomSheetContent( contextMenu: RoomListState.ContextMenu.Shown, - onRoomSettingsClicked: () -> Unit, - onLeaveRoomClicked: () -> Unit, - onFavoriteChanged: (isFavorite: Boolean) -> Unit, - onRoomMarkReadClicked: () -> Unit, - onRoomMarkUnreadClicked: () -> Unit, + onRoomSettingsClick: () -> Unit, + onLeaveRoomClick: () -> Unit, + onFavoriteChange: (isFavorite: Boolean) -> Unit, + onRoomMarkReadClick: () -> Unit, + onRoomMarkUnreadClick: () -> Unit, ) { Column( modifier = Modifier.fillMaxWidth() @@ -111,9 +111,9 @@ private fun RoomListModalBottomSheetContent( }, modifier = Modifier.clickable { if (contextMenu.hasNewContent) { - onRoomMarkReadClicked() + onRoomMarkReadClick() } else { - onRoomMarkUnreadClicked() + onRoomMarkUnreadClick() } }, /* TODO Design @@ -143,11 +143,11 @@ private fun RoomListModalBottomSheetContent( trailingContent = ListItemContent.Switch( checked = contextMenu.isFavorite, onChange = { isFavorite -> - onFavoriteChanged(isFavorite) + onFavoriteChange(isFavorite) }, ), onClick = { - onFavoriteChanged(!contextMenu.isFavorite) + onFavoriteChange(!contextMenu.isFavorite) }, style = ListItemStyle.Primary, ) @@ -158,7 +158,7 @@ private fun RoomListModalBottomSheetContent( style = MaterialTheme.typography.bodyLarge, ) }, - modifier = Modifier.clickable { onRoomSettingsClicked() }, + modifier = Modifier.clickable { onRoomSettingsClick() }, leadingContent = ListItemContent.Icon( iconSource = IconSource.Vector( CompoundIcons.Settings(), @@ -178,7 +178,7 @@ private fun RoomListModalBottomSheetContent( ) Text(text = leaveText) }, - modifier = Modifier.clickable { onLeaveRoomClicked() }, + modifier = Modifier.clickable { onLeaveRoomClick() }, leadingContent = ListItemContent.Icon( iconSource = IconSource.Vector( CompoundIcons.Leave(), @@ -200,10 +200,10 @@ internal fun RoomListModalBottomSheetContentPreview( ) = ElementPreview { RoomListModalBottomSheetContent( contextMenu = contextMenu, - onRoomMarkReadClicked = {}, - onRoomMarkUnreadClicked = {}, - onRoomSettingsClicked = {}, - onLeaveRoomClicked = {}, - onFavoriteChanged = {}, + onRoomMarkReadClick = {}, + onRoomMarkUnreadClick = {}, + onRoomSettingsClick = {}, + onLeaveRoomClick = {}, + onFavoriteChange = {}, ) } diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListNode.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListNode.kt index 912c45eb30..d77fd1f440 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListNode.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListNode.kt @@ -54,39 +54,39 @@ class RoomListNode @AssistedInject constructor( ) } - private fun onRoomClicked(roomId: RoomId) { - plugins().forEach { it.onRoomClicked(roomId) } + private fun onRoomClick(roomId: RoomId) { + plugins().forEach { it.onRoomClick(roomId) } } private fun onOpenSettings() { - plugins().forEach { it.onSettingsClicked() } + plugins().forEach { it.onSettingsClick() } } - private fun onCreateRoomClicked() { - plugins().forEach { it.onCreateRoomClicked() } + private fun onCreateRoomClick() { + plugins().forEach { it.onCreateRoomClick() } } - private fun onSessionConfirmRecoveryKeyClicked() { - plugins().forEach { it.onSessionConfirmRecoveryKeyClicked() } + private fun onSessionConfirmRecoveryKeyClick() { + plugins().forEach { it.onSessionConfirmRecoveryKeyClick() } } - private fun onRoomSettingsClicked(roomId: RoomId) { - plugins().forEach { it.onRoomSettingsClicked(roomId) } + private fun onRoomSettingsClick(roomId: RoomId) { + plugins().forEach { it.onRoomSettingsClick(roomId) } } - private fun onMenuActionClicked(activity: Activity, roomListMenuAction: RoomListMenuAction) { + private fun onMenuActionClick(activity: Activity, roomListMenuAction: RoomListMenuAction) { when (roomListMenuAction) { RoomListMenuAction.InviteFriends -> { inviteFriendsUseCase.execute(activity) } RoomListMenuAction.ReportBug -> { - plugins().forEach { it.onReportBugClicked() } + plugins().forEach { it.onReportBugClick() } } } } - private fun onRoomDirectorySearchClicked() { - plugins().forEach { it.onRoomDirectorySearchClicked() } + private fun onRoomDirectorySearchClick() { + plugins().forEach { it.onRoomDirectorySearchClick() } } @Composable @@ -95,19 +95,19 @@ class RoomListNode @AssistedInject constructor( val activity = LocalContext.current as Activity RoomListView( state = state, - onRoomClicked = this::onRoomClicked, - onSettingsClicked = this::onOpenSettings, - onCreateRoomClicked = this::onCreateRoomClicked, - onConfirmRecoveryKeyClicked = this::onSessionConfirmRecoveryKeyClicked, - onRoomSettingsClicked = this::onRoomSettingsClicked, - onMenuActionClicked = { onMenuActionClicked(activity, it) }, - onRoomDirectorySearchClicked = this::onRoomDirectorySearchClicked, + onRoomClick = this::onRoomClick, + onSettingsClick = this::onOpenSettings, + onCreateRoomClick = this::onCreateRoomClick, + onConfirmRecoveryKeyClick = this::onSessionConfirmRecoveryKeyClick, + onRoomSettingsClick = this::onRoomSettingsClick, + onMenuActionClick = { onMenuActionClick(activity, it) }, + onRoomDirectorySearchClick = this::onRoomDirectorySearchClick, modifier = modifier, ) { acceptDeclineInviteView.Render( state = state.acceptDeclineInviteState, - onInviteAccepted = this::onRoomClicked, - onInviteDeclined = { }, + onAcceptInvite = this::onRoomClick, + onDeclineInvite = { }, modifier = Modifier ) } diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt index 22506fe49b..03fdfbdc3b 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/RoomListView.kt @@ -51,13 +51,13 @@ import io.element.android.libraries.matrix.api.core.RoomId @Composable fun RoomListView( state: RoomListState, - onRoomClicked: (RoomId) -> Unit, - onSettingsClicked: () -> Unit, - onConfirmRecoveryKeyClicked: () -> Unit, - onCreateRoomClicked: () -> Unit, - onRoomSettingsClicked: (roomId: RoomId) -> Unit, - onMenuActionClicked: (RoomListMenuAction) -> Unit, - onRoomDirectorySearchClicked: () -> Unit, + onRoomClick: (RoomId) -> Unit, + onSettingsClick: () -> Unit, + onConfirmRecoveryKeyClick: () -> Unit, + onCreateRoomClick: () -> Unit, + onRoomSettingsClick: (roomId: RoomId) -> Unit, + onMenuActionClick: (RoomListMenuAction) -> Unit, + onRoomDirectorySearchClick: () -> Unit, modifier: Modifier = Modifier, acceptDeclineInviteView: @Composable () -> Unit, ) { @@ -70,7 +70,7 @@ fun RoomListView( RoomListContextMenu( contextMenu = state.contextMenu, eventSink = state.eventSink, - onRoomSettingsClicked = onRoomSettingsClicked, + onRoomSettingsClick = onRoomSettingsClick, ) } @@ -78,19 +78,19 @@ fun RoomListView( RoomListScaffold( state = state, - onConfirmRecoveryKeyClicked = onConfirmRecoveryKeyClicked, - onRoomClicked = onRoomClicked, - onOpenSettings = onSettingsClicked, - onCreateRoomClicked = onCreateRoomClicked, - onMenuActionClicked = onMenuActionClicked, + onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick, + onRoomClick = onRoomClick, + onOpenSettings = onSettingsClick, + onCreateRoomClick = onCreateRoomClick, + onMenuActionClick = onMenuActionClick, modifier = Modifier.padding(top = topPadding), ) // This overlaid view will only be visible when state.displaySearchResults is true RoomListSearchView( state = state.searchState, eventSink = state.eventSink, - onRoomClicked = onRoomClicked, - onRoomDirectorySearchClicked = onRoomDirectorySearchClicked, + onRoomClick = onRoomClick, + onRoomDirectorySearchClick = onRoomDirectorySearchClick, modifier = Modifier .statusBarsPadding() .padding(top = topPadding) @@ -106,15 +106,15 @@ fun RoomListView( @Composable private fun RoomListScaffold( state: RoomListState, - onConfirmRecoveryKeyClicked: () -> Unit, - onRoomClicked: (RoomId) -> Unit, + onConfirmRecoveryKeyClick: () -> Unit, + onRoomClick: (RoomId) -> Unit, onOpenSettings: () -> Unit, - onCreateRoomClicked: () -> Unit, - onMenuActionClicked: (RoomListMenuAction) -> Unit, + onCreateRoomClick: () -> Unit, + onMenuActionClick: (RoomListMenuAction) -> Unit, modifier: Modifier = Modifier, ) { - fun onRoomClicked(room: RoomListRoomSummary) { - onRoomClicked(room.roomId) + fun onRoomClick(room: RoomListRoomSummary) { + onRoomClick(room.roomId) } val appBarState = rememberTopAppBarState() @@ -129,7 +129,7 @@ private fun RoomListScaffold( showAvatarIndicator = state.showAvatarIndicator, areSearchResultsDisplayed = state.searchState.isSearchActive, onToggleSearch = { state.eventSink(RoomListEvents.ToggleSearchResults) }, - onMenuActionClicked = onMenuActionClicked, + onMenuActionClick = onMenuActionClick, onOpenSettings = onOpenSettings, scrollBehavior = scrollBehavior, displayMenuItems = state.displayActions, @@ -142,9 +142,9 @@ private fun RoomListScaffold( contentState = state.contentState, filtersState = state.filtersState, eventSink = state.eventSink, - onConfirmRecoveryKeyClicked = onConfirmRecoveryKeyClicked, - onRoomClicked = ::onRoomClicked, - onCreateRoomClicked = onCreateRoomClicked, + onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick, + onRoomClick = ::onRoomClick, + onCreateRoomClick = onCreateRoomClick, modifier = Modifier .padding(padding) .consumeWindowInsets(padding) @@ -155,7 +155,7 @@ private fun RoomListScaffold( FloatingActionButton( // FIXME align on Design system theme containerColor = MaterialTheme.colorScheme.primary, - onClick = onCreateRoomClicked + onClick = onCreateRoomClick ) { Icon( // Note cannot use Icons.Outlined.EditSquare, it does not exist :/ @@ -176,13 +176,13 @@ internal fun RoomListRoomSummary.contentType() = displayType.ordinal internal fun RoomListViewPreview(@PreviewParameter(RoomListStateProvider::class) state: RoomListState) = ElementPreview { RoomListView( state = state, - onRoomClicked = {}, - onSettingsClicked = {}, - onConfirmRecoveryKeyClicked = {}, - onCreateRoomClicked = {}, - onRoomSettingsClicked = {}, - onMenuActionClicked = {}, - onRoomDirectorySearchClicked = {}, + onRoomClick = {}, + onSettingsClick = {}, + onConfirmRecoveryKeyClick = {}, + onCreateRoomClick = {}, + onRoomSettingsClick = {}, + onMenuActionClick = {}, + onRoomDirectorySearchClick = {}, acceptDeclineInviteView = {}, ) } diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/ConfirmRecoveryKeyBanner.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/ConfirmRecoveryKeyBanner.kt index 71f9c78530..1e987c2a27 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/ConfirmRecoveryKeyBanner.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/ConfirmRecoveryKeyBanner.kt @@ -26,16 +26,16 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight @Composable internal fun ConfirmRecoveryKeyBanner( - onContinueClicked: () -> Unit, - onDismissClicked: () -> Unit, + onContinueClick: () -> Unit, + onDismissClick: () -> Unit, modifier: Modifier = Modifier, ) { DialogLikeBannerMolecule( modifier = modifier, title = stringResource(R.string.confirm_recovery_key_banner_title), content = stringResource(R.string.confirm_recovery_key_banner_message), - onSubmitClicked = onContinueClicked, - onDismissClicked = onDismissClicked, + onSubmitClick = onContinueClick, + onDismissClick = onDismissClick, ) } @@ -43,7 +43,7 @@ internal fun ConfirmRecoveryKeyBanner( @Composable internal fun ConfirmRecoveryKeyBannerPreview() = ElementPreview { ConfirmRecoveryKeyBanner( - onContinueClicked = {}, - onDismissClicked = {}, + onContinueClick = {}, + onDismissClick = {}, ) } diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomListContentView.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomListContentView.kt index 1b1c760889..afe01ce892 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomListContentView.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomListContentView.kt @@ -71,9 +71,9 @@ fun RoomListContentView( contentState: RoomListContentState, filtersState: RoomListFiltersState, eventSink: (RoomListEvents) -> Unit, - onConfirmRecoveryKeyClicked: () -> Unit, - onRoomClicked: (RoomListRoomSummary) -> Unit, - onCreateRoomClicked: () -> Unit, + onConfirmRecoveryKeyClick: () -> Unit, + onRoomClick: (RoomListRoomSummary) -> Unit, + onCreateRoomClick: () -> Unit, modifier: Modifier = Modifier, ) { Box(modifier = modifier) { @@ -88,7 +88,7 @@ fun RoomListContentView( } is RoomListContentState.Empty -> { EmptyView( - onCreateRoomClicked = onCreateRoomClicked, + onCreateRoomClick = onCreateRoomClick, ) } is RoomListContentState.Rooms -> { @@ -96,8 +96,8 @@ fun RoomListContentView( state = contentState, filtersState = filtersState, eventSink = eventSink, - onConfirmRecoveryKeyClicked = onConfirmRecoveryKeyClicked, - onRoomClicked = onRoomClicked, + onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick, + onRoomClick = onRoomClick, ) } } @@ -120,7 +120,7 @@ private fun SkeletonView(count: Int, modifier: Modifier = Modifier) { @Composable private fun EmptyView( - onCreateRoomClicked: () -> Unit, + onCreateRoomClick: () -> Unit, modifier: Modifier = Modifier ) { EmptyScaffold( @@ -130,7 +130,7 @@ private fun EmptyView( Button( text = stringResource(CommonStrings.action_start_chat), leadingIcon = IconSource.Vector(CompoundIcons.Compose()), - onClick = onCreateRoomClicked, + onClick = onCreateRoomClick, ) }, modifier = modifier.fillMaxSize(), @@ -142,8 +142,8 @@ private fun RoomsView( state: RoomListContentState.Rooms, filtersState: RoomListFiltersState, eventSink: (RoomListEvents) -> Unit, - onConfirmRecoveryKeyClicked: () -> Unit, - onRoomClicked: (RoomListRoomSummary) -> Unit, + onConfirmRecoveryKeyClick: () -> Unit, + onRoomClick: (RoomListRoomSummary) -> Unit, modifier: Modifier = Modifier, ) { if (state.summaries.isEmpty() && filtersState.hasAnyFilterSelected) { @@ -155,8 +155,8 @@ private fun RoomsView( RoomsViewList( state = state, eventSink = eventSink, - onConfirmRecoveryKeyClicked = onConfirmRecoveryKeyClicked, - onRoomClicked = onRoomClicked, + onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick, + onRoomClick = onRoomClick, modifier = modifier.fillMaxSize(), ) } @@ -166,8 +166,8 @@ private fun RoomsView( private fun RoomsViewList( state: RoomListContentState.Rooms, eventSink: (RoomListEvents) -> Unit, - onConfirmRecoveryKeyClicked: () -> Unit, - onRoomClicked: (RoomListRoomSummary) -> Unit, + onConfirmRecoveryKeyClick: () -> Unit, + onRoomClick: (RoomListRoomSummary) -> Unit, modifier: Modifier = Modifier, ) { val lazyListState = rememberLazyListState() @@ -197,8 +197,8 @@ private fun RoomsViewList( SecurityBannerState.RecoveryKeyConfirmation -> { item { ConfirmRecoveryKeyBanner( - onContinueClicked = onConfirmRecoveryKeyClicked, - onDismissClicked = { eventSink(RoomListEvents.DismissRecoveryKeyPrompt) } + onContinueClick = onConfirmRecoveryKeyClick, + onDismissClick = { eventSink(RoomListEvents.DismissRecoveryKeyPrompt) } ) } } @@ -213,7 +213,7 @@ private fun RoomsViewList( ) { index, room -> RoomSummaryRow( room = room, - onClick = onRoomClicked, + onClick = onRoomClick, eventSink = eventSink, ) if (index != state.summaries.lastIndex) { @@ -275,8 +275,8 @@ internal fun RoomListContentViewPreview(@PreviewParameter(RoomListContentStatePr filterSelectionStates = RoomListFilter.entries.map { FilterSelectionState(it, isSelected = true) } ), eventSink = {}, - onConfirmRecoveryKeyClicked = {}, - onRoomClicked = {}, - onCreateRoomClicked = {}, + onConfirmRecoveryKeyClick = {}, + onRoomClick = {}, + onCreateRoomClick = {}, ) } diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomListTopBar.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomListTopBar.kt index 301a32a26c..2cab0ab7ec 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomListTopBar.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomListTopBar.kt @@ -88,7 +88,7 @@ fun RoomListTopBar( showAvatarIndicator: Boolean, areSearchResultsDisplayed: Boolean, onToggleSearch: () -> Unit, - onMenuActionClicked: (RoomListMenuAction) -> Unit, + onMenuActionClick: (RoomListMenuAction) -> Unit, onOpenSettings: () -> Unit, scrollBehavior: TopAppBarScrollBehavior, displayMenuItems: Boolean, @@ -101,8 +101,8 @@ fun RoomListTopBar( showAvatarIndicator = showAvatarIndicator, areSearchResultsDisplayed = areSearchResultsDisplayed, onOpenSettings = onOpenSettings, - onSearchClicked = onToggleSearch, - onMenuActionClicked = onMenuActionClicked, + onSearchClick = onToggleSearch, + onMenuActionClick = onMenuActionClick, scrollBehavior = scrollBehavior, displayMenuItems = displayMenuItems, displayFilters = displayFilters, @@ -119,8 +119,8 @@ private fun DefaultRoomListTopBar( areSearchResultsDisplayed: Boolean, scrollBehavior: TopAppBarScrollBehavior, onOpenSettings: () -> Unit, - onSearchClicked: () -> Unit, - onMenuActionClicked: (RoomListMenuAction) -> Unit, + onSearchClick: () -> Unit, + onMenuActionClick: (RoomListMenuAction) -> Unit, displayMenuItems: Boolean, displayFilters: Boolean, filtersState: RoomListFiltersState, @@ -211,7 +211,7 @@ private fun DefaultRoomListTopBar( actions = { if (displayMenuItems) { IconButton( - onClick = onSearchClicked, + onClick = onSearchClick, ) { Icon( imageVector = CompoundIcons.Search(), @@ -236,7 +236,7 @@ private fun DefaultRoomListTopBar( DropdownMenuItem( onClick = { showMenu = false - onMenuActionClicked(RoomListMenuAction.InviteFriends) + onMenuActionClick(RoomListMenuAction.InviteFriends) }, text = { Text(stringResource(id = CommonStrings.action_invite)) }, leadingIcon = { @@ -252,7 +252,7 @@ private fun DefaultRoomListTopBar( DropdownMenuItem( onClick = { showMenu = false - onMenuActionClicked(RoomListMenuAction.ReportBug) + onMenuActionClick(RoomListMenuAction.ReportBug) }, text = { Text(stringResource(id = CommonStrings.common_report_a_problem)) }, leadingIcon = { @@ -324,11 +324,11 @@ internal fun DefaultRoomListTopBarPreview() = ElementPreview { areSearchResultsDisplayed = false, scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()), onOpenSettings = {}, - onSearchClicked = {}, + onSearchClick = {}, displayMenuItems = true, displayFilters = true, filtersState = aRoomListFiltersState(), - onMenuActionClicked = {}, + onMenuActionClick = {}, ) } @@ -342,10 +342,10 @@ internal fun DefaultRoomListTopBarWithIndicatorPreview() = ElementPreview { areSearchResultsDisplayed = false, scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState()), onOpenSettings = {}, - onSearchClicked = {}, + onSearchClick = {}, displayMenuItems = true, displayFilters = true, filtersState = aRoomListFiltersState(), - onMenuActionClicked = {}, + onMenuActionClick = {}, ) } diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomSummaryRow.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomSummaryRow.kt index a14140297c..d90faef985 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomSummaryRow.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/components/RoomSummaryRow.kt @@ -105,10 +105,10 @@ internal fun RoomSummaryRow( } Spacer(modifier = Modifier.height(12.dp)) InviteButtonsRow( - onAcceptClicked = { + onAcceptClick = { eventSink(RoomListEvents.AcceptInvite(room)) }, - onDeclineClicked = { + onDeclineClick = { eventSink(RoomListEvents.DeclineInvite(room)) } ) @@ -299,8 +299,8 @@ private fun InviteNameAndIndicatorRow( @Composable private fun InviteButtonsRow( - onAcceptClicked: () -> Unit, - onDeclineClicked: () -> Unit, + onAcceptClick: () -> Unit, + onDeclineClick: () -> Unit, modifier: Modifier = Modifier ) { Row( @@ -309,13 +309,13 @@ private fun InviteButtonsRow( ) { OutlinedButton( text = stringResource(CommonStrings.action_decline), - onClick = onDeclineClicked, + onClick = onDeclineClick, size = ButtonSize.Medium, modifier = Modifier.weight(1f), ) Button( text = stringResource(CommonStrings.action_accept), - onClick = onAcceptClicked, + onClick = onAcceptClick, size = ButtonSize.Medium, modifier = Modifier.weight(1f), ) diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/filters/RoomListFiltersView.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/filters/RoomListFiltersView.kt index fcdc260c6e..545884c875 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/filters/RoomListFiltersView.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/filters/RoomListFiltersView.kt @@ -16,6 +16,9 @@ package io.element.android.features.roomlist.impl.filters +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -31,13 +34,15 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChipDefaults import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateOf +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.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex import io.element.android.compound.theme.ElementTheme import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.libraries.designsystem.preview.ElementPreview @@ -53,7 +58,7 @@ fun RoomListFiltersView( state: RoomListFiltersState, modifier: Modifier = Modifier ) { - fun onClearFiltersClicked() { + fun onClearFiltersClick() { state.eventSink(RoomListFiltersEvents.ClearSelectedFilters) } @@ -62,6 +67,7 @@ fun RoomListFiltersView( } val lazyListState = rememberLazyListState() + val previousFilters = remember { mutableStateOf(listOf()) } LazyRow( contentPadding = PaddingValues(start = 8.dp, end = 16.dp), modifier = modifier.fillMaxWidth(), @@ -75,28 +81,30 @@ fun RoomListFiltersView( modifier = Modifier .padding(start = 8.dp) .testTag(TestTags.homeScreenClearFilters), - onClick = ::onClearFiltersClicked + onClick = { + previousFilters.value = state.selectedFilters() + onClearFiltersClick() + } ) } } - for (filterWithSelection in state.filterSelectionStates) { + state.filterSelectionStates.forEachIndexed { i, filterWithSelection -> item(filterWithSelection.filter) { + val zIndex = (if (previousFilters.value.contains(filterWithSelection.filter)) state.filterSelectionStates.size else 0) - i.toFloat() RoomListFilterView( - modifier = Modifier.animateItemPlacement(), + modifier = Modifier + .animateItemPlacement() + .zIndex(zIndex), roomListFilter = filterWithSelection.filter, selected = filterWithSelection.isSelected, - onClick = ::onToggleFilter, + onClick = { + previousFilters.value = state.selectedFilters() + onToggleFilter(it) + }, ) } } } - LaunchedEffect(state.filterSelectionStates) { - // Checking for canScrollBackward is necessary for the itemPlacementAnimation to work correctly. - // We don't want the itemPlacementAnimation to be triggered when clearing the filters. - if (!state.hasAnyFilterSelected || lazyListState.canScrollBackward) { - lazyListState.animateScrollToItem(0) - } - } } @Composable @@ -126,16 +134,27 @@ private fun RoomListFilterView( onClick: (RoomListFilter) -> Unit, modifier: Modifier = Modifier ) { + val background = animateColorAsState( + targetValue = if (selected) ElementTheme.colors.bgActionPrimaryRest else ElementTheme.colors.bgCanvasDefault, + animationSpec = spring(stiffness = Spring.StiffnessMediumLow), + label = "chip background colour", + ) + val textColour = animateColorAsState( + targetValue = if (selected) ElementTheme.colors.textOnSolidPrimary else ElementTheme.colors.textPrimary, + animationSpec = spring(stiffness = Spring.StiffnessMediumLow), + label = "chip text colour", + ) + FilterChip( selected = selected, onClick = { onClick(roomListFilter) }, modifier = modifier.height(36.dp), shape = CircleShape, colors = FilterChipDefaults.filterChipColors( - containerColor = ElementTheme.colors.bgCanvasDefault, - selectedContainerColor = ElementTheme.colors.bgActionPrimaryRest, - labelColor = ElementTheme.colors.textPrimary, - selectedLabelColor = ElementTheme.colors.textOnSolidPrimary, + containerColor = background.value, + selectedContainerColor = background.value, + labelColor = textColour.value, + selectedLabelColor = textColour.value ), label = { Text(text = stringResource(id = roomListFilter.stringResource)) diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchView.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchView.kt index a18dd6607f..b8af9e16f1 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchView.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchView.kt @@ -72,8 +72,8 @@ import io.element.android.libraries.ui.strings.CommonStrings internal fun RoomListSearchView( state: RoomListSearchState, eventSink: (RoomListEvents) -> Unit, - onRoomClicked: (RoomId) -> Unit, - onRoomDirectorySearchClicked: () -> Unit, + onRoomClick: (RoomId) -> Unit, + onRoomDirectorySearchClick: () -> Unit, modifier: Modifier = Modifier, ) { BackHandler(enabled = state.isSearchActive) { @@ -87,17 +87,20 @@ internal fun RoomListSearchView( ) { Column( modifier = modifier - .applyIf(state.isSearchActive, ifTrue = { - // Disable input interaction to underlying views - pointerInput(Unit) {} - }) + .applyIf( + condition = state.isSearchActive, + ifTrue = { + // Disable input interaction to underlying views + pointerInput(Unit) {} + } + ) ) { if (state.isSearchActive) { RoomListSearchContent( state = state, - onRoomClicked = onRoomClicked, + onRoomClick = onRoomClick, eventSink = eventSink, - onRoomDirectorySearchClicked = onRoomDirectorySearchClicked, + onRoomDirectorySearchClick = onRoomDirectorySearchClick, ) } } @@ -109,17 +112,17 @@ internal fun RoomListSearchView( private fun RoomListSearchContent( state: RoomListSearchState, eventSink: (RoomListEvents) -> Unit, - onRoomClicked: (RoomId) -> Unit, - onRoomDirectorySearchClicked: () -> Unit, + onRoomClick: (RoomId) -> Unit, + onRoomDirectorySearchClick: () -> Unit, ) { val borderColor = MaterialTheme.colorScheme.tertiary val strokeWidth = 1.dp - fun onBackButtonPressed() { + fun onBackButtonClick() { state.eventSink(RoomListSearchEvents.ToggleSearchVisibility) } - fun onRoomClicked(room: RoomListRoomSummary) { - onRoomClicked(room.roomId) + fun onRoomClick(room: RoomListRoomSummary) { + onRoomClick(room.roomId) } Scaffold( topBar = { @@ -132,7 +135,7 @@ private fun RoomListSearchContent( strokeWidth = strokeWidth.value ) }, - navigationIcon = { BackButton(onClick = ::onBackButtonPressed) }, + navigationIcon = { BackButton(onClick = ::onBackButtonClick) }, title = { val filter = state.query val focusRequester = FocusRequester() @@ -186,7 +189,7 @@ private fun RoomListSearchContent( modifier = Modifier .fillMaxWidth() .padding(vertical = 24.dp, horizontal = 16.dp), - onClick = onRoomDirectorySearchClicked + onClick = onRoomDirectorySearchClick ) } LazyColumn( @@ -198,7 +201,7 @@ private fun RoomListSearchContent( ) { room -> RoomSummaryRow( room = room, - onClick = ::onRoomClicked, + onClick = ::onRoomClick, eventSink = eventSink, ) } @@ -237,8 +240,8 @@ private fun RoomDirectorySearchButton( internal fun RoomListSearchContentPreview(@PreviewParameter(RoomListSearchStateProvider::class) state: RoomListSearchState) = ElementPreview { RoomListSearchContent( state = state, - onRoomClicked = {}, + onRoomClick = {}, eventSink = {}, - onRoomDirectorySearchClicked = {}, + onRoomDirectorySearchClick = {}, ) } diff --git a/features/roomlist/impl/src/main/res/values-es/translations.xml b/features/roomlist/impl/src/main/res/values-es/translations.xml index 3873c335c5..0c3e47822d 100644 --- a/features/roomlist/impl/src/main/res/values-es/translations.xml +++ b/features/roomlist/impl/src/main/res/values-es/translations.xml @@ -13,8 +13,23 @@ "Crear una nueva conversación o sala" "Empieza enviando un mensaje a alguien." "Aún no hay chats." + "Favoritos" + "Puedes añadir un chat a tus favoritos en la configuración del chat. +Por ahora, puedes deseleccionar los filtros para ver tus otros chats" + "Aún no tienes chats favoritos" + "Prioridad baja" + "Puedes deseleccionar filtros para ver tus otros chats." + "No tienes chats para esta selección" "Personas" + "Todavía no tienes ningún mensaje directo" + "Salas" + "Todavía no estás en ninguna sala" + "No leídos" + "¡Felicidades! +¡No tienes ningún mensaje sin leer!" "Todos los chats" + "Marcar como leído" + "Marcar como no leído" "Parece que estás usando un nuevo dispositivo. Verifica que eres tú para acceder a tus mensajes cifrados." "Verifica que eres tú" diff --git a/features/roomlist/impl/src/main/res/values-it/translations.xml b/features/roomlist/impl/src/main/res/values-it/translations.xml index 349057e7c4..c44dbd07db 100644 --- a/features/roomlist/impl/src/main/res/values-it/translations.xml +++ b/features/roomlist/impl/src/main/res/values-it/translations.xml @@ -17,6 +17,8 @@ "Puoi aggiungere una conversazione ai tuoi preferiti nelle impostazioni della stessa. Per il momento, puoi deselezionare i filtri per vedere le altre conversazioni." "Non hai ancora conversazioni preferite" + "Inviti" + "Non hai nessun invito in sospeso." "Bassa priorità" "Puoi deselezionare i filtri per vedere le altre conversazioni." "Non hai conversazioni per questa selezione" diff --git a/features/roomlist/impl/src/main/res/values-ka/translations.xml b/features/roomlist/impl/src/main/res/values-ka/translations.xml new file mode 100644 index 0000000000..de6358f443 --- /dev/null +++ b/features/roomlist/impl/src/main/res/values-ka/translations.xml @@ -0,0 +1,18 @@ + + + "დარწმუნებული ხართ, რომ გსურთ, უარი თქვათ მოწვევაზე %1$s-ში?" + "მოწვევაზე უარის თქმა" + "დარწმუნებული ხართ, რომ გსურთ, უარი თქვათ ჩატზე %1$s-თან?" + "ჩატზე უარის თქვა" + "მოწვევები არ არის" + "%1$s (%2$s) მოგიწვიათ" + "ეს არის ერთჯერადი პროცესი, მადლობა ლოდინისთვის." + "თქვენი ანგარიშის კონფიგურაცია" + "ახალი საუბრისა ან ოთახის შექმნა" + "დაიწყეთ ვინმესთვის შეტყობინების გაგზავნით." + "არც ერთი ჩატი ჯერ არაა." + "ხალხი" + "ჩატები" + "როგორც ჩანს, ახალ მოწყობილობას იყენებთ. დაადასტურეთ სხვა მოწყობილობით თქვენს დაშიფრულ შეტყობინებებზე წვდომისთვის." + "დაადასტურეთ, რომ ეს თქვენ ხართ" + diff --git a/features/roomlist/impl/src/main/res/values-pt-rBR/translations.xml b/features/roomlist/impl/src/main/res/values-pt-rBR/translations.xml new file mode 100644 index 0000000000..2bb094c693 --- /dev/null +++ b/features/roomlist/impl/src/main/res/values-pt-rBR/translations.xml @@ -0,0 +1,38 @@ + + + "A tua cópia de segurança das conversas está atualmente dessincronizada. Tens de inserir a tua chave de recuperação para manteres o acesso à cópia." + "Insere a tua chave de recuperação" + "Tens a certeza que queres rejeitar o convite para %1$s?" + "Rejeitar conite" + "Tens a certeza que queres rejeitar esta conversa privada com %1$s?" + "Rejeitar conversa" + "Sem convites" + "%1$s (%2$s) convidou-te" + "Este processo só acontece uma única vez, obrigado por esperares." + "A configurar a tua conta…" + "Criar uma nova conversa ou sala" + "Começa por enviar uma mensagem a alguém." + "Ainda não tens conversas." + "Favoritas" + "Podes adicionar uma conversa às tuas favoritas nas suas configurações. +Por enquanto, podes anular a seleção dos filtros para veres as tuas outras conversas" + "Ainda não tens nenhuma conversa favorita" + "Convites" + "Não tens nenhum convite pendente." + "Prioridade baixa" + "Podes anular a seleção dos filtros para veres as tuas outras conversas" + "Não tens nenhuma conversa selecionada" + "Pessoas" + "Ainda não tens nenhuma MD (mensagem direta)" + "Salas" + "Ainda não estás em nenhuma sala" + "Por ler" + "Parabéns! +Não tens nenhuma mensagem por ler!" + "Conversas" + "Marcar como lida" + "Marcar como não lida" + "Consultar lista completa de salas" + "Parece que estás a utilizar um novo dispositivo. Verifica-o com um outro para poderes aceder às tuas mensagens cifradas." + "Verifica que és tu" + diff --git a/features/roomlist/impl/src/main/res/values-ro/translations.xml b/features/roomlist/impl/src/main/res/values-ro/translations.xml index b766caf8f0..a4e250070e 100644 --- a/features/roomlist/impl/src/main/res/values-ro/translations.xml +++ b/features/roomlist/impl/src/main/res/values-ro/translations.xml @@ -14,13 +14,25 @@ "Începeți prin a trimite mesaje cuiva." "Nu există încă discuții." "Favorite" + "Puteți adăuga un chat la preferințele dvs. în setările de chat. +Deocamdată, puteți deselecta filtrele pentru a vedea celelalte chat-uri" + "Încă nu aveți conversații preferate" + "Invitații" + "Nu aveți invitații în așteptare." "Prioritate scăzută" + "Puteți deselecta filtrele pentru a vedea celelalte chat-uri" + "Nu aveți chat-uri pentru această selecție" "Persoane" + "Încă nu aveți DM-uri" "Camere" + "Nu sunteți încă în nicio cameră" "Necitite" + "Felicitari! +Nu aveți mesaje necitite!" "Toate conversatiile" "Marcați ca citită" "Marcați ca necitită" + "Răsfoiți toate camerele" "Se pare că folosiți un dispozitiv nou. Verificați-vă identitatea cu un alt dispozitiv pentru a accesa mesajele dumneavoastră criptate." "Verificați că sunteți dumneavoastră" diff --git a/features/roomlist/impl/src/main/res/values-zh-rTW/translations.xml b/features/roomlist/impl/src/main/res/values-zh-rTW/translations.xml index 8d0ec972f7..6d01b67889 100644 --- a/features/roomlist/impl/src/main/res/values-zh-rTW/translations.xml +++ b/features/roomlist/impl/src/main/res/values-zh-rTW/translations.xml @@ -7,10 +7,13 @@ "正在設定您的帳號。" "建立新的對話或聊天室" "我的最愛" + "邀請" "夥伴" "聊天室" "未讀" "所有聊天室" + "標為已讀" + "標為未讀" "您似乎正在使用新的裝置。請使用另一個裝置進行驗證,以存取您的加密訊息。" "驗證這是您本人" diff --git a/features/roomlist/impl/src/main/res/values-zh/translations.xml b/features/roomlist/impl/src/main/res/values-zh/translations.xml new file mode 100644 index 0000000000..0fa5542b5d --- /dev/null +++ b/features/roomlist/impl/src/main/res/values-zh/translations.xml @@ -0,0 +1,38 @@ + + + "您的聊天备份当前不同步。您需要输入恢复密钥才能访问聊天备份。" + "输入您的恢复密钥" + "您确定要拒绝加入 %1$s 的邀请吗?" + "拒绝邀请" + "您确定要拒绝与 %1$s 开始私聊吗?" + "拒绝聊天" + "没有邀请" + "%1$s (%2$s)邀请了你" + "这是一个一次性的过程,感谢您的等待。" + "设置您的账户。" + "创建新的对话或聊天室" + "通过向某人发送消息来开始。" + "还没有聊天。" + "收藏夹" + "您可以在聊天设置中将聊天添加到收藏夹中。 +现在,你可以取消选择过滤器以查看你的其他对话。" + "您未收藏任何聊天" + "邀请" + "你没有任何待处理的邀请。" + "低优先级" + "您可以取消选择过滤器以查看其他对话" + "您没有关于此选项的聊天" + "人" + "目前您还没有私信" + "聊天室" + "您尚未进入任何聊天室" + "未读" + "恭喜! +你没有任何未读消息!" + "全部聊天" + "标记为已读" + "标记为未读" + "浏览所有房间" + "您似乎正在使用新设备。使用另一台设备进行验证以访问您的加密消息。" + "验证是你本人" + diff --git a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListContextMenuTest.kt b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListContextMenuTest.kt index a1c98c6874..d9ebec849d 100644 --- a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListContextMenuTest.kt +++ b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListContextMenuTest.kt @@ -41,7 +41,7 @@ class RoomListContextMenuTest { RoomListContextMenu( contextMenu = contextMenu, eventSink = eventsRecorder, - onRoomSettingsClicked = EnsureNeverCalledWithParam(), + onRoomSettingsClick = EnsureNeverCalledWithParam(), ) } rule.clickOn(R.string.screen_roomlist_mark_as_read) @@ -61,7 +61,7 @@ class RoomListContextMenuTest { RoomListContextMenu( contextMenu = contextMenu, eventSink = eventsRecorder, - onRoomSettingsClicked = EnsureNeverCalledWithParam(), + onRoomSettingsClick = EnsureNeverCalledWithParam(), ) } rule.clickOn(R.string.screen_roomlist_mark_as_unread) @@ -81,7 +81,7 @@ class RoomListContextMenuTest { RoomListContextMenu( contextMenu = contextMenu, eventSink = eventsRecorder, - onRoomSettingsClicked = EnsureNeverCalledWithParam(), + onRoomSettingsClick = EnsureNeverCalledWithParam(), ) } rule.clickOn(CommonStrings.action_leave_conversation) @@ -101,7 +101,7 @@ class RoomListContextMenuTest { RoomListContextMenu( contextMenu = contextMenu, eventSink = eventsRecorder, - onRoomSettingsClicked = EnsureNeverCalledWithParam(), + onRoomSettingsClick = EnsureNeverCalledWithParam(), ) } rule.clickOn(CommonStrings.action_leave_room) @@ -122,7 +122,7 @@ class RoomListContextMenuTest { RoomListContextMenu( contextMenu = contextMenu, eventSink = eventsRecorder, - onRoomSettingsClicked = callback, + onRoomSettingsClick = callback, ) } rule.clickOn(CommonStrings.common_settings) @@ -139,7 +139,7 @@ class RoomListContextMenuTest { RoomListContextMenu( contextMenu = contextMenu, eventSink = eventsRecorder, - onRoomSettingsClicked = callback, + onRoomSettingsClick = callback, ) } rule.clickOn(CommonStrings.common_favourite) diff --git a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt index 053b4e51a8..e682e6608d 100644 --- a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt +++ b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt @@ -140,7 +140,7 @@ class RoomListPresenterTests { }.test { val initialState = awaitItem() assertThat(initialState.showAvatarIndicator).isTrue() - sessionVerificationService.givenCanVerifySession(false) + sessionVerificationService.givenNeedsSessionVerification(false) encryptionService.emitBackupState(BackupState.ENABLED) val finalState = awaitItem() assertThat(finalState.showAvatarIndicator).isFalse() @@ -282,7 +282,7 @@ class RoomListPresenterTests { roomListService = roomListService, encryptionService = encryptionService, sessionVerificationService = FakeSessionVerificationService().apply { - givenCanVerifySession(false) + givenNeedsSessionVerification(false) }, syncService = FakeSyncService(initialState = SyncState.Running) ) diff --git a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListViewTest.kt b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListViewTest.kt index 7507ab466b..99045cd783 100644 --- a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListViewTest.kt +++ b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListViewTest.kt @@ -67,7 +67,7 @@ class RoomListViewTest { contentState = aRoomsContentState(securityBannerState = SecurityBannerState.RecoveryKeyConfirmation), eventSink = eventsRecorder, ), - onConfirmRecoveryKeyClicked = callback, + onConfirmRecoveryKeyClick = callback, ) rule.clickOn(CommonStrings.action_continue) } @@ -82,7 +82,7 @@ class RoomListViewTest { eventSink = eventsRecorder, contentState = anEmptyContentState(), ), - onCreateRoomClicked = callback, + onCreateRoomClick = callback, ) rule.clickOn(CommonStrings.action_start_chat) } @@ -100,7 +100,7 @@ class RoomListViewTest { ensureCalledOnceWithParam(room0.roomId) { callback -> rule.setRoomListView( state = state, - onRoomClicked = callback, + onRoomClick = callback, ) rule.onNodeWithText(room0.lastMessage!!.toString()).performClick() } @@ -133,7 +133,7 @@ class RoomListViewTest { ensureCalledOnceWithParam(room0) { callback -> rule.setRoomListView( state = state, - onRoomSettingsClicked = callback, + onRoomSettingsClick = callback, ) rule.clickOn(CommonStrings.common_settings) } @@ -160,24 +160,24 @@ class RoomListViewTest { private fun AndroidComposeTestRule.setRoomListView( state: RoomListState, - onRoomClicked: (RoomId) -> Unit = EnsureNeverCalledWithParam(), - onSettingsClicked: () -> Unit = EnsureNeverCalled(), - onConfirmRecoveryKeyClicked: () -> Unit = EnsureNeverCalled(), - onCreateRoomClicked: () -> Unit = EnsureNeverCalled(), - onRoomSettingsClicked: (RoomId) -> Unit = EnsureNeverCalledWithParam(), - onMenuActionClicked: (RoomListMenuAction) -> Unit = EnsureNeverCalledWithParam(), - onRoomDirectorySearchClicked: () -> Unit = EnsureNeverCalled(), + onRoomClick: (RoomId) -> Unit = EnsureNeverCalledWithParam(), + onSettingsClick: () -> Unit = EnsureNeverCalled(), + onConfirmRecoveryKeyClick: () -> Unit = EnsureNeverCalled(), + onCreateRoomClick: () -> Unit = EnsureNeverCalled(), + onRoomSettingsClick: (RoomId) -> Unit = EnsureNeverCalledWithParam(), + onMenuActionClick: (RoomListMenuAction) -> Unit = EnsureNeverCalledWithParam(), + onRoomDirectorySearchClick: () -> Unit = EnsureNeverCalled(), ) { setContent { RoomListView( state = state, - onRoomClicked = onRoomClicked, - onSettingsClicked = onSettingsClicked, - onConfirmRecoveryKeyClicked = onConfirmRecoveryKeyClicked, - onCreateRoomClicked = onCreateRoomClicked, - onRoomSettingsClicked = onRoomSettingsClicked, - onMenuActionClicked = onMenuActionClicked, - onRoomDirectorySearchClicked = onRoomDirectorySearchClicked, + onRoomClick = onRoomClick, + onSettingsClick = onSettingsClick, + onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick, + onCreateRoomClick = onCreateRoomClick, + onRoomSettingsClick = onRoomSettingsClick, + onMenuActionClick = onMenuActionClick, + onRoomDirectorySearchClick = onRoomDirectorySearchClick, acceptDeclineInviteView = { }, ) } diff --git a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchViewTest.kt b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchViewTest.kt index b3f0755f11..a50ba4fc26 100644 --- a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchViewTest.kt +++ b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/search/RoomListSearchViewTest.kt @@ -47,7 +47,7 @@ class RoomListSearchViewTest { isRoomDirectorySearchEnabled = true, eventSink = eventsRecorder, ), - onRoomDirectorySearchClicked = it, + onRoomDirectorySearchClick = it, ) rule.clickOn(R.string.screen_roomlist_room_directory_button_title) } @@ -57,15 +57,15 @@ class RoomListSearchViewTest { private fun AndroidComposeTestRule.setRoomListSearchView( state: RoomListSearchState, eventSink: (RoomListEvents) -> Unit = EventsRecorder(expectEvents = false), - onRoomClicked: (RoomId) -> Unit = EnsureNeverCalledWithParam(), - onRoomDirectorySearchClicked: () -> Unit = EnsureNeverCalled(), + onRoomClick: (RoomId) -> Unit = EnsureNeverCalledWithParam(), + onRoomDirectorySearchClick: () -> Unit = EnsureNeverCalled(), ) { setContent { RoomListSearchView( state = state, eventSink = eventSink, - onRoomClicked = onRoomClicked, - onRoomDirectorySearchClicked = onRoomDirectorySearchClicked, + onRoomClick = onRoomClick, + onRoomDirectorySearchClick = onRoomDirectorySearchClick, ) } } diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/SecureBackupFlowNode.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/SecureBackupFlowNode.kt index f696066181..e03aecad43 100644 --- a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/SecureBackupFlowNode.kt +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/SecureBackupFlowNode.kt @@ -87,23 +87,23 @@ class SecureBackupFlowNode @AssistedInject constructor( return when (navTarget) { NavTarget.Root -> { val callback = object : SecureBackupRootNode.Callback { - override fun onSetupClicked() { + override fun onSetupClick() { backstack.push(NavTarget.Setup) } - override fun onChangeClicked() { + override fun onChangeClick() { backstack.push(NavTarget.Change) } - override fun onDisableClicked() { + override fun onDisableClick() { backstack.push(NavTarget.Disable) } - override fun onEnableClicked() { + override fun onEnableClick() { backstack.push(NavTarget.Enable) } - override fun onConfirmRecoveryKeyClicked() { + override fun onConfirmRecoveryKeyClick() { backstack.push(NavTarget.EnterRecoveryKey) } } diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/createkey/CreateNewRecoveryKeyNode.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/createkey/CreateNewRecoveryKeyNode.kt index 33b52a4497..f1fbc6fe7e 100644 --- a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/createkey/CreateNewRecoveryKeyNode.kt +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/createkey/CreateNewRecoveryKeyNode.kt @@ -38,7 +38,7 @@ class CreateNewRecoveryKeyNode @AssistedInject constructor( CreateNewRecoveryKeyView( desktopApplicationName = buildMeta.desktopApplicationName, modifier = modifier, - onBackClicked = ::navigateUp, + onBackClick = ::navigateUp, ) } } diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/createkey/CreateNewRecoveryKeyView.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/createkey/CreateNewRecoveryKeyView.kt index 8974d1faa2..420c6f5675 100644 --- a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/createkey/CreateNewRecoveryKeyView.kt +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/createkey/CreateNewRecoveryKeyView.kt @@ -50,13 +50,13 @@ import io.element.android.libraries.designsystem.theme.components.TopAppBar @Composable fun CreateNewRecoveryKeyView( desktopApplicationName: String, - onBackClicked: () -> Unit, + onBackClick: () -> Unit, modifier: Modifier = Modifier, ) { Scaffold( modifier = modifier, topBar = { - TopAppBar(title = {}, navigationIcon = { BackButton(onClick = onBackClicked) }) + TopAppBar(title = {}, navigationIcon = { BackButton(onClick = onBackClick) }) } ) { padding -> Column( @@ -131,7 +131,7 @@ internal fun CreateNewRecoveryKeyViewPreview() { ElementPreview { CreateNewRecoveryKeyView( desktopApplicationName = "Element", - onBackClicked = {}, + onBackClick = {}, ) } } diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/disable/SecureBackupDisableNode.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/disable/SecureBackupDisableNode.kt index 3fd6559858..795f4d313e 100644 --- a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/disable/SecureBackupDisableNode.kt +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/disable/SecureBackupDisableNode.kt @@ -38,8 +38,8 @@ class SecureBackupDisableNode @AssistedInject constructor( SecureBackupDisableView( state = state, modifier = modifier, - onDone = ::navigateUp, - onBackClicked = ::navigateUp, + onSuccess = ::navigateUp, + onBackClick = ::navigateUp, ) } } diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/disable/SecureBackupDisableView.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/disable/SecureBackupDisableView.kt index 37fc3eb2be..845de55e0c 100644 --- a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/disable/SecureBackupDisableView.kt +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/disable/SecureBackupDisableView.kt @@ -43,19 +43,20 @@ import io.element.android.libraries.designsystem.theme.components.Text @Composable fun SecureBackupDisableView( state: SecureBackupDisableState, - onDone: () -> Unit, - onBackClicked: () -> Unit, + onSuccess: () -> Unit, + onBackClick: () -> Unit, modifier: Modifier = Modifier, ) { FlowStepPage( modifier = modifier, - onBackClicked = onBackClicked, + onBackClick = onBackClick, title = stringResource(id = R.string.screen_key_backup_disable_title), subTitle = stringResource(id = R.string.screen_key_backup_disable_description), iconVector = CompoundIcons.KeyOffSolid(), - content = { Content(state = state) }, buttons = { Buttons(state = state) }, - ) + ) { + Content(state = state) + } AsyncActionView( async = state.disableAction, @@ -68,7 +69,7 @@ fun SecureBackupDisableView( progressDialog = {}, errorMessage = { it.message ?: it.toString() }, onErrorDismiss = { state.eventSink.invoke(SecureBackupDisableEvents.DismissDialogs) }, - onSuccess = { onDone() }, + onSuccess = { onSuccess() }, ) } @@ -79,7 +80,7 @@ private fun SecureBackupDisableConfirmationDialog(onConfirm: () -> Unit, onDismi content = stringResource(id = R.string.screen_key_backup_disable_confirmation_description), submitText = stringResource(id = R.string.screen_key_backup_disable_confirmation_action_turn_off), destructiveSubmit = true, - onSubmitClicked = onConfirm, + onSubmitClick = onConfirm, onDismiss = onDismiss, ) } @@ -135,7 +136,7 @@ internal fun SecureBackupDisableViewPreview( ) = ElementPreview { SecureBackupDisableView( state = state, - onDone = {}, - onBackClicked = {}, + onSuccess = {}, + onBackClick = {}, ) } diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enable/SecureBackupEnableNode.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enable/SecureBackupEnableNode.kt index 804af27493..11e1b7a83a 100644 --- a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enable/SecureBackupEnableNode.kt +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enable/SecureBackupEnableNode.kt @@ -38,8 +38,8 @@ class SecureBackupEnableNode @AssistedInject constructor( SecureBackupEnableView( state = state, modifier = modifier, - onDone = ::navigateUp, - onBackClicked = ::navigateUp, + onSuccess = ::navigateUp, + onBackClick = ::navigateUp, ) } } diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enable/SecureBackupEnableView.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enable/SecureBackupEnableView.kt index 53748d3879..b2b1e2a396 100644 --- a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enable/SecureBackupEnableView.kt +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enable/SecureBackupEnableView.kt @@ -33,13 +33,13 @@ import io.element.android.libraries.designsystem.theme.components.Button @Composable fun SecureBackupEnableView( state: SecureBackupEnableState, - onDone: () -> Unit, - onBackClicked: () -> Unit, + onSuccess: () -> Unit, + onBackClick: () -> Unit, modifier: Modifier = Modifier, ) { FlowStepPage( modifier = modifier, - onBackClicked = onBackClicked, + onBackClick = onBackClick, title = stringResource(id = R.string.screen_chat_backup_key_backup_action_enable), iconVector = CompoundIcons.KeySolid(), buttons = { Buttons(state = state) } @@ -47,7 +47,7 @@ fun SecureBackupEnableView( AsyncActionView( async = state.enableAction, progressDialog = { }, - onSuccess = { onDone() }, + onSuccess = { onSuccess() }, onErrorDismiss = { state.eventSink.invoke(SecureBackupEnableEvents.DismissDialog) } ) } @@ -71,7 +71,7 @@ internal fun SecureBackupEnableViewPreview( ) = ElementPreview { SecureBackupEnableView( state = state, - onDone = {}, - onBackClicked = {}, + onSuccess = {}, + onBackClick = {}, ) } diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enter/SecureBackupEnterRecoveryKeyNode.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enter/SecureBackupEnterRecoveryKeyNode.kt index c80becb88a..2fd9067f78 100644 --- a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enter/SecureBackupEnterRecoveryKeyNode.kt +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enter/SecureBackupEnterRecoveryKeyNode.kt @@ -46,8 +46,8 @@ class SecureBackupEnterRecoveryKeyNode @AssistedInject constructor( SecureBackupEnterRecoveryKeyView( state = state, modifier = modifier, - onDone = callback::onEnterRecoveryKeySuccess, - onBackClicked = ::navigateUp, + onSuccess = callback::onEnterRecoveryKeySuccess, + onBackClick = ::navigateUp, onCreateNewRecoveryKey = callback::onCreateNewRecoveryKey ) } diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enter/SecureBackupEnterRecoveryKeyView.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enter/SecureBackupEnterRecoveryKeyView.kt index db23018c6f..f18d013014 100644 --- a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enter/SecureBackupEnterRecoveryKeyView.kt +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/enter/SecureBackupEnterRecoveryKeyView.kt @@ -38,14 +38,14 @@ import io.element.android.libraries.ui.strings.CommonStrings @Composable fun SecureBackupEnterRecoveryKeyView( state: SecureBackupEnterRecoveryKeyState, - onDone: () -> Unit, - onBackClicked: () -> Unit, + onSuccess: () -> Unit, + onBackClick: () -> Unit, onCreateNewRecoveryKey: () -> Unit, modifier: Modifier = Modifier, ) { AsyncActionView( async = state.submitAction, - onSuccess = { onDone() }, + onSuccess = { onSuccess() }, progressDialog = { }, errorTitle = { stringResource(id = R.string.screen_recovery_key_confirm_error_title) }, errorMessage = { stringResource(id = R.string.screen_recovery_key_confirm_error_content) }, @@ -54,13 +54,14 @@ fun SecureBackupEnterRecoveryKeyView( FlowStepPage( modifier = modifier, - onBackClicked = onBackClicked, + onBackClick = onBackClick, iconVector = CompoundIcons.KeySolid(), title = stringResource(id = R.string.screen_recovery_key_confirm_title), subTitle = stringResource(id = R.string.screen_recovery_key_confirm_description), - content = { Content(state = state) }, buttons = { Buttons(state = state, onCreateRecoveryKey = onCreateNewRecoveryKey) } - ) + ) { + Content(state = state) + } } @Composable @@ -109,8 +110,8 @@ internal fun SecureBackupEnterRecoveryKeyViewPreview( ) = ElementPreview { SecureBackupEnterRecoveryKeyView( state = state, - onDone = {}, - onBackClicked = {}, + onSuccess = {}, + onBackClick = {}, onCreateNewRecoveryKey = {}, ) } diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/root/SecureBackupRootNode.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/root/SecureBackupRootNode.kt index 2fa037860e..22a78f89b4 100644 --- a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/root/SecureBackupRootNode.kt +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/root/SecureBackupRootNode.kt @@ -40,34 +40,34 @@ class SecureBackupRootNode @AssistedInject constructor( plugins = plugins ) { interface Callback : Plugin { - fun onSetupClicked() - fun onChangeClicked() - fun onDisableClicked() - fun onEnableClicked() - fun onConfirmRecoveryKeyClicked() + fun onSetupClick() + fun onChangeClick() + fun onDisableClick() + fun onEnableClick() + fun onConfirmRecoveryKeyClick() } - private fun onSetupClicked() { - plugins().forEach { it.onSetupClicked() } + private fun onSetupClick() { + plugins().forEach { it.onSetupClick() } } - private fun onChangeClicked() { - plugins().forEach { it.onChangeClicked() } + private fun onChangeClick() { + plugins().forEach { it.onChangeClick() } } - private fun onDisableClicked() { - plugins().forEach { it.onDisableClicked() } + private fun onDisableClick() { + plugins().forEach { it.onDisableClick() } } - private fun onEnableClicked() { - plugins().forEach { it.onEnableClicked() } + private fun onEnableClick() { + plugins().forEach { it.onEnableClick() } } - private fun onConfirmRecoveryKeyClicked() { - plugins().forEach { it.onConfirmRecoveryKeyClicked() } + private fun onConfirmRecoveryKeyClick() { + plugins().forEach { it.onConfirmRecoveryKeyClick() } } - private fun onLearnMoreClicked(uriHandler: UriHandler) { + private fun onLearnMoreClick(uriHandler: UriHandler) { uriHandler.openUri(SecureBackupConfig.LEARN_MORE_URL) } @@ -77,13 +77,13 @@ class SecureBackupRootNode @AssistedInject constructor( val uriHandler = LocalUriHandler.current SecureBackupRootView( state = state, - onBackPressed = ::navigateUp, - onSetupClicked = ::onSetupClicked, - onChangeClicked = ::onChangeClicked, - onEnableClicked = ::onEnableClicked, - onDisableClicked = ::onDisableClicked, - onConfirmRecoveryKeyClicked = ::onConfirmRecoveryKeyClicked, - onLearnMoreClicked = { onLearnMoreClicked(uriHandler) }, + onBackClick = ::navigateUp, + onSetupClick = ::onSetupClick, + onChangeClick = ::onChangeClick, + onEnableClick = ::onEnableClick, + onDisableClick = ::onDisableClick, + onConfirmRecoveryKeyClick = ::onConfirmRecoveryKeyClick, + onLearnMoreClick = { onLearnMoreClick(uriHandler) }, modifier = modifier, ) } diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/root/SecureBackupRootView.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/root/SecureBackupRootView.kt index 754beb6d5f..a566fe15a7 100644 --- a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/root/SecureBackupRootView.kt +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/root/SecureBackupRootView.kt @@ -47,20 +47,20 @@ import io.element.android.libraries.ui.strings.CommonStrings @Composable fun SecureBackupRootView( state: SecureBackupRootState, - onBackPressed: () -> Unit, - onSetupClicked: () -> Unit, - onChangeClicked: () -> Unit, - onEnableClicked: () -> Unit, - onDisableClicked: () -> Unit, - onConfirmRecoveryKeyClicked: () -> Unit, - onLearnMoreClicked: () -> Unit, + onBackClick: () -> Unit, + onSetupClick: () -> Unit, + onChangeClick: () -> Unit, + onEnableClick: () -> Unit, + onDisableClick: () -> Unit, + onConfirmRecoveryKeyClick: () -> Unit, + onLearnMoreClick: () -> Unit, modifier: Modifier = Modifier, ) { val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage) PreferencePage( modifier = modifier, - onBackPressed = onBackPressed, + onBackClick = onBackClick, title = stringResource(id = CommonStrings.common_chat_backup), snackbarHost = { SnackbarHost(snackbarHostState) }, ) { @@ -74,7 +74,7 @@ fun SecureBackupRootView( PreferenceText( title = stringResource(id = R.string.screen_chat_backup_key_backup_title), subtitleAnnotated = text, - onClick = onLearnMoreClicked, + onClick = onLearnMoreClick, ) // Disable / Enable backup @@ -87,13 +87,13 @@ fun SecureBackupRootView( PreferenceText( title = stringResource(id = R.string.screen_chat_backup_key_backup_action_disable), tintColor = ElementTheme.colors.textCriticalPrimary, - onClick = onDisableClicked, + onClick = onDisableClick, ) } false -> { PreferenceText( title = stringResource(id = R.string.screen_chat_backup_key_backup_action_enable), - onClick = onEnableClicked, + onClick = onEnableClick, ) } } @@ -127,7 +127,7 @@ fun SecureBackupRootView( PreferenceText( title = stringResource(id = R.string.screen_chat_backup_key_backup_action_enable), - onClick = onEnableClicked, + onClick = onEnableClick, ) } } @@ -140,7 +140,7 @@ fun SecureBackupRootView( PreferenceText( title = stringResource(id = R.string.screen_chat_backup_key_backup_action_disable), tintColor = ElementTheme.colors.textCriticalPrimary, - onClick = onDisableClicked, + onClick = onDisableClick, ) } BackupState.DISABLING -> { @@ -158,14 +158,14 @@ fun SecureBackupRootView( PreferenceText( title = stringResource(id = R.string.screen_chat_backup_recovery_action_setup), subtitle = stringResource(id = R.string.screen_chat_backup_recovery_action_setup_description, state.appName), - onClick = onSetupClicked, + onClick = onSetupClick, showEndBadge = true, ) } RecoveryState.ENABLED -> { PreferenceText( title = stringResource(id = R.string.screen_chat_backup_recovery_action_change), - onClick = onChangeClicked, + onClick = onChangeClick, ) } RecoveryState.INCOMPLETE -> @@ -173,7 +173,7 @@ fun SecureBackupRootView( title = stringResource(id = R.string.screen_chat_backup_recovery_action_confirm), subtitle = stringResource(id = R.string.screen_chat_backup_recovery_action_confirm_description), showEndBadge = true, - onClick = onConfirmRecoveryKeyClicked, + onClick = onConfirmRecoveryKeyClick, ) } } @@ -186,12 +186,12 @@ internal fun SecureBackupRootViewPreview( ) = ElementPreview { SecureBackupRootView( state = state, - onBackPressed = {}, - onSetupClicked = {}, - onChangeClicked = {}, - onEnableClicked = {}, - onDisableClicked = {}, - onConfirmRecoveryKeyClicked = {}, - onLearnMoreClicked = {}, + onBackClick = {}, + onSetupClick = {}, + onChangeClick = {}, + onEnableClick = {}, + onDisableClick = {}, + onConfirmRecoveryKeyClick = {}, + onLearnMoreClick = {}, ) } diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupNode.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupNode.kt index 495b4a38c1..a01d638055 100644 --- a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupNode.kt +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupNode.kt @@ -55,11 +55,11 @@ class SecureBackupSetupNode @AssistedInject constructor( val state = presenter.present() SecureBackupSetupView( state = state, - onDone = { + onSuccess = { coroutineScope.postSuccessSnackbar() navigateUp() }, - onBackClicked = ::navigateUp, + onBackClick = ::navigateUp, modifier = modifier, ) } 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 03d4f80b97..2cefff29b4 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 @@ -42,26 +42,27 @@ import io.element.android.libraries.ui.strings.CommonStrings @Composable fun SecureBackupSetupView( state: SecureBackupSetupState, - onDone: () -> Unit, - onBackClicked: () -> Unit, + onSuccess: () -> Unit, + onBackClick: () -> Unit, modifier: Modifier = Modifier, ) { FlowStepPage( modifier = modifier, - onBackClicked = onBackClicked.takeIf { state.canGoBack() }, + onBackClick = onBackClick.takeIf { state.canGoBack() }, title = title(state), subTitle = subtitle(state), iconVector = CompoundIcons.KeySolid(), - content = { Content(state) }, - buttons = { Buttons(state, onDone = onDone) }, - ) + buttons = { Buttons(state, onFinish = onSuccess) }, + ) { + Content(state = state) + } if (state.showSaveConfirmationDialog) { ConfirmationDialog( title = stringResource(id = R.string.screen_recovery_key_setup_confirmation_title), content = stringResource(id = R.string.screen_recovery_key_setup_confirmation_description), submitText = stringResource(id = CommonStrings.action_continue), - onSubmitClicked = onDone, + onSubmitClick = onSuccess, onDismiss = { state.eventSink.invoke(SecureBackupSetupEvents.DismissDialog) } @@ -138,7 +139,7 @@ private fun Content( @Composable private fun ColumnScope.Buttons( state: SecureBackupSetupState, - onDone: () -> Unit, + onFinish: () -> Unit, ) { val context = LocalContext.current val chooserTitle = stringResource(id = R.string.screen_recovery_key_save_action) @@ -149,7 +150,7 @@ private fun ColumnScope.Buttons( text = stringResource(id = CommonStrings.action_done), enabled = false, modifier = Modifier.fillMaxWidth(), - onClick = onDone + onClick = onFinish ) } is SetupState.Created, @@ -172,7 +173,7 @@ private fun ColumnScope.Buttons( modifier = Modifier.fillMaxWidth(), onClick = { if (state.setupState is SetupState.CreatedAndSaved) { - onDone() + onFinish() } else { state.eventSink.invoke(SecureBackupSetupEvents.Done) } @@ -189,7 +190,7 @@ internal fun SecureBackupSetupViewPreview( ) = ElementPreview { SecureBackupSetupView( state = state, - onDone = {}, - onBackClicked = {}, + onSuccess = {}, + onBackClick = {}, ) } diff --git a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupViewChangePreview.kt b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupViewChangePreview.kt index 31efdfa3a1..cff8cb4347 100644 --- a/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupViewChangePreview.kt +++ b/features/securebackup/impl/src/main/kotlin/io/element/android/features/securebackup/impl/setup/SecureBackupSetupViewChangePreview.kt @@ -32,7 +32,7 @@ internal fun SecureBackupSetupViewChangePreview( isChangeRecoveryKeyUserStory = true, recoveryKeyViewState = state.recoveryKeyViewState.copy(recoveryKeyUserStory = RecoveryKeyUserStory.Change), ), - onDone = {}, - onBackClicked = {}, + onSuccess = {}, + onBackClick = {}, ) } diff --git a/features/securebackup/impl/src/main/res/values-fr/translations.xml b/features/securebackup/impl/src/main/res/values-fr/translations.xml index 824e786cc4..6296e0d7ac 100644 --- a/features/securebackup/impl/src/main/res/values-fr/translations.xml +++ b/features/securebackup/impl/src/main/res/values-fr/translations.xml @@ -48,7 +48,7 @@ "Avez-vous sauvegardé votre clé de récupération?" "Votre sauvegarde est protégée par votre clé de récupération. Si vous avez besoin d’une nouvelle clé après la configuration, vous pourrez en créer une nouvelle en cliquant sur \"Changer la clé de récupération\"." "Générer la clé de récupération" - "Assurez-vous de pouvoir enregistrer votre clé dans un endroit sécurisé" + "Assurez-vous de conserver la clé dans un endroit sûr" "Sauvegarde mise en place avec succès" "Configurer la sauvegarde" diff --git a/features/securebackup/impl/src/main/res/values-it/translations.xml b/features/securebackup/impl/src/main/res/values-it/translations.xml index ba2948e8a5..7b2a40341a 100644 --- a/features/securebackup/impl/src/main/res/values-it/translations.xml +++ b/features/securebackup/impl/src/main/res/values-it/translations.xml @@ -9,6 +9,13 @@ "Il backup delle conversazioni non è attualmente sincronizzato." "Configura il recupero" "Ottieni l\'accesso ai tuoi messaggi cifrati se perdi tutti i tuoi dispositivi o se sei disconnesso da %1$s ovunque." + "Apri %1$s in un dispositivo desktop" + "Accedi nuovamente al tuo account" + "Quando ti viene chiesto di verificare il tuo dispositivo, seleziona %1$s" + "“Reimposta tutto”" + "Segui le istruzioni per creare una nuova chiave di recupero" + "Salva la tua nuova chiave di recupero in un gestore di password o in una nota cifrata." + "Reimposta la crittografia del tuo account utilizzando un altro dispositivo" "Disattiva" "Perderai i tuoi messaggi cifrati se sei disconnesso da tutti i dispositivi." "Vuoi davvero disattivare il backup?" @@ -21,13 +28,16 @@ "Assicurati di conservare la chiave di recupero in un posto sicuro" "Chiave di recupero cambiata" "Cambiare la chiave di recupero?" + "Crea una nuova chiave di recupero" "Assicurati che nessuno possa vedere questa schermata!" "Riprova per confermare l\'accesso al backup della chat." "Chiave di recupero errata" "Se hai una chiave di sicurezza o una password, andrà bene anche questo." + "Chiave di recupero o codice di accesso" "Inserisci…" + "Hai perso la chiave di recupero?" "Chiave di recupero confermata" - "Inserisci la chiave o password di recupero" + "Inserisci la tua chiave di recupero" "Chiave di recupero copiata" "Generazione…" "Salva la chiave di recupero" diff --git a/features/securebackup/impl/src/main/res/values-ka/translations.xml b/features/securebackup/impl/src/main/res/values-ka/translations.xml new file mode 100644 index 0000000000..e31153859c --- /dev/null +++ b/features/securebackup/impl/src/main/res/values-ka/translations.xml @@ -0,0 +1,40 @@ + + + "სარეზერვო ასლის გამორთვა" + "სარეზერვო ასლის ჩართვა" + "სარეზერვო ასლი უზრუნველყოფს იმას, რომ თქვენ შეტყობინებების ისტორიას არ დაკარგავთ. %1$s" + "სარეზერვო ასლი" + "აღდგენის გასაღების შეცვლა" + "შეიყვანეთ აღდგენის გასაღები" + "თქვენი ჩატის სარეზერვო ასლი ამჟამად არ არის სინქრონიზებული." + "აღდგენის დაყენება" + "მიიღეთ წვდომა თქვენს დაშიფრულ შეტყობინებებზე, თუ დაკარგავთ თქვენს ყველა მოწყობილობას ან გამოხვალთ სისტემიდან %1$s-დან ყველგან." + "გამორთვა" + "თქვენ დაკარგავთ დაშიფრულ შეტყობინებებს, თუ ყველა მოწყობილობიდან გამოხვალთ." + "დარწმუნებული ხართ, რომ გსურთ გამორთოთ სარეზერვო ასლი?" + "სარეზერვო ასლის გამორთვა წაშლის თქვენი მიმდინარე დაშიფვრის გასაღების სარეზერვო ასლს და გამორთავს უსაფრთხოების სხვა ფუნქციებს. ამ შემთხვევაში, თქვენ:" + "არ გექნებათ დაშიფვრული შეტყობინებების ისტორია ახალ მოწყობილობებზე" + "დაკარვავთ წვდომას დაშიფრულ შეტყობინებებზე თუ ყველგან გამოხვალთ %1$s-დან" + "დარწმუნებული ხართ, რომ გსურთ გამორთოთ სარეზერვო ასლი?" + "მიიღეთ ახალი აღდგენის გასაღები, თუ დაკარგეთ არსებული. აღდგენის გასაღების შეცვლის შემდეგ, ძველი აღარ იმუშავებს." + "ახალი აღდგენის გასაღების შექმნა" + "დარწმუნდით, რომ შეგიძლიათ შეინახოთ თქვენი აღდგენის გასაღები სადმე უსაფრთხო ადგილას" + "აღდგენის გასაღები შეიცვალა" + "გსურთ აღდგენის გასაღების შეცვლა?" + "დარწმუნდით, რომ ვერავინ ხედავს ამ ეკრანს!" + "თუ თქვენ გაქვთ უსაფრთხოების გასაღები ან უსაფრთხოების ფრაზა, ეს ასევე იმუშავებს." + "შეყვანა" + "აღდგენის გასაღები დადასტურებულია" + "შეიყვანეთ თქვენი აღდგენის გასაღები" + "აღდგენის გასაღების შენახვა" + "ჩაწერეთ თქვენი აღდგენის გასაღები სადმე უსაფრთხო ადგილას ან შეინახეთ პაროლის მენეჯერში." + "აღდგენის გასაღების დასაკოპირებლად, დააწკაპუნეთ" + "შეინახეთ აღდგენის გასაღები" + "თქვენ ვერ შეძლებთ წვდომას თქვენი ახალი აღდგენის გასაღებზე ამ ნაბიჯის შემდეგ." + "შეინახეთ თქვენი აღდგენის გასაღები?" + "თქვენი ჩატის სარეზერვო ასლი დაცულია აღდგენის გასაღებით. თუ დაყენების შემდეგ გჭირდებათ ახალი აღდგენის გასაღები, შეგიძლიათ ხელახლა შექმნათ „აღდგენის გასაღების შეცვლის“ არჩევით." + "შექმენით აღდგენის გასაღები" + "დარწმუნდით, რომ შეგიძლიათ შეინახოთ თქვენი აღდგენის გასაღები სადმე უსაფრთხო ადგილას" + "აღდგენის დაყენება წარმატებით დასრულდა" + "აღდგენის დაყენება" + diff --git a/features/securebackup/impl/src/main/res/values-pt-rBR/translations.xml b/features/securebackup/impl/src/main/res/values-pt-rBR/translations.xml new file mode 100644 index 0000000000..274de6d6af --- /dev/null +++ b/features/securebackup/impl/src/main/res/values-pt-rBR/translations.xml @@ -0,0 +1,54 @@ + + + "Desativar a cópia de segurança" + "Ativar a cópia de segurança" + "A cópia de segurança garante que não perdes o teu histórico de mensagens. %1$s." + "Cópia de segurança" + "Alterar chave de recuperação" + "Inserir chave de recuperação" + "A tua cópia de segurança das conversas está atualmente dessincronizada." + "Configurar recuperação" + "Obtém acesso às tuas mensagens cifradas mesmo se perderes todos os teus dispositivos ou se terminares todas as tuas sessões %1$s." + "Abre a %1$s num computador" + "Inicia sessão novamente" + "Quando te for pedido para verificares o teu dispositivo, seleciona %1$s" + "“Repor tudo”" + "Segue as instruções para criar uma nova chave de recuperação" + "Guarda a tua nova chave de recuperação num gestor de senhas ou numa nota cifrada" + "Repor a cifragem da tua conta utilizando outro dispositivo" + "Desligar" + "Perderás as tuas mensagens cifradas se tiveres terminado a sessão em todos os teus dispositivos." + "Tens a certeza que queres desativar a cópia de segurança?" + "Desativar a cópia de segurança irá remover a atual cópia da chave de cifragem e desativar outras funcionalidades de segurança. Neste caso, irá:" + "O histórico de mensagens cifradas em novos dispositivos" + "Acesso às tuas mensagens cifradas se terminares todas as sessões %1$s" + "Tens a certeza que queres desativar a cópia de segurança?" + "Obtém uma nova chave de recuperação se tiveres perdido a atual. Depois de a alterares, a antiga deixará de funcionar." + "Gerar uma nova chave de recuperação" + "Certifica-te de que podes guardar a tua chave de recuperação num local seguro" + "Chave de recuperação alterada" + "Alterar a chave de recuperação?" + "Criar nova chave de recuperação" + "Certifica-te de que ninguém consegue ver esta página!" + "Por favor, tenta novamente para confirmar o acesso à tua cópia de segurança das conversas." + "Chave de recuperação incorreta" + "Também funciona se tiveres uma chave ou frase de segurança." + "Chave ou código de recuperação" + "Inserir…" + "Perdeste a tua chave de recuperação?" + "Chave de recuperação confirmada" + "Insere a tua chave de recuperação" + "Chave de recuperação copiada" + "A gerar…" + "Guardar chave de recuperação" + "Anota a tua chave de recuperação num local seguro ou guarda-a num gestor de senhas." + "Toca para copiar a chave de recuperação" + "Guarda a tua chave de recuperação" + "Não poderás aceder à tua nova chave de recuperação após este passo." + "Guardaste a tua chave de recuperação?" + "A tua cópia de segurança das conversas está protegida por uma chave de recuperação. Se precisares de uma nova chave após a configuração, podes recriá-la selecionando \"Alterar chave de recuperação\"." + "Gerar a tua chave de recuperação" + "Certifica-te de que podes guardar a tua chave de recuperação num local seguro" + "Recuperação configurada com sucesso" + "Configurar recuperação" + diff --git a/features/securebackup/impl/src/main/res/values-ro/translations.xml b/features/securebackup/impl/src/main/res/values-ro/translations.xml index 5bc6e44d55..6b2f2e31dc 100644 --- a/features/securebackup/impl/src/main/res/values-ro/translations.xml +++ b/features/securebackup/impl/src/main/res/values-ro/translations.xml @@ -9,6 +9,13 @@ "Backup-ul pentru chat nu este sincronizat în prezent." "Configurați recuperarea" "Obțineți acces la mesajele dumneavoastră criptate dacă vă pierdeți toate dispozitivele sau sunteți deconectat de la %1$s peste tot." + "Deschideți %1$s pe un dispozitiv desktop" + "Conectați-vă din nou la contul dumneavoastră" + "Când vi se cere să vă verificați dispozitivul, selectați%1$s" + "„Resetați tot”" + "Urmați instrucțiunile pentru a crea o nouă cheie de recuperare" + "Salvați noua cheie de recuperare într-un manager de parole sau o notă criptată" + "Resetați criptarea contului dumneavoastră folosind un alt dispozitiv" "Dezactivare" "Veți pierde mesajele criptate dacă sunteți deconectat de pe toate dispozitivele." "Sunteți sigur că doriți să dezactivați backup-ul?" @@ -21,11 +28,14 @@ "Asigurați-vă că puteți stoca cheia de recuperare undeva în siguranță" "Cheia de recuperare a fost schimbată" "Schimbați cheia de recuperare?" - "Introduceți cheia de recuperare pentru a confirma accesul la backup." + "Creați o nouă cheie de recuperare" + "Asigurați-vă că nimeni nu poate vedea acest ecran!" "Vă rugăm să încercați din nou să confirmați accesul la backup." "Cheie de recuperare incorectă" - "Introduceți codul de 48 de caractere." + "Dacă aveți o cheie de securitate sau o frază de securitate, aceasta va funcționa și ea." + "Cheie de recuperare sau cod de acces" "Introduceți…" + "Ați pierdut cheia de recuperare?" "Cheia de recuperare confirmată" "Confirmați cheia de recuperare" "Cheia de recuperare copiată" diff --git a/features/securebackup/impl/src/main/res/values-ru/translations.xml b/features/securebackup/impl/src/main/res/values-ru/translations.xml index b311575c4b..6a46ab3797 100644 --- a/features/securebackup/impl/src/main/res/values-ru/translations.xml +++ b/features/securebackup/impl/src/main/res/values-ru/translations.xml @@ -60,6 +60,7 @@ " или пароль" "Вход…" + "Потеряли ключ восстановления?" "Ключ восстановления" " подтвержден" diff --git a/features/securebackup/impl/src/main/res/values-uk/translations.xml b/features/securebackup/impl/src/main/res/values-uk/translations.xml index 236e7e258a..2a5b029a6b 100644 --- a/features/securebackup/impl/src/main/res/values-uk/translations.xml +++ b/features/securebackup/impl/src/main/res/values-uk/translations.xml @@ -12,9 +12,9 @@ "Вимкнути" "Ви втратите зашифровані повідомлення, якщо вийдете з усіх пристроїв." "Ви впевнені, що хочете вимкнути резервне копіювання?" - "Вимкнення резервного копіювання призведе до видалення поточної резервної копії ключа шифрування та вимкнення інших функцій безпеки. В цьому випадку ви будете:" - "Не мати зашифрованої історії повідомлень на нових пристроях" - "Втратите доступ до зашифрованих повідомлень, якщо ви вийдете з усіх %1$s сеансів" + "Вимкнення резервного копіювання призведе до видалення поточної резервної копії ключа шифрування та вимкнення інших функцій безпеки. В такому разі ви:" + "Не матимете історії зашифрованих повідомлень на нових пристроях" + "Втратите доступ до зашифрованих повідомлень, якщо вийдете з усіх сеансів %1$s" "Ви впевнені, що хочете вимкнути резервне копіювання?" "Отримайте новий ключ відновлення, якщо ви втратили існуючий ключ. Після зміни ключа відновлення ваш старий більше не буде працювати." "Згенерувати новий ключ відновлення" diff --git a/features/securebackup/impl/src/main/res/values-zh/translations.xml b/features/securebackup/impl/src/main/res/values-zh/translations.xml new file mode 100644 index 0000000000..a984d72208 --- /dev/null +++ b/features/securebackup/impl/src/main/res/values-zh/translations.xml @@ -0,0 +1,54 @@ + + + "关闭备份" + "开启备份" + "备份可确保你不会丢失消息历史记录。%1$s。" + "备份" + "更改恢复密钥" + "输入恢复密钥" + "您的聊天备份当前不同步。" + "设置恢复密钥" + "在丢失或从 %1$s 登出所有设备的情况下访问加密消息。" + "在桌面设备中打开 %1$s" + "再次登录您的账户" + "当要求验证您的设备时,选择 %1$s" + "「全部重置」" + "按照说明创建新的恢复密钥" + "将新的恢复密钥保存在密码管理器或加密备忘录中" + "使用其他设备重置账户的加密" + "关闭" + "如果您登出所有设备,您的加密消息将丢失。" + "您确定要关闭备份吗?" + "关闭备份将删除您当前的加密密钥备份并关闭其他安全功能。在这种情况下,你将:" + "新设备上没有加密消息的历史记录" + "如果您在所有设备上登出了 %1$s,那将无法访问加密的消息" + "您确定要关闭备份吗?" + "如果您丢失了现有的恢复密钥,请获取新的恢复密钥。更改恢复密钥后,您的旧密钥将不再起作用。" + "生成新的恢复密钥" + "确保您可以将恢复密钥存储在安全的地方" + "恢复密钥已更改" + "更改恢复密钥?" + "创建新的恢复密钥" + "确保没有人能看到这个界面!" + "请重试以访问您的聊天备份。" + "恢复密钥不正确" + "如果您有安全密钥或安全短语,也可以用。" + "恢复密钥或密码" + "输入……" + "丢失了恢复密钥?" + "恢复密钥已确认" + "输入您的恢复密钥" + "恢复密钥已复制" + "正在生成……" + "保存恢复密钥" + "在安全的地方写下恢复密钥或将其保存在密码管理器中。" + "点击复制恢复密钥" + "保存您的恢复密钥" + "完成此步骤后,您将无法访问新的恢复密钥。" + "您保存了恢复密钥吗?" + "您的聊天备份受恢复密钥保护。如果您在安装后需要新的恢复密钥,则可以通过选择「更改恢复密钥」来重新创建。" + "生成恢复密钥" + "确保将恢复密钥存储在安全的地方" + "恢复设置成功" + "设置恢复" + diff --git a/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/enter/SecureBackupEnterRecoveryKeyViewTest.kt b/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/enter/SecureBackupEnterRecoveryKeyViewTest.kt index f074116af1..c42847a323 100644 --- a/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/enter/SecureBackupEnterRecoveryKeyViewTest.kt +++ b/features/securebackup/impl/src/test/kotlin/io/element/android/features/securebackup/impl/enter/SecureBackupEnterRecoveryKeyViewTest.kt @@ -39,22 +39,22 @@ class SecureBackupEnterRecoveryKeyViewTest { @get:Rule val rule = createAndroidComposeRule() @Test - fun `back key pressed - calls onBackClicked`() { + fun `back key pressed - calls onBackClick`() { ensureCalledOnce { callback -> rule.setSecureBackupEnterRecoveryKeyView( aSecureBackupEnterRecoveryKeyState(), - onBackClicked = callback, + onBackClick = callback, ) rule.pressBackKey() } } @Test - fun `back button clicked - calls onBackClicked`() { + fun `back button clicked - calls onBackClick`() { ensureCalledOnce { callback -> rule.setSecureBackupEnterRecoveryKeyView( aSecureBackupEnterRecoveryKeyState(), - onBackClicked = callback, + onBackClick = callback, ) rule.pressBack() } @@ -95,14 +95,14 @@ class SecureBackupEnterRecoveryKeyViewTest { private fun AndroidComposeTestRule.setSecureBackupEnterRecoveryKeyView( state: SecureBackupEnterRecoveryKeyState, onDone: () -> Unit = EnsureNeverCalled(), - onBackClicked: () -> Unit = EnsureNeverCalled(), + onBackClick: () -> Unit = EnsureNeverCalled(), onCreateNewRecoveryKey: () -> Unit = EnsureNeverCalled(), ) { rule.setContent { SecureBackupEnterRecoveryKeyView( state = state, - onDone = onDone, - onBackClicked = onBackClicked, + onSuccess = onDone, + onBackClick = onBackClick, onCreateNewRecoveryKey = onCreateNewRecoveryKey ) } diff --git a/features/signedout/impl/src/main/res/values-ka/translations.xml b/features/signedout/impl/src/main/res/values-ka/translations.xml new file mode 100644 index 0000000000..37c86149ed --- /dev/null +++ b/features/signedout/impl/src/main/res/values-ka/translations.xml @@ -0,0 +1,8 @@ + + + "თქვენ პაროლი შეცვალეთ სხვა სესიაში" + "თქვენ სესია წაშალეთ სხვა სესიიდან" + "თქვენი სერვერის ადმინისტრატორმა გააუქმა თქვენი წვდომა" + "ალბათ, თქვენ გამოხვედით ქვემოთ ჩამოთვლილი ერთ-ერთი მიზეზის გამო. გთხოვთ, შეხვიდეთ ანგარიშში, რათა გააგრძელოთ %s-ს გამოყენება." + "თქვენ ანგარიშიდან გამოსული ხართ" + diff --git a/features/signedout/impl/src/main/res/values-pt-rBR/translations.xml b/features/signedout/impl/src/main/res/values-pt-rBR/translations.xml new file mode 100644 index 0000000000..d7e6ad8948 --- /dev/null +++ b/features/signedout/impl/src/main/res/values-pt-rBR/translations.xml @@ -0,0 +1,8 @@ + + + "Alteraste a tua senha noutra sessão" + "Apagaste esta sessão a partir de outra" + "O administrador do teu servidor invalidou o teu acesso" + "A tua sessão pode ter sido terminada por um dos motivos indicados abaixo. Inicia sessão novamente para continuares a utilizar a %s." + "Não tens sessão iniciada" + diff --git a/features/signedout/impl/src/main/res/values-zh/translations.xml b/features/signedout/impl/src/main/res/values-zh/translations.xml new file mode 100644 index 0000000000..87c7620d98 --- /dev/null +++ b/features/signedout/impl/src/main/res/values-zh/translations.xml @@ -0,0 +1,8 @@ + + + "你在另一个会话中更改了密码" + "你已从其他会话中删除本会话" + "您的服务器管理员已禁止您访问" + "您可能因下列原因而被登出。请重新登录以继续使用 %s。" + "你已登出" + diff --git a/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/root/UserProfileNode.kt b/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/root/UserProfileNode.kt index 4d4ea993c4..d2fa42183b 100644 --- a/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/root/UserProfileNode.kt +++ b/features/userprofile/impl/src/main/kotlin/io/element/android/features/userprofile/impl/root/UserProfileNode.kt @@ -89,7 +89,7 @@ class UserProfileNode @AssistedInject constructor( modifier = modifier, goBack = this::navigateUp, onShareUser = ::onShareUser, - onDmStarted = ::onStartDM, + onOpenDm = ::onStartDM, onStartCall = callback::onStartCall, openAvatarPreview = callback::openAvatarPreview, ) diff --git a/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileView.kt b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileView.kt index 3d5ae4a66c..f147798b19 100644 --- a/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileView.kt +++ b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/UserProfileView.kt @@ -47,7 +47,7 @@ import io.element.android.libraries.ui.strings.CommonStrings fun UserProfileView( state: UserProfileState, onShareUser: () -> Unit, - onDmStarted: (RoomId) -> Unit, + onOpenDm: (RoomId) -> Unit, onStartCall: (RoomId) -> Unit, goBack: () -> Unit, openAvatarPreview: (username: String, url: String) -> Unit, @@ -96,7 +96,7 @@ fun UserProfileView( progressText = stringResource(CommonStrings.common_starting_chat), ) }, - onSuccess = onDmStarted, + onSuccess = onOpenDm, errorMessage = { stringResource(R.string.screen_start_chat_error_starting_chat) }, onRetry = { state.eventSink(UserProfileEvents.StartDM) }, onErrorDismiss = { state.eventSink(UserProfileEvents.ClearStartDMState) }, @@ -114,7 +114,7 @@ internal fun UserProfileViewPreview( state = state, onShareUser = {}, goBack = {}, - onDmStarted = {}, + onOpenDm = {}, onStartCall = {}, openAvatarPreview = { _, _ -> } ) diff --git a/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/blockuser/BlockUserDialogs.kt b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/blockuser/BlockUserDialogs.kt index 3e7aeff512..7f671c397d 100644 --- a/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/blockuser/BlockUserDialogs.kt +++ b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/blockuser/BlockUserDialogs.kt @@ -63,7 +63,7 @@ private fun BlockConfirmationDialog( title = stringResource(R.string.screen_dm_details_block_user), content = stringResource(R.string.screen_dm_details_block_alert_description), submitText = stringResource(R.string.screen_dm_details_block_alert_action), - onSubmitClicked = onBlockAction, + onSubmitClick = onBlockAction, onDismiss = onDismiss ) } @@ -77,7 +77,7 @@ private fun UnblockConfirmationDialog( title = stringResource(R.string.screen_dm_details_unblock_user), content = stringResource(R.string.screen_dm_details_unblock_alert_description), submitText = stringResource(R.string.screen_dm_details_unblock_alert_action), - onSubmitClicked = onUnblockAction, + onSubmitClick = onUnblockAction, onDismiss = onDismiss ) } 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 6ad1bf8484..424219158a 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 @@ -45,7 +45,7 @@ fun BlockUserSection( ) { PreferenceCategory( modifier = modifier, - showDivider = false, + showTopDivider = false, ) { when (state.isBlocked) { is AsyncData.Failure -> PreferenceBlockUser(isBlocked = state.isBlocked.prevData, isLoading = false, eventSink = state.eventSink) diff --git a/features/userprofile/shared/src/main/res/values-ka/translations.xml b/features/userprofile/shared/src/main/res/values-ka/translations.xml new file mode 100644 index 0000000000..0054f19946 --- /dev/null +++ b/features/userprofile/shared/src/main/res/values-ka/translations.xml @@ -0,0 +1,10 @@ + + + "დაბლოკვა" + "დაბლოკილი მომხმარებლები ვერ შეძლებენ თქვენთვის შეტყობინების გაგზავნას და ყველა მათი შეტყობინება თქვენთვის დამალული იქნება. თქვენ მათი განბლოკვა ნებისმეირ დროს შეგიძლიათ." + "მომხმარებლის დაბლოკვა" + "განბლოკვა" + "თქვენ კვლავ შეძლებთ მათგან ყველა შეტყობინების ნახვას." + "Მომხმარებლის განბლოკვა" + "ჩატის დაწყების მცდელობისას შეცდომა მოხდა" + diff --git a/features/userprofile/shared/src/main/res/values-pt-rBR/translations.xml b/features/userprofile/shared/src/main/res/values-pt-rBR/translations.xml new file mode 100644 index 0000000000..df2ca21681 --- /dev/null +++ b/features/userprofile/shared/src/main/res/values-pt-rBR/translations.xml @@ -0,0 +1,10 @@ + + + "Bloquear" + "Os utilizadores bloqueados não poderão enviar-te mensagens e todas as suas mensagens ficarão ocultas. Podes desbloqueá-los em qualquer altura." + "Bloquear utilizador" + "Desbloquear" + "Poderás voltar a ver todas as suas mensagens." + "Desbloquear utilizador" + "Ocorreu um erro ao tentar iniciar uma conversa" + diff --git a/features/userprofile/shared/src/main/res/values-zh/translations.xml b/features/userprofile/shared/src/main/res/values-zh/translations.xml new file mode 100644 index 0000000000..d70689b326 --- /dev/null +++ b/features/userprofile/shared/src/main/res/values-zh/translations.xml @@ -0,0 +1,10 @@ + + + "封禁" + "被封禁的用户无法给你发消息,并且他们的消息会被隐藏。你可以随时解封。" + "封禁用户" + "解封" + "你可以重新接收他们的消息。" + "解封用户" + "在开始聊天时发生了错误" + diff --git a/features/userprofile/shared/src/test/kotlin/io/element/android/features/userprofile/UserProfileViewTest.kt b/features/userprofile/shared/src/test/kotlin/io/element/android/features/userprofile/UserProfileViewTest.kt index 6cc5e229e5..04fc36da48 100644 --- a/features/userprofile/shared/src/test/kotlin/io/element/android/features/userprofile/UserProfileViewTest.kt +++ b/features/userprofile/shared/src/test/kotlin/io/element/android/features/userprofile/UserProfileViewTest.kt @@ -226,7 +226,7 @@ private fun AndroidComposeTestRule.setUserP UserProfileView( state = state, onShareUser = onShareUser, - onDmStarted = onDmStarted, + onOpenDm = onDmStarted, onStartCall = onStartCall, goBack = goBack, openAvatarPreview = openAvatarPreview, diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionNode.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionNode.kt index 6fbde66faf..9ce1358683 100644 --- a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionNode.kt +++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionNode.kt @@ -43,7 +43,7 @@ class VerifySelfSessionNode @AssistedInject constructor( state = state, modifier = modifier, onEnterRecoveryKey = callback::onEnterRecoveryKey, - onFinished = callback::onDone, + onFinish = callback::onDone, ) } } diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionPresenter.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionPresenter.kt index b31cbd0162..ed7342eb60 100644 --- a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionPresenter.kt +++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionPresenter.kt @@ -25,7 +25,6 @@ import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue import com.freeletics.flowredux.compose.rememberStateAndDispatch import io.element.android.features.preferences.api.store.SessionPreferencesStore import io.element.android.libraries.architecture.AsyncData @@ -61,7 +60,7 @@ class VerifySelfSessionPresenter @Inject constructor( val recoveryState by encryptionService.recoveryStateStateFlow.collectAsState() val stateAndDispatch = stateMachine.rememberStateAndDispatch() val skipVerification by sessionPreferencesStore.isSessionVerificationSkipped().collectAsState(initial = false) - val needsVerification by sessionVerificationService.canVerifySessionFlow.collectAsState(initial = true) + val needsVerification by sessionVerificationService.needsSessionVerification.collectAsState(initial = true) val verificationFlowStep by remember { derivedStateOf { when { diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionView.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionView.kt index 42d752d8f5..6b908e3ebd 100644 --- a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionView.kt +++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionView.kt @@ -66,16 +66,16 @@ import io.element.android.features.verifysession.impl.VerifySelfSessionState.Ver fun VerifySelfSessionView( state: VerifySelfSessionState, onEnterRecoveryKey: () -> Unit, - onFinished: () -> Unit, + onFinish: () -> Unit, modifier: Modifier = Modifier, ) { fun resetFlow() { state.eventSink(VerifySelfSessionViewEvents.Reset) } - val updatedOnFinished by rememberUpdatedState(newValue = onFinished) - LaunchedEffect(state.verificationFlowStep, updatedOnFinished) { + val latestOnFinish by rememberUpdatedState(newValue = onFinish) + LaunchedEffect(state.verificationFlowStep, latestOnFinish) { if (state.verificationFlowStep is FlowStep.Skipped) { - updatedOnFinished() + latestOnFinish() } } BackHandler { @@ -114,7 +114,7 @@ fun VerifySelfSessionView( screenState = state, goBack = ::resetFlow, onEnterRecoveryKey = onEnterRecoveryKey, - onFinished = onFinished, + onFinish = onFinish, ) } ) { @@ -227,7 +227,7 @@ private fun BottomMenu( screenState: VerifySelfSessionState, onEnterRecoveryKey: () -> Unit, goBack: () -> Unit, - onFinished: () -> Unit, + onFinish: () -> Unit, ) { val verificationViewState = screenState.verificationFlowStep val eventSink = screenState.eventSink @@ -239,37 +239,37 @@ private fun BottomMenu( if (verificationViewState.isLastDevice) { BottomMenu( positiveButtonTitle = stringResource(R.string.screen_session_verification_enter_recovery_key), - onPositiveButtonClicked = onEnterRecoveryKey, + onPositiveButtonClick = onEnterRecoveryKey, ) } else { BottomMenu( positiveButtonTitle = stringResource(R.string.screen_identity_use_another_device), - onPositiveButtonClicked = { eventSink(VerifySelfSessionViewEvents.RequestVerification) }, + onPositiveButtonClick = { eventSink(VerifySelfSessionViewEvents.RequestVerification) }, negativeButtonTitle = stringResource(R.string.screen_session_verification_enter_recovery_key), - onNegativeButtonClicked = onEnterRecoveryKey, + onNegativeButtonClick = onEnterRecoveryKey, ) } } is FlowStep.Canceled -> { BottomMenu( positiveButtonTitle = stringResource(R.string.screen_session_verification_positive_button_canceled), - onPositiveButtonClicked = { eventSink(VerifySelfSessionViewEvents.RequestVerification) }, + onPositiveButtonClick = { eventSink(VerifySelfSessionViewEvents.RequestVerification) }, negativeButtonTitle = stringResource(CommonStrings.action_cancel), - onNegativeButtonClicked = goBack, + onNegativeButtonClick = goBack, ) } is FlowStep.Ready -> { BottomMenu( positiveButtonTitle = stringResource(CommonStrings.action_start), - onPositiveButtonClicked = { eventSink(VerifySelfSessionViewEvents.StartSasVerification) }, + onPositiveButtonClick = { eventSink(VerifySelfSessionViewEvents.StartSasVerification) }, negativeButtonTitle = stringResource(CommonStrings.action_cancel), - onNegativeButtonClicked = goBack, + onNegativeButtonClick = goBack, ) } is FlowStep.AwaitingOtherDeviceResponse -> { BottomMenu( positiveButtonTitle = stringResource(R.string.screen_identity_waiting_on_other_device), - onPositiveButtonClicked = {}, + onPositiveButtonClick = {}, isLoading = true, ) } @@ -281,20 +281,20 @@ private fun BottomMenu( } BottomMenu( positiveButtonTitle = positiveButtonTitle, - onPositiveButtonClicked = { + onPositiveButtonClick = { if (!isVerifying) { eventSink(VerifySelfSessionViewEvents.ConfirmVerification) } }, negativeButtonTitle = stringResource(R.string.screen_session_verification_they_dont_match), - onNegativeButtonClicked = { eventSink(VerifySelfSessionViewEvents.DeclineVerification) }, + onNegativeButtonClick = { eventSink(VerifySelfSessionViewEvents.DeclineVerification) }, isLoading = isVerifying, ) } is FlowStep.Completed -> { BottomMenu( positiveButtonTitle = stringResource(CommonStrings.action_continue), - onPositiveButtonClicked = onFinished, + onPositiveButtonClick = onFinish, ) } is FlowStep.Skipped -> return @@ -304,11 +304,11 @@ private fun BottomMenu( @Composable private fun BottomMenu( positiveButtonTitle: String?, - onPositiveButtonClicked: () -> Unit, + onPositiveButtonClick: () -> Unit, modifier: Modifier = Modifier, negativeButtonTitle: String? = null, negativeButtonEnabled: Boolean = negativeButtonTitle != null, - onNegativeButtonClicked: () -> Unit = {}, + onNegativeButtonClick: () -> Unit = {}, isLoading: Boolean = false, ) { ButtonColumnMolecule( @@ -319,14 +319,14 @@ private fun BottomMenu( text = positiveButtonTitle, showProgress = isLoading, modifier = Modifier.fillMaxWidth(), - onClick = onPositiveButtonClicked, + onClick = onPositiveButtonClick, ) } if (negativeButtonTitle != null) { TextButton( text = negativeButtonTitle, modifier = Modifier.fillMaxWidth(), - onClick = onNegativeButtonClicked, + onClick = onNegativeButtonClick, enabled = negativeButtonEnabled, ) } else { @@ -341,6 +341,6 @@ internal fun VerifySelfSessionViewPreview(@PreviewParameter(VerifySelfSessionSta VerifySelfSessionView( state = state, onEnterRecoveryKey = {}, - onFinished = {}, + onFinish = {}, ) } diff --git a/features/verifysession/impl/src/main/res/values-es/translations.xml b/features/verifysession/impl/src/main/res/values-es/translations.xml index 812ba53a32..b165818726 100644 --- a/features/verifysession/impl/src/main/res/values-es/translations.xml +++ b/features/verifysession/impl/src/main/res/values-es/translations.xml @@ -1,11 +1,16 @@ + "Verifica este dispositivo para configurar la mensajería segura." + "Confirma que eres tú" + "Ahora puedes leer o enviar mensajes de forma segura y cualquier persona con la que chatees también puede confiar en este dispositivo." + "Dispositivo verificado" "Algo no fue bien. Se agotó el tiempo de espera de la solicitud o se rechazó." "Confirma que los emojis que aparecen a continuación coinciden con los que aparecen en tu otra sesión." "Comparar emojis" "Confirma que los números que aparecen a continuación coinciden con los mostrados en tu otra sesión." "Comparar números" "Tu nueva sesión ya está verificada. Tienes acceso a tus mensajes cifrados y otros usuarios lo considerarán de confianza." + "Introduzca la clave de recuperación" "Demuestra que eres tú para acceder a tu historial de mensajes cifrados." "Abrir una sesión existente" "Reintentar la verificación" diff --git a/features/verifysession/impl/src/main/res/values-it/translations.xml b/features/verifysession/impl/src/main/res/values-it/translations.xml index a3b300793d..5e22712283 100644 --- a/features/verifysession/impl/src/main/res/values-it/translations.xml +++ b/features/verifysession/impl/src/main/res/values-it/translations.xml @@ -1,5 +1,6 @@ + "Crea una nuova chiave di recupero" "Verifica questo dispositivo per segnare i tuoi messaggi come sicuri." "Conferma la tua identità" "Ora puoi leggere o inviare messaggi in tutta sicurezza e anche chi chatta con te può fidarsi di questo dispositivo." diff --git a/features/verifysession/impl/src/main/res/values-ka/translations.xml b/features/verifysession/impl/src/main/res/values-ka/translations.xml new file mode 100644 index 0000000000..fb1f3c6db4 --- /dev/null +++ b/features/verifysession/impl/src/main/res/values-ka/translations.xml @@ -0,0 +1,17 @@ + + + "რაღაცა არასწორადაა. ან მოთხოვნის ვადაა ამოწურული, ან მოთხოვნა უარყოფილი იყო." + "დაადასტურეთ, რომ ქვემოთ მოყვანილი ემოჯიები შეესაბამება თქვენს სხვა სესიაზე ნაჩვენებს." + "შეადარეთ ემოჯიები" + "თქვენი ახალი სესია დადასტურებულია. მას აქვს წვდომა დაშიფრულ შეტყობინებებზე და სხვა მომხმარებლები მას სანდოდ ხედავენ." + "დაამტკიცეთ, რომ ეს თქვენ ხართ, რათა მიიღოთ წვდომა თქვენი დაშიფრული შეტყობინებების ისტორიასთან." + "არსებული სესიის გახსნა" + "დადასტურების ხელახლა ცდა" + "მზად ვარ" + "ველოდებით დამთხვევას" + "შეადარეთ უნიკალური ემოჯი, დარწმუნდით, რომ ისინი ერთი დ იმავე თანმიმდევრობით გამოჩნდნენ." + "ისინი არ ემთხვევიან ერთმანეთს" + "ისინი ემთხვევიან ერთმანეთს" + "მიიღეთ დადასტურების მოთხოვნა თქვენს სხვა სესიაში ამ პროცესის გასაგრძელებლად." + "მოთხოვნის მიღებას ველოდებით" + diff --git a/features/verifysession/impl/src/main/res/values-pt-rBR/translations.xml b/features/verifysession/impl/src/main/res/values-pt-rBR/translations.xml new file mode 100644 index 0000000000..919c8db023 --- /dev/null +++ b/features/verifysession/impl/src/main/res/values-pt-rBR/translations.xml @@ -0,0 +1,28 @@ + + + "Criar uma nova chave de recuperação" + "Verifica este dispositivo para configurar o envio seguro de mensagens." + "Confirma que és tu" + "Agora podes ler ou enviar mensagens de forma segura, e qualquer pessoa com quem converses também pode confiar neste dispositivo." + "Dispositivo verificado" + "Utilizar outro dispositivo" + "A aguardar por outros dispositivos…" + "Algo não bateu certo. O pedido ou demorou demasiado tempo ou foi rejeitado." + "Confirma se os emojis abaixo correspondem aos apresentados na tua outra sessão." + "Compara os emojis" + "Confirma se os números abaixo correspondem aos números apresentados na tua outra sessão." + "Comparar números" + "A tua nova sessão está agora verificada, pelo que tem acesso às tuas mensagens cifradas e os outros utilizadores vão vê-la como de confiança." + "Insere a chave de recuperação" + "Prova que és tu para acederes ao teu histórico de mensagens cifradas." + "Abrir sessão existente" + "Repetir verficiação" + "Estou pronto" + "A aguardar correspondência" + "Compara um conjunto único de emojis." + "Compara os emojis únicos, certificando-te de que aparecem pela mesma ordem." + "Não correspondem" + "Correspondem" + "Para continuar, aceita o pedido de verificação na tua outra sessão." + "À aguardar a aceitação do pedido" + diff --git a/features/verifysession/impl/src/main/res/values-ro/translations.xml b/features/verifysession/impl/src/main/res/values-ro/translations.xml index ed96210f84..91e837ddec 100644 --- a/features/verifysession/impl/src/main/res/values-ro/translations.xml +++ b/features/verifysession/impl/src/main/res/values-ro/translations.xml @@ -1,5 +1,12 @@ + "Creați o nouă cheie de recuperare" + "Verificați acest dispozitiv pentru a configura mesagerie securizată." + "Confirmați că sunteți dumneavoastră" + "Acum puteți citi sau trimite mesaje în siguranță, iar oricine cu care conversați poate avea încredere în acest dispozitiv." + "Dispozitiv verificat" + "Utilizați un alt dispozitiv" + "Se așteaptă celălalt dispozitiv…" "Ceva nu este în regulă. Fie cererea a expirat, fie a fost respinsă." "Confirmați că emoticoanele de mai jos se potrivesc cu cele afișate în cealaltă sesiune." "Comparați emoticoanele" diff --git a/features/verifysession/impl/src/main/res/values-sv/translations.xml b/features/verifysession/impl/src/main/res/values-sv/translations.xml index f1b2fcc6e5..5460b76d12 100644 --- a/features/verifysession/impl/src/main/res/values-sv/translations.xml +++ b/features/verifysession/impl/src/main/res/values-sv/translations.xml @@ -6,6 +6,7 @@ "Bekräfta att siffrorna nedan matchar de som visas på din andra session." "Jämför siffror" "Din nya session är nu verifierad. Den har tillgång till dina krypterade meddelanden, och andra användare kommer att se den som betrodd." + "Ange återställningsnyckel" "Bevisa att det är du för att komma åt din krypterade meddelandehistorik." "Öppna en befintlig session" "Försök att verifiera igen" diff --git a/features/verifysession/impl/src/main/res/values-zh-rTW/translations.xml b/features/verifysession/impl/src/main/res/values-zh-rTW/translations.xml index e98480316a..f5a26eaf33 100644 --- a/features/verifysession/impl/src/main/res/values-zh-rTW/translations.xml +++ b/features/verifysession/impl/src/main/res/values-zh-rTW/translations.xml @@ -6,7 +6,7 @@ "您可以安全地讀取和發送訊息了,與您聊天的人也可以信任這部裝置。" "裝置已驗證" "使用另一部裝置" - "正在等待其他裝置……" + "正在等待其他裝置…" "似乎出了一點問題。有可能是因為等候逾時,或是請求被拒絕。" "確認顯示在其他工作階段上的表情符號是否和下方的相同。" "比對表情符號" diff --git a/features/verifysession/impl/src/main/res/values-zh/translations.xml b/features/verifysession/impl/src/main/res/values-zh/translations.xml new file mode 100644 index 0000000000..15cf93695f --- /dev/null +++ b/features/verifysession/impl/src/main/res/values-zh/translations.xml @@ -0,0 +1,28 @@ + + + "创建新的恢复密钥" + "验证此设备以开始安全地收发消息。" + "确认这是你" + "现在,您可以安全地阅读或发送消息,与您聊天的人也会信任此设备。" + "设备已验证" + "使用其他设备" + "正在等待其他设备……" + "发生了一些错误。网络请求超时,或者被服务器拒绝。" + "确认下方的表情符号与另一设备上显示的相同。" + "比较表情符号" + "确认以下数字与其他会话中显示的一致。" + "比较数字" + "你的新设备已经成功验证。现在新设备可以访问加密信息,别的用户也会信任这个设备。" + "输入恢复密钥" + "证明自己的身份以访问加密历史消息。" + "打开已有会话" + "重试验证" + "准备就绪" + "等待比对" + "比较一组表情符号。" + "比较表情符号,确保它们以相同顺序排列。" + "不匹配" + "匹配" + "请在其他会话中接受验证请求。" + "等待接受请求" + diff --git a/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionPresenterTests.kt b/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionPresenterTests.kt index 3dd391da16..1a27891bef 100644 --- a/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionPresenterTests.kt +++ b/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionPresenterTests.kt @@ -296,8 +296,7 @@ class VerifySelfSessionPresenterTests { @Test fun `present - When verification is not needed, the flow is completed`() = runTest { val service = FakeSessionVerificationService().apply { - givenCanVerifySession(false) - givenIsReady(true) + givenNeedsSessionVerification(false) givenVerifiedStatus(SessionVerifiedStatus.Verified) givenVerificationFlowState(VerificationFlowState.Finished) } diff --git a/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionViewTest.kt b/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionViewTest.kt index 52158c2d7f..15a1ffedac 100644 --- a/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionViewTest.kt +++ b/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionViewTest.kt @@ -222,7 +222,7 @@ class VerifySelfSessionViewTest { VerifySelfSessionView( state = state, onEnterRecoveryKey = onEnterRecoveryKey, - onFinished = onFinished, + onFinish = onFinished, ) } } diff --git a/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFileNode.kt b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFileNode.kt index 3d4ad727fe..2c063f5e16 100644 --- a/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFileNode.kt +++ b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFileNode.kt @@ -41,7 +41,7 @@ class ViewFileNode @AssistedInject constructor( ) : NodeInputs interface Callback : Plugin { - fun onBackPressed() + fun onBackClick() } private val inputs: Inputs = inputs() @@ -51,8 +51,8 @@ class ViewFileNode @AssistedInject constructor( name = inputs.name, ) - private fun onBackPressed() { - plugins().forEach { it.onBackPressed() } + private fun onBackClick() { + plugins().forEach { it.onBackClick() } } @Composable @@ -61,7 +61,7 @@ class ViewFileNode @AssistedInject constructor( ViewFileView( state = state, modifier = modifier, - onBackPressed = ::onBackPressed, + onBackClick = ::onBackClick, ) } } diff --git a/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFileView.kt b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFileView.kt index f832617280..351b032e32 100644 --- a/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFileView.kt +++ b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/file/ViewFileView.kt @@ -62,7 +62,7 @@ import kotlinx.collections.immutable.toImmutableList @Composable fun ViewFileView( state: ViewFileState, - onBackPressed: () -> Unit, + onBackClick: () -> Unit, modifier: Modifier = Modifier, ) { Scaffold( @@ -70,7 +70,7 @@ fun ViewFileView( topBar = { TopAppBar( navigationIcon = { - BackButton(onClick = onBackPressed) + BackButton(onClick = onBackClick) }, title = { Text( @@ -247,6 +247,6 @@ private val colorError = Color(0xFFFF6B68) internal fun ViewFileViewPreview(@PreviewParameter(ViewFileStateProvider::class) state: ViewFileState) = ElementPreview { ViewFileView( state = state, - onBackPressed = {}, + onBackClick = {}, ) } diff --git a/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/folder/ViewFolderNode.kt b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/folder/ViewFolderNode.kt index 23dac7bc4e..6e6c2a2415 100644 --- a/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/folder/ViewFolderNode.kt +++ b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/folder/ViewFolderNode.kt @@ -42,7 +42,7 @@ class ViewFolderNode @AssistedInject constructor( ) : NodeInputs interface Callback : Plugin { - fun onBackPressed() + fun onBackClick() fun onNavigateTo(item: Item) } @@ -53,8 +53,8 @@ class ViewFolderNode @AssistedInject constructor( path = inputs.path, ) - private fun onBackPressed() { - plugins().forEach { it.onBackPressed() } + private fun onBackClick() { + plugins().forEach { it.onBackClick() } } private fun onNavigateTo(item: Item) { @@ -68,7 +68,7 @@ class ViewFolderNode @AssistedInject constructor( state = state, modifier = modifier, onNavigateTo = ::onNavigateTo, - onBackPressed = ::onBackPressed, + onBackClick = ::onBackClick, ) } } diff --git a/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/folder/ViewFolderView.kt b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/folder/ViewFolderView.kt index 44453c253e..b8198cff21 100644 --- a/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/folder/ViewFolderView.kt +++ b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/folder/ViewFolderView.kt @@ -53,7 +53,7 @@ import io.element.android.libraries.designsystem.theme.components.TopAppBar fun ViewFolderView( state: ViewFolderState, onNavigateTo: (Item) -> Unit, - onBackPressed: () -> Unit, + onBackClick: () -> Unit, modifier: Modifier = Modifier, ) { Scaffold( @@ -61,7 +61,7 @@ fun ViewFolderView( topBar = { TopAppBar( navigationIcon = { - BackButton(onClick = onBackPressed) + BackButton(onClick = onBackClick) }, title = { Text( @@ -85,7 +85,7 @@ fun ViewFolderView( ) { item -> ItemRow( item = item, - onItemClicked = { onNavigateTo(item) }, + onItemClick = { onNavigateTo(item) }, ) } if (state.content.none { it !is Item.Parent }) { @@ -108,7 +108,7 @@ fun ViewFolderView( @Composable private fun ItemRow( item: Item, - onItemClicked: () -> Unit, + onItemClick: () -> Unit, ) { when (item) { Item.Parent -> { @@ -121,7 +121,7 @@ private fun ItemRow( style = ElementTheme.typography.fontBodyMdMedium, ) }, - onClick = onItemClicked, + onClick = onItemClick, ) } is Item.Folder -> { @@ -134,7 +134,7 @@ private fun ItemRow( style = ElementTheme.typography.fontBodyMdMedium, ) }, - onClick = onItemClicked, + onClick = onItemClick, ) } is Item.File -> { @@ -148,7 +148,7 @@ private fun ItemRow( ) }, trailingContent = ListItemContent.Text(item.formattedSize), - onClick = onItemClicked, + onClick = onItemClick, ) } } @@ -160,6 +160,6 @@ internal fun ViewFolderViewPreview(@PreviewParameter(ViewFolderStateProvider::cl ViewFolderView( state = state, onNavigateTo = {}, - onBackPressed = {}, + onBackClick = {}, ) } diff --git a/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/root/ViewFolderRootNode.kt b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/root/ViewFolderRootNode.kt index 697bd76d13..9379d86f26 100644 --- a/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/root/ViewFolderRootNode.kt +++ b/features/viewfolder/impl/src/main/kotlin/io/element/android/features/viewfolder/impl/root/ViewFolderRootNode.kt @@ -97,7 +97,7 @@ class ViewFolderRootNode @AssistedInject constructor( } is NavTarget.File -> { val callback: ViewFileNode.Callback = object : ViewFileNode.Callback { - override fun onBackPressed() { + override fun onBackClick() { backstack.pop() } } @@ -115,7 +115,7 @@ class ViewFolderRootNode @AssistedInject constructor( inputs: ViewFolderNode.Inputs, ): Node { val callback: ViewFolderNode.Callback = object : ViewFolderNode.Callback { - override fun onBackPressed() { + override fun onBackClick() { onDone() } diff --git a/gradle.properties b/gradle.properties index f28522f5b0..19237b74cf 100644 --- a/gradle.properties +++ b/gradle.properties @@ -49,7 +49,7 @@ signing.element.nightly.keyPassword=Secret # Customise the Lint version to use a more recent version than the one bundled with AGP # https://googlesamples.github.io/android-custom-lint-rules/usage/newer-lint.md.html -android.experimental.lint.version=8.4.0-alpha13 +android.experimental.lint.version=8.5.0-alpha07 # Enable test fixture for all modules by default android.experimental.enableTestFixtures=true @@ -59,3 +59,6 @@ android.enableBuildConfigAsBytecode=true # By default, the plugin applies itself to all subprojects, but we don't want that as it would cause issues with builds using local AARs dependency.analysis.autoapply=false + +# Disable new R8 shrinking for local dependencies as it causes issues with release builds +android.disableMinifyLocalDependenciesForLibraries=false diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e917045d33..5790ec817f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,17 +3,17 @@ [versions] # Project -android_gradle_plugin = "8.3.2" -kotlin = "1.9.23" -ksp = "1.9.23-1.0.20" +android_gradle_plugin = "8.4.1" +kotlin = "1.9.24" +ksp = "1.9.24-1.0.20" firebaseAppDistribution = "5.0.0" # AndroidX core = "1.13.0" -# Warning: there is an issue with 1.1.0, that I cannot repro on unit test. +# Warning: there is an issue with 1.1.0 and 1.1.1, that I cannot repro on unit test. # To repro with the application: -# Clear the cache of the application and run the app. Nearly each time, there is an infinite loading -# Due to the DefaultMigrationStore not bahaving as expected. +# Clear the storage of the application and run the app. Nearly each time, there is an infinite loading +# due to the DefaultMigrationStore not behaving as expected. # Stick to 1.0.0 for now, and ensure that this scenario cannot be reproduced when upgrading the version. datastore = "1.0.0" constraintlayout = "2.1.4" @@ -24,10 +24,10 @@ media3 = "1.3.1" # Compose compose_bom = "2024.05.00" -composecompiler = "1.5.13" +composecompiler = "1.5.14" # Coroutines -coroutines = "1.8.0" +coroutines = "1.8.1" # Accompanist accompanist = "0.34.0" @@ -37,13 +37,13 @@ test_core = "1.5.0" #other coil = "2.6.0" -datetime = "0.5.0" +datetime = "0.6.0" dependencyAnalysis = "1.31.0" serialization_json = "1.6.3" showkase = "1.0.2" appyx = "1.4.0" sqldelight = "2.0.2" -wysiwyg = "2.37.2" +wysiwyg = "2.37.3" telephoto = "0.11.2" # DI @@ -54,10 +54,9 @@ anvil = "2.4.9" autoservice = "1.1.1" # quality -junit = "4.13.2" androidx-test-ext-junit = "1.1.5" espresso-core = "3.5.1" -kover = "0.7.6" +kover = "0.8.0" [libraries] # Project @@ -75,7 +74,7 @@ autonomousapps_dependencyanalysis_plugin = { module = "com.autonomousapps:depend # AndroidX androidx_core = { module = "androidx.core:core", version.ref = "core" } androidx_corektx = { module = "androidx.core:core-ktx", version.ref = "core" } -androidx_annotationjvm = "androidx.annotation:annotation-jvm:1.7.1" +androidx_annotationjvm = "androidx.annotation:annotation-jvm:1.8.0" androidx_datastore_preferences = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" } androidx_datastore_datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" } androidx_exifinterface = "androidx.exifinterface:exifinterface:1.3.7" @@ -114,7 +113,6 @@ coroutines_test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", ve # Accompanist accompanist_permission = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanist" } -accompanist_systemui = { module = "com.google.accompanist:accompanist-systemuicontroller", version.ref = "accompanist" } # Libraries squareup_seismic = "com.squareup:seismic:1.0.3" @@ -135,13 +133,12 @@ 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.5.2" -test_junitext = "androidx.test.ext:junit:1.1.5" -test_mockk = "io.mockk:mockk:1.13.10" +test_mockk = "io.mockk:mockk:1.13.11" test_konsist = "com.lemonappdev:konsist:0.13.0" test_turbine = "app.cash.turbine:turbine:1.1.0" test_truth = "com.google.truth:truth:1.4.2" test_parameter_injector = "com.google.testparameterinjector:test-parameter-injector:1.16" -test_robolectric = "org.robolectric:robolectric:4.12.1" +test_robolectric = "org.robolectric:robolectric:4.12.2" test_appyx_junit = { module = "com.bumble.appyx:testing-junit4", version.ref = "appyx" } # Others @@ -157,9 +154,9 @@ showkase = { module = "com.airbnb.android:showkase", version.ref = "showkase" } showkase_processor = { module = "com.airbnb.android:showkase-processor", version.ref = "showkase" } jsoup = "org.jsoup:jsoup:1.17.2" appyx_core = { module = "com.bumble.appyx:core", version.ref = "appyx" } -molecule-runtime = "app.cash.molecule:molecule-runtime:1.4.2" +molecule-runtime = "app.cash.molecule:molecule-runtime:1.4.3" timber = "com.jakewharton.timber:timber:5.0.1" -matrix_sdk = "org.matrix.rustcomponents:sdk-android:0.2.18" +matrix_sdk = "org.matrix.rustcomponents:sdk-android:0.2.21" matrix_richtexteditor = { module = "io.element.android:wysiwyg", version.ref = "wysiwyg" } matrix_richtexteditor_compose = { module = "io.element.android:wysiwyg-compose", version.ref = "wysiwyg" } sqldelight-driver-android = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" } @@ -167,7 +164,7 @@ sqldelight-driver-jvm = { module = "app.cash.sqldelight:sqlite-driver", version. sqldelight-coroutines = { module = "app.cash.sqldelight:coroutines-extensions", version.ref = "sqldelight" } sqlcipher = "net.zetetic:android-database-sqlcipher:4.5.4" sqlite = "androidx.sqlite:sqlite-ktx:2.4.0" -unifiedpush = "com.github.UnifiedPush:android-connector:2.1.1" +unifiedpush = "com.github.UnifiedPush:android-connector:2.4.0" otaliastudios_transcoder = "com.otaliastudios:transcoder:0.10.5" vanniktech_blurhash = "com.vanniktech:blurhash:0.3.0" telephoto_zoomableimage = { module = "me.saket.telephoto:zoomable-image-coil", version.ref = "telephoto" } @@ -177,11 +174,11 @@ maplibre = "org.maplibre.gl:android-sdk:11.0.0" maplibre_ktx = "org.maplibre.gl:android-sdk-ktx-v7:2.0.2" maplibre_annotation = "org.maplibre.gl:android-plugin-annotation-v9:3.0.0" opusencoder = "io.element.android:opusencoder:1.1.0" -kotlinpoet = "com.squareup:kotlinpoet:1.16.0" +kotlinpoet = "com.squareup:kotlinpoet:1.17.0" # Analytics -posthog = "com.posthog:posthog-android:3.2.1" -sentry = "io.sentry:sentry-android:7.8.0" +posthog = "com.posthog:posthog-android:3.3.0" +sentry = "io.sentry:sentry-android:7.9.0" # main branch can be tested replacing the version with main-SNAPSHOT matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:0.21.0" @@ -204,7 +201,6 @@ google_autoservice_annotations = { module = "com.google.auto.service:auto-servic # value of `composecompiler` (which is used to set composeOptions.kotlinCompilerExtensionVersion. # See https://github.com/renovatebot/renovate/issues/18354 android_composeCompiler = { module = "androidx.compose.compiler:compiler", version.ref = "composecompiler" } -junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidx-test-ext-junit" } espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso-core" } appcompat = "androidx.appcompat:appcompat:1.6.1" @@ -226,7 +222,6 @@ dependencygraph = "com.savvasdalkitsis.module-dependency-graph:0.12" dependencycheck = "org.owasp.dependencycheck:9.1.0" dependencyanalysis = { id = "com.autonomousapps.dependency-analysis", version.ref = "dependencyAnalysis" } paparazzi = "app.cash.paparazzi:1.3.3" -kover = { id = "org.jetbrains.kotlinx.kover", version.ref = "kover" } 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" } 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 737eab7ac7..49e055cc29 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 @@ -28,6 +28,7 @@ import android.widget.Toast import androidx.activity.result.ActivityResultLauncher import androidx.annotation.ChecksSdkIntAtLeast import androidx.annotation.RequiresApi +import androidx.core.content.pm.PackageInfoCompat import io.element.android.libraries.androidutils.R import io.element.android.libraries.androidutils.compat.getApplicationInfoCompat import io.element.android.libraries.core.mimetype.MimeTypes @@ -47,6 +48,19 @@ fun Context.getApplicationLabel(packageName: String): String { } } +/** + * Retrieve the versionCode from the Manifest. + * The value is more accurate than BuildConfig.VERSION_CODE, as it is correct according to the + * computation in the `androidComponents` block of the app build.gradle.kts file. + * In other words, the last digit (for the architecture) will be set, whereas BuildConfig.VERSION_CODE + * last digit will always be 0. + */ +fun Context.getVersionCodeFromManifest(): Long { + return PackageInfoCompat.getLongVersionCode( + packageManager.getPackageInfo(packageName, 0) + ) +} + // ============================================================================================================== // Clipboard helper // ============================================================================================================== diff --git a/libraries/androidutils/src/main/res/values-ka/translations.xml b/libraries/androidutils/src/main/res/values-ka/translations.xml new file mode 100644 index 0000000000..ee284f3a40 --- /dev/null +++ b/libraries/androidutils/src/main/res/values-ka/translations.xml @@ -0,0 +1,4 @@ + + + "თავსებადი აპლიკაცია ვერ მოიძებნა ამ მოქმედების შესასრულებლად." + diff --git a/libraries/androidutils/src/main/res/values-pt-rBR/translations.xml b/libraries/androidutils/src/main/res/values-pt-rBR/translations.xml new file mode 100644 index 0000000000..9795e1b0d5 --- /dev/null +++ b/libraries/androidutils/src/main/res/values-pt-rBR/translations.xml @@ -0,0 +1,4 @@ + + + "Nenhuma aplicação encontrada capaz de continuar esta ação." + diff --git a/libraries/androidutils/src/main/res/values-zh/translations.xml b/libraries/androidutils/src/main/res/values-zh/translations.xml new file mode 100644 index 0000000000..00f50d4614 --- /dev/null +++ b/libraries/androidutils/src/main/res/values-zh/translations.xml @@ -0,0 +1,4 @@ + + + "找不到完成此项操作的合适应用。" + diff --git a/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/AsyncAction.kt b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/AsyncAction.kt index e69b1b9eaf..9545f1ab68 100644 --- a/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/AsyncAction.kt +++ b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/AsyncAction.kt @@ -86,6 +86,8 @@ sealed interface AsyncAction { fun isFailure(): Boolean = this is Failure fun isSuccess(): Boolean = this is Success + + fun isReady() = isSuccess() || isFailure() } suspend inline fun MutableState>.runCatchingUpdatingState( diff --git a/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/AsyncData.kt b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/AsyncData.kt index 75bb503390..dab7a5f3b1 100644 --- a/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/AsyncData.kt +++ b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/AsyncData.kt @@ -91,6 +91,8 @@ sealed interface AsyncData { fun isSuccess(): Boolean = this is Success fun isUninitialized(): Boolean = this == Uninitialized + + fun isReady() = isSuccess() || isFailure() } suspend inline fun MutableState>.runCatchingUpdatingState( diff --git a/libraries/core/src/main/kotlin/io/element/android/libraries/core/meta/BuildMeta.kt b/libraries/core/src/main/kotlin/io/element/android/libraries/core/meta/BuildMeta.kt index ad5581f631..a9cb78548f 100644 --- a/libraries/core/src/main/kotlin/io/element/android/libraries/core/meta/BuildMeta.kt +++ b/libraries/core/src/main/kotlin/io/element/android/libraries/core/meta/BuildMeta.kt @@ -25,7 +25,7 @@ data class BuildMeta( val applicationId: String, val lowPrivacyLoggingEnabled: Boolean, val versionName: String, - val versionCode: Int, + val versionCode: Long, val gitRevision: String, val gitBranchName: String, val flavorDescription: String, diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/DialogLikeBannerMolecule.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/DialogLikeBannerMolecule.kt index 54261bfebe..1474899273 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/DialogLikeBannerMolecule.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/molecules/DialogLikeBannerMolecule.kt @@ -45,8 +45,8 @@ import io.element.android.libraries.ui.strings.CommonStrings fun DialogLikeBannerMolecule( title: String, content: String, - onSubmitClicked: () -> Unit, - onDismissClicked: (() -> Unit)?, + onSubmitClick: () -> Unit, + onDismissClick: (() -> Unit)?, modifier: Modifier = Modifier, ) { Box(modifier = modifier.padding(horizontal = 16.dp, vertical = 8.dp)) { @@ -68,9 +68,9 @@ fun DialogLikeBannerMolecule( color = MaterialTheme.colorScheme.primary, textAlign = TextAlign.Start, ) - if (onDismissClicked != null) { + if (onDismissClick != null) { Icon( - modifier = Modifier.clickable(onClick = onDismissClicked), + modifier = Modifier.clickable(onClick = onDismissClick), imageVector = CompoundIcons.Close(), contentDescription = stringResource(CommonStrings.action_close) ) @@ -86,7 +86,7 @@ fun DialogLikeBannerMolecule( text = stringResource(CommonStrings.action_continue), size = ButtonSize.Medium, modifier = Modifier.fillMaxWidth(), - onClick = onSubmitClicked, + onClick = onSubmitClick, ) } } @@ -99,7 +99,7 @@ internal fun DialogLikeBannerMoleculePreview() = ElementPreview { DialogLikeBannerMolecule( title = "Title", content = "Content", - onSubmitClicked = {}, - onDismissClicked = {} + onSubmitClick = {}, + onDismissClick = {} ) } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/pages/FlowStepPage.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/pages/FlowStepPage.kt index 7eb4b6d413..789e4a3a18 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/pages/FlowStepPage.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/atomic/pages/FlowStepPage.kt @@ -41,7 +41,7 @@ import io.element.android.libraries.designsystem.theme.components.TopAppBar /** * A Page with: - * - a top bar as TobAppBar with optional back button (displayed if [onBackClicked] is not null) + * - a top bar as TobAppBar with optional back button (displayed if [onBackClick] is not null) * - a header, as IconTitleSubtitleMolecule * - a content. * - a footer, as ButtonColumnMolecule @@ -52,21 +52,21 @@ fun FlowStepPage( iconVector: ImageVector?, title: String, modifier: Modifier = Modifier, - onBackClicked: (() -> Unit)? = null, + onBackClick: (() -> Unit)? = null, subTitle: String? = null, - content: @Composable () -> Unit = {}, buttons: @Composable ColumnScope.() -> Unit = {}, + content: @Composable () -> Unit = {}, ) { - BackHandler(enabled = onBackClicked != null) { - onBackClicked?.invoke() + BackHandler(enabled = onBackClick != null) { + onBackClick?.invoke() } HeaderFooterPage( modifier = modifier, topBar = { TopAppBar( navigationIcon = { - if (onBackClicked != null) { - BackButton(onClick = onBackClicked) + if (onBackClick != null) { + BackButton(onClick = onBackClick) } }, title = {}, @@ -94,25 +94,24 @@ fun FlowStepPage( @Composable internal fun FlowStepPagePreview() = ElementPreview { FlowStepPage( - onBackClicked = {}, + onBackClick = {}, title = "Title", subTitle = "Subtitle", iconVector = CompoundIcons.Computer(), - content = { - Box( - Modifier - .fillMaxSize(), - contentAlignment = Alignment.Center - ) { - Text( - text = "Content", - style = ElementTheme.typography.fontHeadingXlBold - ) - } - }, buttons = { TextButton(text = "A button", onClick = { }) Button(text = "Continue", onClick = { }) } - ) + ) { + Box( + Modifier + .fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Text( + text = "Content", + style = ElementTheme.typography.fontHeadingXlBold + ) + } + } } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/Bloom.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/Bloom.kt index 8558780a22..f8121ec500 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/Bloom.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/Bloom.kt @@ -168,6 +168,7 @@ data class BloomLayer( * @param bottomSoftEdgeAlpha The alpha value to apply to the bottom soft edge. * @param alpha The alpha value to apply to the bloom effect. */ +@SuppressWarnings("ModifierComposed") fun Modifier.bloom( hash: String?, background: Color, @@ -312,6 +313,7 @@ fun Modifier.bloom( * @param bottomSoftEdgeAlpha The alpha value to apply to the bottom soft edge. * @param alpha The alpha value to apply to the bloom effect. */ +@SuppressWarnings("ModifierComposed") fun Modifier.avatarBloom( avatarData: AvatarData, background: Color, diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/ProgressDialog.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/ProgressDialog.kt index 37429c9d36..e1872b0a98 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/ProgressDialog.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/ProgressDialog.kt @@ -67,7 +67,7 @@ fun ProgressDialog( modifier = modifier, text = text, isCancellable = isCancellable, - onCancelClicked = onDismissRequest, + onCancelClick = onDismissRequest, progressIndicator = { when (type) { is ProgressDialogType.Indeterminate -> { @@ -98,7 +98,7 @@ private fun ProgressDialogContent( modifier: Modifier = Modifier, text: String? = null, isCancellable: Boolean = false, - onCancelClicked: () -> Unit = {}, + onCancelClick: () -> Unit = {}, progressIndicator: @Composable () -> Unit = { CircularProgressIndicator( color = MaterialTheme.colorScheme.primary @@ -133,7 +133,7 @@ private fun ProgressDialogContent( ) { TextButton( text = stringResource(id = CommonStrings.action_cancel), - onClick = onCancelClicked, + onClick = onCancelClick, ) } } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/async/AsyncActionView.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/async/AsyncActionView.kt index f82553d731..5bd3c045cf 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/async/AsyncActionView.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/async/AsyncActionView.kt @@ -99,7 +99,7 @@ internal fun AsyncActionViewPreview( ConfirmationDialog( title = "Confirmation", content = "Are you sure?", - onSubmitClicked = {}, + onSubmitClick = {}, onDismiss = {}, ) }, diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ConfirmationDialog.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ConfirmationDialog.kt index 49d3c98b4f..20f2396b7a 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ConfirmationDialog.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ConfirmationDialog.kt @@ -34,7 +34,7 @@ import io.element.android.libraries.ui.strings.CommonStrings @Composable fun ConfirmationDialog( content: String, - onSubmitClicked: () -> Unit, + onSubmitClick: () -> Unit, onDismiss: () -> Unit, modifier: Modifier = Modifier, title: String? = null, @@ -42,8 +42,8 @@ fun ConfirmationDialog( cancelText: String = stringResource(id = CommonStrings.action_cancel), destructiveSubmit: Boolean = false, thirdButtonText: String? = null, - onCancelClicked: () -> Unit = onDismiss, - onThirdButtonClicked: () -> Unit = {}, + onCancelClick: () -> Unit = onDismiss, + onThirdButtonClick: () -> Unit = {}, ) { BasicAlertDialog(modifier = modifier, onDismissRequest = onDismiss) { ConfirmationDialogContent( @@ -53,9 +53,9 @@ fun ConfirmationDialog( cancelText = cancelText, thirdButtonText = thirdButtonText, destructiveSubmit = destructiveSubmit, - onSubmitClicked = onSubmitClicked, - onCancelClicked = onCancelClicked, - onThirdButtonClicked = onThirdButtonClicked, + onSubmitClick = onSubmitClick, + onCancelClick = onCancelClick, + onThirdButtonClick = onThirdButtonClick, ) } } @@ -65,11 +65,11 @@ private fun ConfirmationDialogContent( content: String, submitText: String, cancelText: String, - onSubmitClicked: () -> Unit, - onCancelClicked: () -> Unit, + onSubmitClick: () -> Unit, + onCancelClick: () -> Unit, title: String? = null, thirdButtonText: String? = null, - onThirdButtonClicked: () -> Unit = {}, + onThirdButtonClick: () -> Unit = {}, destructiveSubmit: Boolean = false, icon: @Composable (() -> Unit)? = null, ) { @@ -77,11 +77,11 @@ private fun ConfirmationDialogContent( title = title, content = content, submitText = submitText, - onSubmitClicked = onSubmitClicked, + onSubmitClick = onSubmitClick, cancelText = cancelText, - onCancelClicked = onCancelClicked, + onCancelClick = onCancelClick, thirdButtonText = thirdButtonText, - onThirdButtonClicked = onThirdButtonClicked, + onThirdButtonClick = onThirdButtonClick, destructiveSubmit = destructiveSubmit, icon = icon, ) @@ -98,9 +98,9 @@ internal fun ConfirmationDialogContentPreview() = submitText = "OK", cancelText = "Cancel", thirdButtonText = "Disable", - onSubmitClicked = {}, - onCancelClicked = {}, - onThirdButtonClicked = {}, + onSubmitClick = {}, + onCancelClick = {}, + onThirdButtonClick = {}, ) } } @@ -114,7 +114,7 @@ internal fun ConfirmationDialogPreview() = ElementPreview { submitText = "OK", cancelText = "Cancel", thirdButtonText = "Disable", - onSubmitClicked = {}, + onSubmitClick = {}, onDismiss = {} ) } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ErrorDialog.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ErrorDialog.kt index d8e9cbe1da..977246d9df 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ErrorDialog.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ErrorDialog.kt @@ -44,7 +44,7 @@ fun ErrorDialog( title = title, content = content, submitText = submitText, - onSubmitClicked = onDismiss, + onSubmitClick = onDismiss, ) } } @@ -52,7 +52,7 @@ fun ErrorDialog( @Composable private fun ErrorDialogContent( content: String, - onSubmitClicked: () -> Unit, + onSubmitClick: () -> Unit, title: String = ErrorDialogDefaults.title, submitText: String = ErrorDialogDefaults.submitText, ) { @@ -60,7 +60,7 @@ private fun ErrorDialogContent( title = title, content = content, submitText = submitText, - onSubmitClicked = onSubmitClicked, + onSubmitClick = onSubmitClick, ) } @@ -76,7 +76,7 @@ internal fun ErrorDialogContentPreview() { DialogPreview { ErrorDialogContent( content = "Content", - onSubmitClicked = {}, + onSubmitClick = {}, ) } } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ListDialog.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ListDialog.kt index 4e44ce805d..1d06e6409c 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ListDialog.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/ListDialog.kt @@ -67,7 +67,7 @@ fun ListDialog( cancelText = cancelText, submitText = submitText, onDismissRequest = onDismissRequest, - onSubmitClicked = onSubmit, + onSubmitClick = onSubmit, enabled = enabled, listItems = listItems, ) @@ -78,7 +78,7 @@ fun ListDialog( private fun ListDialogContent( listItems: LazyListScope.() -> Unit, onDismissRequest: () -> Unit, - onSubmitClicked: () -> Unit, + onSubmitClick: () -> Unit, cancelText: String, submitText: String, title: String? = null, @@ -90,8 +90,8 @@ private fun ListDialogContent( subtitle = subtitle, cancelText = cancelText, submitText = submitText, - onCancelClicked = onDismissRequest, - onSubmitClicked = onSubmitClicked, + onCancelClick = onDismissRequest, + onSubmitClick = onSubmitClick, enabled = enabled, applyPaddingToContents = false, ) { @@ -109,15 +109,15 @@ internal fun ListDialogContentPreview() { ListDialogContent( listItems = { item { - TextFieldListItem(placeholder = "Text input", text = "", onTextChanged = {}) + TextFieldListItem(placeholder = "Text input", text = "", onTextChange = {}) } item { - TextFieldListItem(placeholder = "Another text input", text = "", onTextChanged = {}) + TextFieldListItem(placeholder = "Another text input", text = "", onTextChange = {}) } }, title = "Dialog title", onDismissRequest = {}, - onSubmitClicked = {}, + onSubmitClick = {}, cancelText = "Cancel", submitText = "Save", ) @@ -131,10 +131,10 @@ internal fun ListDialogPreview() = ElementPreview { ListDialog( listItems = { item { - TextFieldListItem(placeholder = "Text input", text = "", onTextChanged = {}) + TextFieldListItem(placeholder = "Text input", text = "", onTextChange = {}) } item { - TextFieldListItem(placeholder = "Another text input", text = "", onTextChanged = {}) + TextFieldListItem(placeholder = "Another text input", text = "", onTextChange = {}) } }, title = "Dialog title", diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/MultipleSelectionDialog.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/MultipleSelectionDialog.kt index 183ab92522..74d38f99bc 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/MultipleSelectionDialog.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/MultipleSelectionDialog.kt @@ -44,7 +44,7 @@ import kotlinx.collections.immutable.persistentListOf @Composable fun MultipleSelectionDialog( options: ImmutableList, - onConfirmClicked: (List) -> Unit, + onConfirmClick: (List) -> Unit, onDismissRequest: () -> Unit, modifier: Modifier = Modifier, confirmButtonTitle: String = stringResource(CommonStrings.action_confirm), @@ -70,7 +70,7 @@ fun MultipleSelectionDialog( subtitle = decoratedSubtitle, options = options, confirmButtonTitle = confirmButtonTitle, - onConfirmClicked = onConfirmClicked, + onConfirmClick = onConfirmClick, dismissButtonTitle = dismissButtonTitle, onDismissRequest = onDismissRequest, initialSelected = initialSelection, @@ -82,7 +82,7 @@ fun MultipleSelectionDialog( private fun MultipleSelectionDialogContent( options: ImmutableList, confirmButtonTitle: String, - onConfirmClicked: (List) -> Unit, + onConfirmClick: (List) -> Unit, dismissButtonTitle: String, onDismissRequest: () -> Unit, title: String? = null, @@ -97,11 +97,11 @@ private fun MultipleSelectionDialogContent( title = title, subtitle = subtitle, submitText = confirmButtonTitle, - onSubmitClicked = { - onConfirmClicked(selectedOptionIndexes.toList()) + onSubmitClick = { + onConfirmClick(selectedOptionIndexes.toList()) }, cancelText = dismissButtonTitle, - onCancelClicked = onDismissRequest, + onCancelClick = onDismissRequest, applyPaddingToContents = false, ) { LazyColumn { @@ -138,7 +138,7 @@ internal fun MultipleSelectionDialogContentPreview() { MultipleSelectionDialogContent( title = "Dialog title", options = options, - onConfirmClicked = {}, + onConfirmClick = {}, onDismissRequest = {}, confirmButtonTitle = "Save", dismissButtonTitle = "Cancel", @@ -159,7 +159,7 @@ internal fun MultipleSelectionDialogPreview() = ElementPreview { MultipleSelectionDialog( title = "Dialog title", options = options, - onConfirmClicked = {}, + onConfirmClick = {}, onDismissRequest = {}, confirmButtonTitle = "Save", dismissButtonTitle = "Cancel", diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/RetryDialog.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/RetryDialog.kt index e3884c58dc..d46b2c932b 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/RetryDialog.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/RetryDialog.kt @@ -66,9 +66,9 @@ private fun RetryDialogContent( title = title, content = content, submitText = retryText, - onSubmitClicked = onRetry, + onSubmitClick = onRetry, cancelText = dismissText, - onCancelClicked = onDismiss, + onCancelClick = onDismiss, ) } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/SingleSelectionDialog.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/SingleSelectionDialog.kt index a23a8068a9..813e4d92b9 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/SingleSelectionDialog.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/dialogs/SingleSelectionDialog.kt @@ -41,7 +41,7 @@ import kotlinx.collections.immutable.persistentListOf @Composable fun SingleSelectionDialog( options: ImmutableList, - onOptionSelected: (Int) -> Unit, + onSelectOption: (Int) -> Unit, onDismissRequest: () -> Unit, modifier: Modifier = Modifier, title: String? = null, @@ -65,7 +65,7 @@ fun SingleSelectionDialog( title = title, subtitle = decoratedSubtitle, options = options, - onOptionSelected = onOptionSelected, + onOptionClick = onSelectOption, dismissButtonTitle = dismissButtonTitle, onDismissRequest = onDismissRequest, initialSelection = initialSelection, @@ -76,7 +76,7 @@ fun SingleSelectionDialog( @Composable private fun SingleSelectionDialogContent( options: ImmutableList, - onOptionSelected: (Int) -> Unit, + onOptionClick: (Int) -> Unit, dismissButtonTitle: String, onDismissRequest: () -> Unit, title: String? = null, @@ -87,7 +87,7 @@ private fun SingleSelectionDialogContent( title = title, subtitle = subtitle, submitText = dismissButtonTitle, - onSubmitClicked = onDismissRequest, + onSubmitClick = onDismissRequest, applyPaddingToContents = false, ) { LazyColumn { @@ -96,7 +96,7 @@ private fun SingleSelectionDialogContent( headline = option.title, supportingText = option.subtitle, selected = index == initialSelection, - onSelected = { onOptionSelected(index) }, + onSelect = { onOptionClick(index) }, compactLayout = true, modifier = Modifier.padding(start = 8.dp) ) @@ -118,7 +118,7 @@ internal fun SingleSelectionDialogContentPreview() { SingleSelectionDialogContent( title = "Dialog title", options = options, - onOptionSelected = {}, + onOptionClick = {}, onDismissRequest = {}, dismissButtonTitle = "Cancel", initialSelection = 0 @@ -138,7 +138,7 @@ internal fun SingleSelectionDialogPreview() = ElementPreview { SingleSelectionDialog( title = "Dialog title", options = options, - onOptionSelected = {}, + onSelectOption = {}, onDismissRequest = {}, dismissButtonTitle = "Cancel", initialSelection = 0 diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/MultipleSelectionListItem.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/MultipleSelectionListItem.kt index 8a0bc1b4a9..638930ab24 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/MultipleSelectionListItem.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/MultipleSelectionListItem.kt @@ -41,7 +41,7 @@ import kotlinx.collections.immutable.toImmutableList fun MultipleSelectionListItem( headline: String, options: ImmutableList, - onSelectionChanged: (List) -> Unit, + onSelectionChange: (List) -> Unit, resultFormatter: (List) -> String?, modifier: Modifier = Modifier, supportingText: String? = null, @@ -87,9 +87,9 @@ fun MultipleSelectionListItem( MultipleSelectionDialog( title = headline, options = options, - onConfirmClicked = { newSelectedIndexes -> + onConfirmClick = { newSelectedIndexes -> if (newSelectedIndexes != selectedIndexes.toList()) { - onSelectionChanged(newSelectedIndexes) + onSelectionChange(newSelectedIndexes) selectedIndexes.clear() selectedIndexes.addAll(newSelectedIndexes) } @@ -109,7 +109,7 @@ internal fun MutipleSelectionListItemPreview() { MultipleSelectionListItem( headline = "Headline", options = options, - onSelectionChanged = {}, + onSelectionChange = {}, supportingText = "Supporting text", resultFormatter = { result -> formatResult(result, options) }, ) @@ -125,7 +125,7 @@ internal fun MutipleSelectionListItemSelectedPreview() { MultipleSelectionListItem( headline = "Headline", options = options, - onSelectionChanged = {}, + onSelectionChange = {}, supportingText = "Supporting text", resultFormatter = { val selectedValues = formatResult(it, options) @@ -145,7 +145,7 @@ internal fun MutipleSelectionListItemSelectedTrailingContentPreview() { MultipleSelectionListItem( headline = "Headline", options = options, - onSelectionChanged = {}, + onSelectionChange = {}, supportingText = "Supporting text", resultFormatter = { selected.size.toString() }, displayResultInTrailingContent = true, diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/RadioButtonListItem.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/RadioButtonListItem.kt index fff038121e..fcef47734b 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/RadioButtonListItem.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/RadioButtonListItem.kt @@ -26,7 +26,7 @@ import io.element.android.libraries.designsystem.theme.components.Text fun RadioButtonListItem( headline: String, selected: Boolean, - onSelected: () -> Unit, + onSelect: () -> Unit, modifier: Modifier = Modifier, supportingText: String? = null, trailingContent: ListItemContent? = null, @@ -42,6 +42,6 @@ fun RadioButtonListItem( trailingContent = trailingContent, style = style, enabled = enabled, - onClick = onSelected, + onClick = onSelect, ) } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/SingleSelectionListItem.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/SingleSelectionListItem.kt index d119025b9b..82887a2430 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/SingleSelectionListItem.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/SingleSelectionListItem.kt @@ -42,7 +42,7 @@ import kotlin.time.Duration.Companion.seconds fun SingleSelectionListItem( headline: String, options: ImmutableList, - onSelectionChanged: (Int) -> Unit, + onSelectionChange: (Int) -> Unit, modifier: Modifier = Modifier, supportingText: String? = null, leadingContent: ListItemContent? = null, @@ -86,9 +86,9 @@ fun SingleSelectionListItem( SingleSelectionDialog( title = headline, options = options, - onOptionSelected = { index -> + onSelectOption = { index -> if (index != selectedIndex) { - onSelectionChanged(index) + onSelectionChange(index) selectedIndex = index } // Delay hiding the dialog for a bit so the new state is displayed in it before being dismissed @@ -110,7 +110,7 @@ internal fun SingleSelectionListItemPreview() { SingleSelectionListItem( headline = "Headline", options = listOptionOf("Option 1", "Option 2", "Option 3"), - onSelectionChanged = {}, + onSelectionChange = {}, ) } } @@ -123,7 +123,7 @@ internal fun SingleSelectionListItemUnselectedWithSupportingTextPreview() { headline = "Headline", options = listOptionOf("Option 1", "Option 2", "Option 3"), supportingText = "Supporting text", - onSelectionChanged = {}, + onSelectionChange = {}, ) } } @@ -136,7 +136,7 @@ internal fun SingleSelectionListItemSelectedInSupportingTextPreview() { headline = "Headline", options = listOptionOf("Option 1", "Option 2", "Option 3"), supportingText = "Supporting text", - onSelectionChanged = {}, + onSelectionChange = {}, selected = 1, ) } @@ -150,7 +150,7 @@ internal fun SingleSelectionListItemSelectedInTrailingContentPreview() { headline = "Headline", options = listOptionOf("Option 1", "Option 2", "Option 3"), supportingText = "Supporting text", - onSelectionChanged = {}, + onSelectionChange = {}, selected = 1, displayResultInTrailingContent = true, ) @@ -165,7 +165,7 @@ internal fun SingleSelectionListItemCustomFormattertPreview() { headline = "Headline", options = listOptionOf("Option 1", "Option 2", "Option 3"), supportingText = "Supporting text", - onSelectionChanged = {}, + onSelectionChange = {}, resultFormatter = { "Selected index: $it" }, selected = 1, displayResultInTrailingContent = true, diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/TextFieldListItem.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/TextFieldListItem.kt index f0e9458a02..770d745b77 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/TextFieldListItem.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/list/TextFieldListItem.kt @@ -34,7 +34,7 @@ import io.element.android.libraries.designsystem.theme.components.Text fun TextFieldListItem( placeholder: String?, text: String, - onTextChanged: (String) -> Unit, + onTextChange: (String) -> Unit, modifier: Modifier = Modifier, error: String? = null, maxLines: Int = 1, @@ -45,7 +45,7 @@ fun TextFieldListItem( OutlinedTextField( value = text, - onValueChange = { onTextChanged(it) }, + onValueChange = { onTextChange(it) }, placeholder = placeholder?.let { @Composable { Text(it) } }, colors = OutlinedTextFieldDefaults.colors( disabledBorderColor = Color.Transparent, @@ -68,7 +68,7 @@ fun TextFieldListItem( fun TextFieldListItem( placeholder: String?, text: TextFieldValue, - onTextChanged: (TextFieldValue) -> Unit, + onTextChange: (TextFieldValue) -> Unit, modifier: Modifier = Modifier, error: String? = null, maxLines: Int = 1, @@ -79,7 +79,7 @@ fun TextFieldListItem( OutlinedTextField( value = text, - onValueChange = { onTextChanged(it) }, + onValueChange = { onTextChange(it) }, placeholder = placeholder?.let { @Composable { Text(it) } }, colors = OutlinedTextFieldDefaults.colors( disabledBorderColor = Color.Transparent, @@ -105,7 +105,7 @@ internal fun TextFieldListItemEmptyPreview() { TextFieldListItem( placeholder = "Placeholder", text = "", - onTextChanged = {}, + onTextChange = {}, ) } } @@ -117,7 +117,7 @@ internal fun TextFieldListItemPreview() { TextFieldListItem( placeholder = "Placeholder", text = "Text", - onTextChanged = {}, + onTextChange = {}, ) } } @@ -129,7 +129,7 @@ internal fun TextFieldListItemTextFieldValuePreview() { TextFieldListItem( placeholder = "Placeholder", text = TextFieldValue("Text field value"), - onTextChanged = {}, + onTextChange = {}, ) } } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceCategory.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceCategory.kt index f86177d98e..bae1301615 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceCategory.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceCategory.kt @@ -19,22 +19,19 @@ package io.element.android.libraries.designsystem.components.preferences import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier 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.generated.CompoundIcons import io.element.android.libraries.designsystem.preview.ElementThemedPreview import io.element.android.libraries.designsystem.preview.PreviewGroup -import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.theme.components.ListSectionHeader @Composable fun PreferenceCategory( modifier: Modifier = Modifier, title: String? = null, - showDivider: Boolean = true, + showTopDivider: Boolean = true, content: @Composable ColumnScope.() -> Unit, ) { Column( @@ -42,30 +39,17 @@ fun PreferenceCategory( .fillMaxWidth() ) { if (title != null) { - PreferenceCategoryTitle(title = title) - } - content() - if (showDivider) { + ListSectionHeader( + title = title, + hasDivider = showTopDivider, + ) + } else if (showTopDivider) { PreferenceDivider() } + content() } } -@Composable -private fun PreferenceCategoryTitle(title: String) { - Text( - modifier = Modifier.padding( - top = 20.dp, - bottom = 8.dp, - start = preferencePaddingHorizontal, - end = preferencePaddingHorizontal, - ), - style = ElementTheme.typography.fontBodyLgMedium, - color = ElementTheme.materialColors.primary, - text = title, - ) -} - @Preview(group = PreviewGroup.Preferences) @Composable internal fun PreferenceCategoryPreview() = ElementThemedPreview { 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 cebe0ddbe2..99c1ba3f6b 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 @@ -17,25 +17,18 @@ package io.element.android.libraries.designsystem.components.preferences import androidx.annotation.DrawableRes -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.defaultMinSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme -import io.element.android.libraries.designsystem.components.preferences.components.PreferenceIcon +import io.element.android.libraries.designsystem.components.list.ListItemContent +import io.element.android.libraries.designsystem.components.preferences.components.preferenceIcon import io.element.android.libraries.designsystem.icons.CompoundDrawables import io.element.android.libraries.designsystem.preview.ElementThemedPreview import io.element.android.libraries.designsystem.preview.PreviewGroup -import io.element.android.libraries.designsystem.theme.components.Checkbox +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.toSecondaryEnabledColor @@ -52,45 +45,36 @@ fun PreferenceCheckbox( @DrawableRes iconResourceId: Int? = null, showIconAreaIfNoIcon: Boolean = false, ) { - Row( - modifier = modifier - .fillMaxWidth() - .defaultMinSize(minHeight = preferenceMinHeight) - .clickable { onCheckedChange(!isChecked) } - .padding(vertical = 4.dp, horizontal = preferencePaddingHorizontal), - verticalAlignment = Alignment.CenterVertically - ) { - PreferenceIcon( + ListItem( + modifier = modifier, + onClick = onCheckedChange.takeIf { enabled }?.let { { onCheckedChange(!isChecked) } }, + leadingContent = preferenceIcon( icon = icon, iconResourceId = iconResourceId, enabled = enabled, - isVisible = showIconAreaIfNoIcon - ) - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { + showIconAreaIfNoIcon = showIconAreaIfNoIcon, + ), + headlineContent = { Text( style = ElementTheme.typography.fontBodyLgRegular, text = title, color = enabled.toEnabledColor(), ) - if (supportingText != null) { + }, + supportingContent = supportingText?.let { + { Text( style = ElementTheme.typography.fontBodyMdRegular, - text = supportingText, + text = it, color = enabled.toSecondaryEnabledColor(), ) } - } - Checkbox( - modifier = Modifier - .align(Alignment.CenterVertically), + }, + trailingContent = ListItemContent.Checkbox( checked = isChecked, enabled = enabled, - onCheckedChange = onCheckedChange - ) - } + ), + ) } @Preview(group = PreviewGroup.Preferences) @@ -112,5 +96,31 @@ internal fun PreferenceCheckboxPreview() = ElementThemedPreview { isChecked = true, onCheckedChange = {}, ) + PreferenceCheckbox( + title = "Checkbox with supporting text", + supportingText = "Supporting text", + iconResourceId = CompoundDrawables.ic_compound_threads, + enabled = false, + isChecked = true, + onCheckedChange = {}, + ) + PreferenceCheckbox( + title = "Checkbox with supporting text", + supportingText = "Supporting text", + iconResourceId = null, + showIconAreaIfNoIcon = true, + enabled = true, + isChecked = true, + onCheckedChange = {}, + ) + PreferenceCheckbox( + title = "Checkbox with supporting text", + supportingText = "Supporting text", + iconResourceId = null, + showIconAreaIfNoIcon = false, + enabled = true, + isChecked = true, + onCheckedChange = {}, + ) } } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceDivider.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceDivider.kt index 27a846aa10..57bd88b637 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceDivider.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceDivider.kt @@ -19,7 +19,6 @@ package io.element.android.libraries.designsystem.components.preferences import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview -import io.element.android.compound.theme.ElementTheme import io.element.android.libraries.designsystem.preview.ElementThemedPreview import io.element.android.libraries.designsystem.preview.PreviewGroup import io.element.android.libraries.designsystem.theme.components.HorizontalDivider @@ -28,10 +27,7 @@ import io.element.android.libraries.designsystem.theme.components.HorizontalDivi fun PreferenceDivider( modifier: Modifier = Modifier, ) { - HorizontalDivider( - modifier = modifier, - color = ElementTheme.colors.borderDisabled, - ) + HorizontalDivider(modifier = modifier) } @Preview(group = PreviewGroup.Preferences) diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferencePage.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferencePage.kt index b02cf157d8..e2dca9641d 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferencePage.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferencePage.kt @@ -44,7 +44,7 @@ import io.element.android.libraries.designsystem.theme.components.TopAppBar @Composable fun PreferencePage( title: String, - onBackPressed: () -> Unit, + onBackClick: () -> Unit, modifier: Modifier = Modifier, snackbarHost: @Composable () -> Unit = {}, content: @Composable ColumnScope.() -> Unit, @@ -58,7 +58,7 @@ fun PreferencePage( topBar = { PreferenceTopAppBar( title = title, - onBackPressed = onBackPressed, + onBackClick = onBackClick, ) }, snackbarHost = snackbarHost, @@ -79,11 +79,11 @@ fun PreferencePage( @Composable private fun PreferenceTopAppBar( title: String, - onBackPressed: () -> Unit, + onBackClick: () -> Unit, ) { TopAppBar( navigationIcon = { - BackButton(onClick = onBackPressed) + BackButton(onClick = onBackClick) }, title = { Text( @@ -101,7 +101,7 @@ private fun PreferenceTopAppBar( internal fun PreferencePagePreview() = ElementPreview { PreferencePage( title = "Preference screen", - onBackPressed = {}, + onBackClick = {}, ) { PreferenceCategory( title = "Category title", diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceRow.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceRow.kt index 80813a2d1a..e217f5150a 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceRow.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceRow.kt @@ -19,14 +19,13 @@ package io.element.android.libraries.designsystem.components.preferences import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope 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 import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import io.element.android.libraries.designsystem.preview.ElementThemedPreview import io.element.android.libraries.designsystem.preview.PreviewGroup +import io.element.android.libraries.designsystem.theme.components.ListItem import io.element.android.libraries.designsystem.theme.components.Text /** @@ -37,15 +36,17 @@ fun PreferenceRow( modifier: Modifier = Modifier, content: @Composable RowScope.() -> Unit, ) { - Row( - modifier = modifier - .padding(horizontal = preferencePaddingHorizontal) - .heightIn(min = preferenceMinHeight) - .fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - content() - } + ListItem( + modifier = modifier, + headlineContent = { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + content() + } + } + ) } @Preview(group = PreviewGroup.Preferences) 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 392343f48e..9173c1e193 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 @@ -19,23 +19,18 @@ package io.element.android.libraries.designsystem.components.preferences import androidx.annotation.DrawableRes import androidx.annotation.FloatRange import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.defaultMinSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector 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.generated.CompoundIcons -import io.element.android.libraries.designsystem.components.preferences.components.PreferenceIcon +import io.element.android.libraries.designsystem.components.preferences.components.preferenceIcon import io.element.android.libraries.designsystem.preview.ElementThemedPreview import io.element.android.libraries.designsystem.preview.PreviewGroup +import io.element.android.libraries.designsystem.theme.components.ListItem import io.element.android.libraries.designsystem.theme.components.Slider import io.element.android.libraries.designsystem.theme.components.Text -import io.element.android.libraries.designsystem.toEnabledColor @Composable fun PreferenceSlide( @@ -51,51 +46,57 @@ fun PreferenceSlide( summary: String? = null, steps: Int = 0, ) { - Row( - modifier = modifier - .fillMaxWidth() - .defaultMinSize(minHeight = preferenceMinHeight) - .padding(vertical = 4.dp, horizontal = preferencePaddingHorizontal), - ) { - PreferenceIcon( + ListItem( + modifier = modifier, + enabled = enabled, + leadingContent = preferenceIcon( icon = icon, iconResourceId = iconResourceId, - isVisible = showIconAreaIfNoIcon, - ) - Column( - modifier = Modifier - .weight(1f), - ) { - Text( - style = ElementTheme.typography.fontBodyLgRegular, - text = title, - color = enabled.toEnabledColor(), - ) - summary?.let { + enabled = enabled, + showIconAreaIfNoIcon = showIconAreaIfNoIcon, + ), + headlineContent = { + Column { Text( - style = ElementTheme.typography.fontBodyMdRegular, - text = summary, - color = enabled.toEnabledColor(), + style = ElementTheme.typography.fontBodyLgRegular, + text = title, + ) + summary?.let { + Text( + style = ElementTheme.typography.fontBodyMdRegular, + text = summary, + ) + } + Slider( + value = value, + steps = steps, + onValueChange = onValueChange, + enabled = enabled, ) } - Slider( - value = value, - steps = steps, - onValueChange = onValueChange, - enabled = enabled, - ) } - } + ) } @Preview(group = PreviewGroup.Preferences) @Composable internal fun PreferenceSlidePreview() = ElementThemedPreview { - PreferenceSlide( - icon = CompoundIcons.UserProfile(), - title = "Slide", - summary = "Summary", - value = 0.75F, - onValueChange = {}, - ) + Column { + PreferenceSlide( + icon = CompoundIcons.UserProfile(), + title = "Slide", + summary = "Summary", + enabled = true, + value = 0.75F, + onValueChange = {}, + ) + PreferenceSlide( + icon = CompoundIcons.UserProfile(), + title = "Slide", + summary = "Summary", + enabled = false, + value = 0.75F, + onValueChange = {}, + ) + } } 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 e79e915484..388dcec23f 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 @@ -17,30 +17,19 @@ package io.element.android.libraries.designsystem.components.preferences import androidx.annotation.DrawableRes -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.defaultMinSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector 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.generated.CompoundIcons -import io.element.android.libraries.designsystem.components.preferences.components.PreferenceIcon +import io.element.android.libraries.designsystem.components.list.ListItemContent +import io.element.android.libraries.designsystem.components.preferences.components.preferenceIcon import io.element.android.libraries.designsystem.preview.ElementThemedPreview import io.element.android.libraries.designsystem.preview.PreviewGroup -import io.element.android.libraries.designsystem.theme.components.Switch +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.toSecondaryEnabledColor @Composable fun PreferenceSwitch( @@ -53,62 +42,65 @@ fun PreferenceSwitch( icon: ImageVector? = null, @DrawableRes iconResourceId: Int? = null, showIconAreaIfNoIcon: Boolean = false, - switchAlignment: Alignment.Vertical = Alignment.CenterVertically ) { - Row( - modifier = modifier - .fillMaxWidth() - .defaultMinSize(minHeight = preferenceMinHeight) - .clickable { onCheckedChange(!isChecked) } - .padding(vertical = 4.dp, horizontal = preferencePaddingHorizontal), - verticalAlignment = Alignment.CenterVertically - ) { - PreferenceIcon( + ListItem( + modifier = modifier, + enabled = enabled, + onClick = onCheckedChange.takeIf { enabled }?.let { { onCheckedChange(!isChecked) } }, + leadingContent = preferenceIcon( icon = icon, iconResourceId = iconResourceId, enabled = enabled, - isVisible = showIconAreaIfNoIcon - ) - Column( - modifier = Modifier - .weight(1f) - .align(Alignment.CenterVertically) - ) { + showIconAreaIfNoIcon = showIconAreaIfNoIcon, + ), + headlineContent = { Text( style = ElementTheme.typography.fontBodyLgRegular, text = title, - color = enabled.toEnabledColor(), ) - if (subtitle != null) { - Spacer(modifier = Modifier.height(4.dp)) + }, + supportingContent = subtitle?.let { + { Text( style = ElementTheme.typography.fontBodyMdRegular, text = subtitle, - color = enabled.toSecondaryEnabledColor(), ) } - } - Spacer(modifier = Modifier.width(16.dp)) - // TODO Create a wrapper for Switch - Switch( - modifier = Modifier - .align(switchAlignment), + }, + trailingContent = ListItemContent.Switch( checked = isChecked, enabled = enabled, - onCheckedChange = onCheckedChange ) - } + ) } @Preview(group = PreviewGroup.Preferences) @Composable internal fun PreferenceSwitchPreview() = ElementThemedPreview { - PreferenceSwitch( - title = "Switch", - subtitle = "Subtitle Switch", - icon = CompoundIcons.Threads(), - enabled = true, - isChecked = true, - onCheckedChange = {}, - ) + Column { + PreferenceSwitch( + title = "Switch", + subtitle = "Subtitle Switch", + icon = CompoundIcons.Threads(), + enabled = true, + isChecked = true, + onCheckedChange = {}, + ) + PreferenceSwitch( + title = "Switch", + subtitle = "Subtitle Switch", + icon = CompoundIcons.Threads(), + enabled = false, + isChecked = true, + onCheckedChange = {}, + ) + PreferenceSwitch( + title = "Switch no subtitle", + subtitle = null, + icon = CompoundIcons.Threads(), + enabled = false, + isChecked = true, + onCheckedChange = {}, + ) + } } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceText.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceText.kt index e8a859b54a..85f1c10b21 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceText.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceText.kt @@ -17,12 +17,9 @@ package io.element.android.libraries.designsystem.components.preferences import androidx.annotation.DrawableRes -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.defaultMinSize -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.progressSemantics @@ -38,18 +35,17 @@ import io.element.android.compound.theme.ElementTheme import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage import io.element.android.libraries.designsystem.atomic.atoms.RedIndicatorAtom -import io.element.android.libraries.designsystem.components.preferences.components.PreferenceIcon +import io.element.android.libraries.designsystem.components.list.ListItemContent +import io.element.android.libraries.designsystem.components.preferences.components.preferenceIcon import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.PreviewGroup import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator +import io.element.android.libraries.designsystem.theme.components.ListItem import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.toEnabledColor import io.element.android.libraries.designsystem.toSecondaryEnabledColor -/** - * Tried to use ListItem, but it cannot really match the design. Keep custom Layout for now. - */ @Composable fun PreferenceText( title: String, @@ -67,76 +63,76 @@ fun PreferenceText( tintColor: Color? = null, onClick: () -> Unit = {}, ) { - val minHeight = if (subtitle == null && subtitleAnnotated == null) preferenceMinHeightOnlyTitle else preferenceMinHeight - - Row( - modifier = modifier - .fillMaxWidth() - .defaultMinSize(minHeight = minHeight) - .clickable { onClick() } - .padding(horizontal = preferencePaddingHorizontal, vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - PreferenceIcon( + ListItem( + modifier = modifier, + enabled = enabled, + onClick = onClick, + leadingContent = preferenceIcon( icon = icon, iconResourceId = iconResourceId, showIconBadge = showIconBadge, enabled = enabled, - isVisible = showIconAreaIfNoIcon, - tintColor = tintColor ?: enabled.toSecondaryEnabledColor(), - ) - Column( - modifier = Modifier - .weight(1f) - .align(Alignment.CenterVertically) - ) { + showIconAreaIfNoIcon = showIconAreaIfNoIcon, + tintColor = tintColor, + ), + headlineContent = { Text( style = ElementTheme.typography.fontBodyLgRegular, text = title, color = tintColor ?: enabled.toEnabledColor(), ) - if (subtitle != null) { + }, + supportingContent = if (subtitle != null) { + { Text( style = ElementTheme.typography.fontBodyMdRegular, text = subtitle, color = tintColor ?: enabled.toSecondaryEnabledColor(), ) - } else if (subtitleAnnotated != null) { - Text( - style = ElementTheme.typography.fontBodyMdRegular, - text = subtitleAnnotated, - color = tintColor ?: enabled.toSecondaryEnabledColor(), - ) } + } else { + subtitleAnnotated?.let { + { + Text( + style = ElementTheme.typography.fontBodyMdRegular, + text = it, + color = tintColor ?: enabled.toSecondaryEnabledColor(), + ) + } + } + }, + trailingContent = if (currentValue != null || loadingCurrentValue || showEndBadge) { + ListItemContent.Custom { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + if (currentValue != null) { + Text( + text = currentValue, + style = ElementTheme.typography.fontBodyXsMedium, + color = enabled.toSecondaryEnabledColor(), + ) + } else if (loadingCurrentValue) { + CircularProgressIndicator( + modifier = Modifier + .progressSemantics() + .size(20.dp), + strokeWidth = 2.dp + ) + } + if (showEndBadge) { + val endBadgeStartPadding = if (currentValue != null || loadingCurrentValue) 16.dp else 0.dp + RedIndicatorAtom( + modifier = Modifier + .padding(start = endBadgeStartPadding) + ) + } + } + } + } else { + null } - if (currentValue != null) { - Text( - modifier = Modifier - .align(Alignment.CenterVertically) - .padding(start = 16.dp, end = 8.dp), - text = currentValue, - style = ElementTheme.typography.fontBodyXsMedium, - color = enabled.toSecondaryEnabledColor(), - ) - } else if (loadingCurrentValue) { - CircularProgressIndicator( - modifier = Modifier - .progressSemantics() - .padding(start = 16.dp, end = 8.dp) - .size(20.dp) - .align(Alignment.CenterVertically), - strokeWidth = 2.dp - ) - } - if (showEndBadge) { - val endBadgeStartPadding = if (currentValue != null || loadingCurrentValue) 8.dp else 16.dp - RedIndicatorAtom( - modifier = Modifier - .align(Alignment.CenterVertically) - .padding(start = endBadgeStartPadding) - ) - } - } + ) } @Preview(group = PreviewGroup.Preferences) diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceTextField.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceTextField.kt index 5b319e41d8..78cf40030e 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceTextField.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceTextField.kt @@ -114,7 +114,7 @@ private fun TextFieldDialog( TextFieldListItem( placeholder = placeholder.orEmpty(), text = textFieldContents, - onTextChanged = { + onTextChange = { error = if (!validation(it.text)) onValidationErrorMessage else null textFieldContents = it }, 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 61e8aabdd7..f2f1e5ffb7 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 @@ -19,7 +19,6 @@ package io.element.android.libraries.designsystem.components.preferences.compone import androidx.annotation.DrawableRes import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable @@ -31,13 +30,39 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import io.element.android.libraries.designsystem.atomic.atoms.RedIndicatorAtom +import io.element.android.libraries.designsystem.components.list.ListItemContent import io.element.android.libraries.designsystem.preview.ElementThemedPreview import io.element.android.libraries.designsystem.preview.PreviewGroup import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.toSecondaryEnabledColor @Composable -fun PreferenceIcon( +fun preferenceIcon( + icon: ImageVector? = null, + @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 { + PreferenceIcon( + icon = icon, + iconResourceId = iconResourceId, + showIconBadge = showIconBadge, + enabled = enabled, + isVisible = showIconAreaIfNoIcon, + tintColor = tintColor, + ) + } + } else { + null + } +} + +@Composable +private fun PreferenceIcon( modifier: Modifier = Modifier, icon: ImageVector? = null, @DrawableRes iconResourceId: Int? = null, @@ -54,19 +79,17 @@ fun PreferenceIcon( contentDescription = null, tint = tintColor ?: enabled.toSecondaryEnabledColor(), modifier = Modifier - .padding(end = 16.dp) .size(24.dp), ) if (showIconBadge) { RedIndicatorAtom( modifier = Modifier .align(Alignment.TopEnd) - .padding(end = 16.dp) ) } } } else if (isVisible) { - Spacer(modifier = modifier.width(40.dp)) + Spacer(modifier = modifier.width(24.dp)) } } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/ApplyIf.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/ApplyIf.kt index a18d0ef3ed..3a4a433821 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/ApplyIf.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/modifiers/ApplyIf.kt @@ -16,30 +16,26 @@ package io.element.android.libraries.designsystem.modifiers -import android.annotation.SuppressLint -import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier -import androidx.compose.ui.composed import androidx.compose.ui.platform.debugInspectorInfo +import androidx.compose.ui.platform.inspectable /** * Applies the [ifTrue] modifier when the [condition] is true, [ifFalse] otherwise. */ -@SuppressLint("UnnecessaryComposedModifier") // It's actually necessary due to the `@Composable` lambdas fun Modifier.applyIf( condition: Boolean, - ifTrue: @Composable Modifier.() -> Modifier, - ifFalse: @Composable (Modifier.() -> Modifier)? = null -): Modifier = - composed( - inspectorInfo = debugInspectorInfo { - name = "applyIf" - value = condition - } - ) { - when { - condition -> then(ifTrue(Modifier)) - ifFalse != null -> then(ifFalse(Modifier)) - else -> this - } + ifTrue: Modifier.() -> Modifier, + ifFalse: (Modifier.() -> Modifier)? = null +): Modifier = this then inspectable( + inspectorInfo = debugInspectorInfo { + name = "applyIf" + value = condition } +) { + this then when { + condition -> ifTrue(Modifier) + ifFalse != null -> ifFalse(Modifier) + else -> Modifier + } +} 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 4927a195e0..fa35864c45 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 @@ -54,14 +54,14 @@ import kotlin.math.max internal fun SimpleAlertDialogContent( content: String, submitText: String, - onSubmitClicked: () -> Unit, + onSubmitClick: () -> Unit, title: String? = null, subtitle: @Composable (() -> Unit)? = null, destructiveSubmit: Boolean = false, cancelText: String? = null, - onCancelClicked: () -> Unit = {}, + onCancelClick: () -> Unit = {}, thirdButtonText: String? = null, - onThirdButtonClicked: () -> Unit = {}, + onThirdButtonClick: () -> Unit = {}, applyPaddingToContents: Boolean = true, icon: @Composable (() -> Unit)? = null, ) { @@ -77,11 +77,11 @@ internal fun SimpleAlertDialogContent( }, submitText = submitText, destructiveSubmit = destructiveSubmit, - onSubmitClicked = onSubmitClicked, + onSubmitClick = onSubmitClick, cancelText = cancelText, - onCancelClicked = onCancelClicked, + onCancelClick = onCancelClick, thirdButtonText = thirdButtonText, - onThirdButtonClicked = onThirdButtonClicked, + onThirdButtonClick = onThirdButtonClick, applyPaddingToContents = applyPaddingToContents, ) } @@ -89,14 +89,14 @@ internal fun SimpleAlertDialogContent( @Composable internal fun SimpleAlertDialogContent( submitText: String, - onSubmitClicked: () -> Unit, + onSubmitClick: () -> Unit, title: String? = null, subtitle: @Composable (() -> Unit)? = null, destructiveSubmit: Boolean = false, cancelText: String? = null, - onCancelClicked: () -> Unit = {}, + onCancelClick: () -> Unit = {}, thirdButtonText: String? = null, - onThirdButtonClicked: () -> Unit = {}, + onThirdButtonClick: () -> Unit = {}, applyPaddingToContents: Boolean = true, enabled: Boolean = true, icon: @Composable (() -> Unit)? = null, @@ -115,7 +115,7 @@ internal fun SimpleAlertDialogContent( modifier = Modifier.testTag(TestTags.dialogNeutral), text = thirdButtonText, size = ButtonSize.Medium, - onClick = onThirdButtonClicked, + onClick = onThirdButtonClick, ) } if (cancelText != null) { @@ -123,14 +123,14 @@ internal fun SimpleAlertDialogContent( modifier = Modifier.testTag(TestTags.dialogNegative), text = cancelText, size = ButtonSize.Medium, - onClick = onCancelClicked, + onClick = onCancelClick, ) Button( modifier = Modifier.testTag(TestTags.dialogPositive), text = submitText, enabled = enabled, size = ButtonSize.Medium, - onClick = onSubmitClicked, + onClick = onSubmitClick, destructive = destructiveSubmit, ) } else { @@ -139,7 +139,7 @@ internal fun SimpleAlertDialogContent( text = submitText, enabled = enabled, size = ButtonSize.Medium, - onClick = onSubmitClicked, + onClick = onSubmitClick, destructive = destructiveSubmit, ) } @@ -174,6 +174,7 @@ internal fun SimpleAlertDialogContent( /** * Copy of M3's `AlertDialogContent` so we can use it for previews. */ +@Suppress("ContentTrailingLambda") @Composable internal fun AlertDialogContent( buttons: @Composable () -> Unit, @@ -444,7 +445,7 @@ internal fun DialogWithTitleIconAndOkButtonPreview() { content = "A dialog is a type of modal window that appears in front of app content to provide critical information," + " or prompt for a decision to be made. Learn more", submitText = "OK", - onSubmitClicked = {}, + onSubmitClick = {}, ) } } @@ -461,7 +462,7 @@ internal fun DialogWithTitleAndOkButtonPreview() { content = "A dialog is a type of modal window that appears in front of app content to provide critical information," + " or prompt for a decision to be made. Learn more", submitText = "OK", - onSubmitClicked = {}, + onSubmitClick = {}, ) } } @@ -477,7 +478,7 @@ internal fun DialogWithOnlyMessageAndOkButtonPreview() { content = "A dialog is a type of modal window that appears in front of app content to provide critical information," + " or prompt for a decision to be made. Learn more", submitText = "OK", - onSubmitClicked = {}, + onSubmitClick = {}, ) } } @@ -494,7 +495,7 @@ internal fun DialogWithDestructiveButtonPreview() { cancelText = "Cancel", submitText = "Delete", destructiveSubmit = true, - onSubmitClicked = {}, + onSubmitClick = {}, ) } } @@ -511,7 +512,7 @@ internal fun DialogWithThirdButtonPreview() { cancelText = "Cancel", submitText = "Delete", thirdButtonText = "Other", - onSubmitClicked = {}, + onSubmitClick = {}, ) } } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/ModalBottomSheetLayout.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/ModalBottomSheetLayout.kt deleted file mode 100644 index 8dd63d1cc6..0000000000 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/ModalBottomSheetLayout.kt +++ /dev/null @@ -1,132 +0,0 @@ -/* - * Copyright (c) 2023 New Vector Ltd - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -// This is actually expected, as we should remove this component soon and use ModalBottomSheet instead -@file:Suppress("UsingMaterialAndMaterial3Libraries") - -package io.element.android.libraries.designsystem.theme.components - -import androidx.compose.foundation.background -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.ColumnScope -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.size -import androidx.compose.foundation.shape.CornerSize -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.ExperimentalMaterialApi -import androidx.compose.material.ModalBottomSheetDefaults -import androidx.compose.material.ModalBottomSheetState -import androidx.compose.material.ModalBottomSheetValue -import androidx.compose.material.rememberModalBottomSheetState -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.contentColorFor -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Shape -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.Dp -import androidx.compose.ui.unit.dp -import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage -import io.element.android.libraries.designsystem.modifiers.applyIf -import io.element.android.libraries.designsystem.preview.ElementPreviewDark -import io.element.android.libraries.designsystem.preview.ElementPreviewLight -import io.element.android.libraries.designsystem.preview.PreviewGroup - -@OptIn(ExperimentalMaterialApi::class) -@Composable -fun ModalBottomSheetLayout( - sheetContent: @Composable ColumnScope.() -> Unit, - modifier: Modifier = Modifier, - sheetState: ModalBottomSheetState = rememberModalBottomSheetState(ModalBottomSheetValue.Hidden), - sheetShape: Shape = MaterialTheme.shapes.large.copy(bottomStart = CornerSize(0.dp), bottomEnd = CornerSize(0.dp)), - sheetElevation: Dp = ModalBottomSheetDefaults.Elevation, - sheetBackgroundColor: Color = MaterialTheme.colorScheme.surface, - sheetContentColor: Color = contentColorFor(sheetBackgroundColor), - scrimColor: Color = ModalBottomSheetDefaults.scrimColor, - displayHandle: Boolean = false, - useSystemPadding: Boolean = true, - content: @Composable () -> Unit = {} -) { - androidx.compose.material.ModalBottomSheetLayout( - sheetContent = { - Column( - Modifier.fillMaxWidth() - .applyIf(useSystemPadding, ifTrue = { - navigationBarsPadding() - }) - ) { - if (displayHandle) { - Spacer(modifier = Modifier.height(16.dp)) - Box( - modifier = Modifier - .background(MaterialTheme.colorScheme.onSurfaceVariant, RoundedCornerShape(2.dp)) - .size(width = 32.dp, height = 4.dp) - .align(Alignment.CenterHorizontally), - ) - Spacer(modifier = Modifier.height(24.dp)) - } - sheetContent() - } - }, - modifier = modifier, - sheetState = sheetState, - sheetShape = sheetShape, - sheetElevation = sheetElevation, - sheetBackgroundColor = sheetBackgroundColor, - sheetContentColor = sheetContentColor, - scrimColor = scrimColor, - content = content, - ) -} - -@Preview(group = PreviewGroup.BottomSheets) -@Composable -internal fun ModalBottomSheetLayoutLightPreview() = - ElementPreviewLight { ContentToPreview() } - -@Preview(group = PreviewGroup.BottomSheets) -@Composable -internal fun ModalBottomSheetLayoutDarkPreview() = - ElementPreviewDark { ContentToPreview() } - -@OptIn(ExperimentalMaterialApi::class) -@ExcludeFromCoverage -@Composable -private fun ContentToPreview() { - ModalBottomSheetLayout( - modifier = Modifier.height(140.dp), - displayHandle = true, - sheetState = ModalBottomSheetState(ModalBottomSheetValue.Expanded, density = LocalDensity.current), - sheetContent = { - Text( - text = "Sheet Content", - modifier = Modifier - .padding(start = 16.dp, end = 16.dp, bottom = 20.dp) - .background(color = Color.Green) - ) - } - ) { - Text(text = "Content", modifier = Modifier.background(color = Color.Red)) - } -} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Slider.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Slider.kt index 65f43bb250..182ab961d6 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Slider.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/Slider.kt @@ -39,7 +39,7 @@ fun Slider( valueRange: ClosedFloatingPointRange = 0f..1f, // @IntRange(from = 0) steps: Int = 0, - onValueChangeFinished: (() -> Unit)? = null, + onValueChangeFinish: (() -> Unit)? = null, colors: SliderColors = SliderDefaults.colors(), interactionSource: MutableInteractionSource = remember { MutableInteractionSource() } ) { @@ -50,7 +50,7 @@ fun Slider( enabled = enabled, valueRange = valueRange, steps = steps, - onValueChangeFinished = onValueChangeFinished, + onValueChangeFinished = onValueChangeFinish, colors = colors, interactionSource = interactionSource, ) diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/TextField.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/TextField.kt index f2c004d2ca..10fb4a2906 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/TextField.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/theme/components/TextField.kt @@ -213,6 +213,7 @@ private fun TextFieldValueContentToPreview() { } } +@Suppress("ModifierComposed") @OptIn(ExperimentalComposeUiApi::class) fun Modifier.autofill(autofillTypes: List, onFill: (String) -> Unit) = composed { val autofillNode = AutofillNode(autofillTypes, onFill = onFill) diff --git a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/RoomMembershipContentFormatter.kt b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/RoomMembershipContentFormatter.kt index 1c58cffd43..72748a84ff 100644 --- a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/RoomMembershipContentFormatter.kt +++ b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/RoomMembershipContentFormatter.kt @@ -34,6 +34,7 @@ class RoomMembershipContentFormatter @Inject constructor( ): CharSequence? { val userId = membershipContent.userId val memberIsYou = matrixClient.isMe(userId) + val userDisplayNameOrId = membershipContent.userDisplayName ?: userId.value return when (membershipContent.change) { MembershipChange.JOINED -> if (memberIsYou) { sp.getString(R.string.state_event_room_join_by_you) @@ -46,41 +47,41 @@ class RoomMembershipContentFormatter @Inject constructor( sp.getString(R.string.state_event_room_leave, senderDisambiguatedDisplayName) } MembershipChange.BANNED, MembershipChange.KICKED_AND_BANNED -> if (senderIsYou) { - sp.getString(R.string.state_event_room_ban_by_you, userId.value) + sp.getString(R.string.state_event_room_ban_by_you, userDisplayNameOrId) } else { - sp.getString(R.string.state_event_room_ban, senderDisambiguatedDisplayName, userId.value) + sp.getString(R.string.state_event_room_ban, senderDisambiguatedDisplayName, userDisplayNameOrId) } MembershipChange.UNBANNED -> if (senderIsYou) { - sp.getString(R.string.state_event_room_unban_by_you, userId.value) + sp.getString(R.string.state_event_room_unban_by_you, userDisplayNameOrId) } else { - sp.getString(R.string.state_event_room_unban, senderDisambiguatedDisplayName, userId.value) + sp.getString(R.string.state_event_room_unban, senderDisambiguatedDisplayName, userDisplayNameOrId) } MembershipChange.KICKED -> if (senderIsYou) { - sp.getString(R.string.state_event_room_remove_by_you, userId.value) + sp.getString(R.string.state_event_room_remove_by_you, userDisplayNameOrId) } else { - sp.getString(R.string.state_event_room_remove, senderDisambiguatedDisplayName, userId.value) + sp.getString(R.string.state_event_room_remove, senderDisambiguatedDisplayName, userDisplayNameOrId) } MembershipChange.INVITED -> if (senderIsYou) { - sp.getString(R.string.state_event_room_invite_by_you, userId.value) + sp.getString(R.string.state_event_room_invite_by_you, userDisplayNameOrId) } else if (memberIsYou) { sp.getString(R.string.state_event_room_invite_you, senderDisambiguatedDisplayName) } else { - sp.getString(R.string.state_event_room_invite, senderDisambiguatedDisplayName, userId.value) + sp.getString(R.string.state_event_room_invite, senderDisambiguatedDisplayName, userDisplayNameOrId) } MembershipChange.INVITATION_ACCEPTED -> if (memberIsYou) { sp.getString(R.string.state_event_room_invite_accepted_by_you) } else { - sp.getString(R.string.state_event_room_invite_accepted, userId.value) + sp.getString(R.string.state_event_room_invite_accepted, userDisplayNameOrId) } MembershipChange.INVITATION_REJECTED -> if (memberIsYou) { sp.getString(R.string.state_event_room_reject_by_you) } else { - sp.getString(R.string.state_event_room_reject, userId.value) + sp.getString(R.string.state_event_room_reject, userDisplayNameOrId) } MembershipChange.INVITATION_REVOKED -> if (senderIsYou) { - sp.getString(R.string.state_event_room_third_party_revoked_invite_by_you, userId.value) + sp.getString(R.string.state_event_room_third_party_revoked_invite_by_you, userDisplayNameOrId) } else { - sp.getString(R.string.state_event_room_third_party_revoked_invite, senderDisambiguatedDisplayName, userId.value) + sp.getString(R.string.state_event_room_third_party_revoked_invite, senderDisambiguatedDisplayName, userDisplayNameOrId) } MembershipChange.KNOCKED -> if (memberIsYou) { sp.getString(R.string.state_event_room_knock_by_you) @@ -88,9 +89,9 @@ class RoomMembershipContentFormatter @Inject constructor( sp.getString(R.string.state_event_room_knock, senderDisambiguatedDisplayName) } MembershipChange.KNOCK_ACCEPTED -> if (senderIsYou) { - sp.getString(R.string.state_event_room_knock_accepted_by_you, userId.value) + sp.getString(R.string.state_event_room_knock_accepted_by_you, userDisplayNameOrId) } else { - sp.getString(R.string.state_event_room_knock_accepted, senderDisambiguatedDisplayName, userId.value) + sp.getString(R.string.state_event_room_knock_accepted, senderDisambiguatedDisplayName, userDisplayNameOrId) } MembershipChange.KNOCK_RETRACTED -> if (memberIsYou) { sp.getString(R.string.state_event_room_knock_retracted_by_you) @@ -98,11 +99,11 @@ class RoomMembershipContentFormatter @Inject constructor( sp.getString(R.string.state_event_room_knock_retracted, senderDisambiguatedDisplayName) } MembershipChange.KNOCK_DENIED -> if (senderIsYou) { - sp.getString(R.string.state_event_room_knock_denied_by_you, userId.value) + sp.getString(R.string.state_event_room_knock_denied_by_you, userDisplayNameOrId) } else if (memberIsYou) { sp.getString(R.string.state_event_room_knock_denied_you, senderDisambiguatedDisplayName) } else { - sp.getString(R.string.state_event_room_knock_denied, senderDisambiguatedDisplayName, userId.value) + sp.getString(R.string.state_event_room_knock_denied, senderDisambiguatedDisplayName, userDisplayNameOrId) } MembershipChange.NONE -> if (senderIsYou) { sp.getString(R.string.state_event_room_none_by_you) diff --git a/libraries/eventformatter/impl/src/main/res/values-es/translations.xml b/libraries/eventformatter/impl/src/main/res/values-es/translations.xml index dc732d9e97..33ed87e423 100644 --- a/libraries/eventformatter/impl/src/main/res/values-es/translations.xml +++ b/libraries/eventformatter/impl/src/main/res/values-es/translations.xml @@ -3,12 +3,16 @@ "(el avatar también cambió)" "%1$s cambió su avatar" "Cambiaste tu avatar" + "%1$s fue degradado a miembro" + "%1$s fue degradado a moderador" "%1$s cambió su nombre de %2$s a %3$s" "Cambiaste tu nombre de %1$s a %2$s" "%1$s eliminó su nombre (era %2$s)" "Eliminaste tu nombre (era %1$s)" "%1$s cambió su nombre a %2$s" "Cambiaste tu nombre a %1$s" + "%1$s fue ascendido a administrador" + "%1$s fue ascendido a moderador" "%1$s cambió el avatar de la sala" "Cambiaste el avatar de la sala" "%1$s eliminó el avatar de la sala" @@ -39,6 +43,8 @@ "Cambiaste el nombre de la sala a: %1$s" "%1$s eliminó el nombre de la sala" "Eliminaste el nombre de la sala" + "%1$s no hizo cambios" + "No has hecho ningún cambio" "%1$s rechazó la invitación" "Rechazaste la invitación" "%1$s echó a %2$s" diff --git a/libraries/eventformatter/impl/src/main/res/values-it/translations.xml b/libraries/eventformatter/impl/src/main/res/values-it/translations.xml index f5250ab40e..2d391404c7 100644 --- a/libraries/eventformatter/impl/src/main/res/values-it/translations.xml +++ b/libraries/eventformatter/impl/src/main/res/values-it/translations.xml @@ -30,7 +30,7 @@ "Ti sei unito alla stanza" "%1$s ha chiesto di unirsi" "%1$s ha permesso a %2$s di unirsi" - "%1$s ti ha permesso di unirti" + "Hai permesso a %1$s di partecipare" "Hai richiesto di unirti" "%1$s ha rifiutato la richiesta di unirsi di %2$s" "Hai rifiutato la richiesta di unirsi di %1$s" diff --git a/libraries/eventformatter/impl/src/main/res/values-ka/translations.xml b/libraries/eventformatter/impl/src/main/res/values-ka/translations.xml new file mode 100644 index 0000000000..7fa4f65716 --- /dev/null +++ b/libraries/eventformatter/impl/src/main/res/values-ka/translations.xml @@ -0,0 +1,57 @@ + + + "(ფოტოც შეიცვალა)" + "%1$s პროფილის ფოტო შეცვალა" + "თქვენ შეცვალეთ პროფილის ფოტო" + "%1$s თავისი ნაჩვენები სახელი შეცვალა %2$s დან %3$s ზე" + "თქვენ შეცვალეთ თქვენი ნაჩვენები სახელი %1$s -დან %2$s -ზე" + "%1$s წაშალა თავისი ნაჩვენები სახელი (იყო %2$s)" + "თქვენ წაშალეთ ნაჩვენები სახელი (იყო %1$s)" + "%1$s თავისი ნაჩვენები სახელი შეცვალა %2$s" + "თქვენი ახალი ნაჩვენები სახელი - %1$s" + "%1$s ოთახის ფოტო შეცვალა" + "თქვენ შეცვალეთ ოთახის ფოტო" + "%1$s წაშალა ოთახის ფოტო" + "თქვენ წაშალეთ ოთახის ფოტო" + "%1$s დაბლოკა %2$s" + "თქვენ დაბლოკეთ %1$s" + "%1$s შექმნა ოთახი" + "თქვენ შექმენით ოთახი" + "%1$s მოიწვია %2$s" + "%1$s მიიღო მოწვევა" + "თქვენ მიიღეთ მოწვევა" + "თქვენ მოიწვიეთ %1$s" + "%1$s მოგიწვიათ" + "%1$s გაწევრიანდა ოთახში" + "თქვენ გაწევრიანდით ოთახში" + "%1$s გაწევრიანება მოითხოვა" + "%1$s გაწევრიანების უფლება მისცა %2$s" + "თქვენ %1$s გაწევრიანების უფლება მიეცით" + "თქვენ მოითხოვეთ გაწევრიანება" + "%1$s უარი თქვა %2$s-ს გაწევრიანების მოთხოვნაზე" + "თქვენ უარი თქვით %1$s გაწევრიანების თხოვნაზე" + "%1$s უარი თქვა თქვენს მოთხოვნაზე გაწევრიანების შესახებ" + "%1$s აღარ არის დაინტერესებული გაწევრიანებით" + "თქვენ გააუქმეთ გაწევრიანების მოთხოვნა" + "%1$s დატოვა ოთახი" + "თქვენ დატოვეთ ოთახი" + "%1$s შეცვალა ოთახის სახელი: %2$s" + "თქვენ შეცვალეთ ოთახის სახელი: %1$s" + "%1$s წაშალა ოთახის სახელი" + "თქვენ წაშალეთ ოთახის სახელი" + "%1$s მოწვევაზე უარი თქვა" + "თქვენ უარი თქვით მოწვევაზე" + "%1$s გააგდო %2$s" + "თქვენ გააგდეთ %1$s" + "%1$s მოიწვია %2$s ოთახში" + "თქვენ მოიწვიეთ %1$s ოთახში" + "%1$s გააუქმო %2$s-ს ოთახში მოწვევა" + "თქვენ %1$s-ს ოთახში მოწვევა გააუქმეთ" + "%1$s შეცვალა თემა: %2$s" + "თქვენ შეცვალეთ თემა: %1$s" + "%1$s წაშალა ოთახის თემა" + "თქვენ წაშალეთ ოთახის თემა" + "%1$s განბლოკა %2$s" + "თქვენ განბლოკეთ %1$s" + "%1$s უცნობი ცვლილება შეიტანა თავის წევრობაში" + diff --git a/libraries/eventformatter/impl/src/main/res/values-pt-rBR/translations.xml b/libraries/eventformatter/impl/src/main/res/values-pt-rBR/translations.xml new file mode 100644 index 0000000000..6d214ab03e --- /dev/null +++ b/libraries/eventformatter/impl/src/main/res/values-pt-rBR/translations.xml @@ -0,0 +1,63 @@ + + + "(avatar alterado também)" + "%1$s alterou o seu avatar" + "Alteraste o teu avatar" + "%1$s foi despromovido a participante" + "%1$s foi despromovido a moderador" + "%1$s alterou o seu pseudónimo de %2$s para %3$s" + "Alteraste o teu pseudónimo de %1$s para %2$s" + "%1$s removeu o seu pseudónimo (era %2$s)" + "Removeste o teu pseudónimo (era %1$s)" + "%1$s definiu o seu pseudónimo como %2$s" + "Definiste o teu pseudónimo como %1$s" + "%1$s foi promovido a administrador" + "%1$s foi promovido a moderador" + "%1$s alterou o ícone da sala" + "Alteraste o ícone da sala" + "%1$s removeu o ícone da sala" + "Removeste o ícone da sala" + "%1$s baniu %2$s" + "Baniste %1$s" + "%1$s criou a sala" + "Criaste a sala" + "%1$s convidou %2$s" + "%1$s aceitou o convite" + "Aceitaste o convite" + "Convidaste %1$s" + "%1$s convidou-te" + "%1$s entrou na sala" + "Entraste na sala" + "%1$s pediu para entrar" + "%1$s permitiu %2$s entrar" + "Permitiste a entrada de %1$s" + "Pediste para entrar" + "%1$s rejeitou o pedido de entrada de %2$s" + "Rejeitaste o pedido de entrada e %1$s" + "%1$s rejeitou o teu pedido de entrada" + "%1$s deixou de querer entrar" + "Cancelaste o teu pedido de entrada" + "%1$s saiu da sala" + "Saíste da sala" + "%1$s alterou o nome da sala para: %2$s" + "Alteraste o nome da sala para:%1$s" + "%1$s removeu o nome da sala" + "Removeste o nome da sala" + "%1$s não fiz nenhuma alteração" + "Não fizeste nenhuma alteração" + "%1$s rejeitou o convite" + "Rejeitaste o convite" + "%1$s removeu %2$s" + "Removeste %1$s" + "%1$s enviou um convite a %2$s" + "Enviaste um convite a %1$s" + "%1$s revogou o convite de %2$s" + "Revogaste o convite de %1$s" + "%1$s alterou a descrição para: %2$s" + "Alteraste a descrição para: %1$s" + "%1$s removeu a descrição da sala" + "Removeste a descrição da sala" + "%1$s desbaniu %2$s" + "Anulaste o banimento de %1$s" + "%1$s efetuou uma alteração desconhecida à sua participação na sala" + diff --git a/libraries/eventformatter/impl/src/main/res/values-ro/translations.xml b/libraries/eventformatter/impl/src/main/res/values-ro/translations.xml index e3009c4648..8669ff4211 100644 --- a/libraries/eventformatter/impl/src/main/res/values-ro/translations.xml +++ b/libraries/eventformatter/impl/src/main/res/values-ro/translations.xml @@ -3,12 +3,16 @@ "(s-a schimbat si avatarul)" "%1$s și-a schimbat avatarul" "V-ați schimbat avatarul" + "%1$s a fost retrogradat la funcția de membru" + "%1$s a fost retrogradat la funcția de moderator" "%1$s și-a schimbat numele din %2$s în %3$s" "V-ați schimbat numele din %1$s în %2$s" "%1$s și-a sters numele (era %2$s)" "V-ați sters numele (era %1$s)" "%1$s și-a schimbat numele %2$s" "V-ați schimbat numele în %1$s" + "%1$s a fost promovat în funcția de administrator" + "%1$s a fost promovat la funcția de moderator" "%1$s a schimbat avatarul camerei" "Ați schimbat avatarul camerei" "%1$s a șters avatarul camerei" @@ -26,7 +30,7 @@ "Ați intrat în cameră" "%1$s a solicitat să se alăture camerei" "%1$s i-a permis lui %2$s să se alăture camerei" - "%1$s v-a permis să vă alăturați camerei" + "I-ați permis lui %1$s să se alăture" "Ați solicitat să vă alăturați camerei" "%1$s a respins solicitarea de alăturare a lui %2$s" "Ați respins solicitarea de alăturare a lui %1$s" diff --git a/libraries/eventformatter/impl/src/main/res/values-ru/translations.xml b/libraries/eventformatter/impl/src/main/res/values-ru/translations.xml index 5e1cde283e..186c594be2 100644 --- a/libraries/eventformatter/impl/src/main/res/values-ru/translations.xml +++ b/libraries/eventformatter/impl/src/main/res/values-ru/translations.xml @@ -30,7 +30,7 @@ "Вы присоединились к комнате" "%1$s запросил присоединение" "%1$s разрешил %2$s присоединиться" - "%1$s разрешил вам присоединиться" + "Вы разрешили %1$s присоединиться" "Вы запросили присоединение" "%1$s отклонил запрос %2$s на присоединение" "Вы отклонили запрос %1$s на присоединение" diff --git a/libraries/eventformatter/impl/src/main/res/values-zh/translations.xml b/libraries/eventformatter/impl/src/main/res/values-zh/translations.xml new file mode 100644 index 0000000000..76a61c6ccc --- /dev/null +++ b/libraries/eventformatter/impl/src/main/res/values-zh/translations.xml @@ -0,0 +1,63 @@ + + + "(头像也更改了)" + "%1$s 更换了头像" + "你更换了头像" + "%1$s 降级为成员" + "%1$s 降级为协管员" + "%1$s 把显示名称从 %2$s 更改为 %3$s" + "你将显示名称从 %1$s 更改为 %2$s" + "%1$s 移除了其显示名称(原为 %2$s)" + "你移除了自己的显示名称(原为 %1$s)" + "%1$s 将其显示名称设置为 %2$s" + "你将显示名称设置为 %1$s" + "%1$s 晋升为管理员" + "%1$s 晋升为协管员" + "%1$s 更换了房间头像" + "你更换了房间头像" + "%1$s 移除了房间头像" + "你移除了房间头像" + "%1$s 封禁了 %2$s" + "你封禁了 %1$s" + "%1$s 创建了房间" + "你创建了房间" + "%1$s 邀请了 %2$s" + "%1$s 接受了邀请" + "你接受了邀请" + "你邀请了 %1$s" + "%1$s 邀请了你" + "%1$s 加入了房间" + "你加入了房间" + "%1$s 请求加入" + "%1$s 允许 %2$s 加入" + "您已允许 %1$s 加入" + "你已请求加入" + "%1$s 拒绝了 %2$s 的加入请求" + "你拒绝了 %1$s 的加入请求" + "%1$s 拒绝了你的加入请求" + "%1$s 已不再想加入" + "你取消了加入申请" + "%1$s 离开了房间" + "你离开了房间" + "%1$s 将房间名称改为 %2$s" + "你把房间名称改为 %1$s" + "%1$s 移除了房间名称" + "你移除了房间名称" + "%1$s 没有任何更改" + "您未进行任何更改" + "%1$s 拒绝了邀请" + "你拒绝了邀请" + "%1$s 移除了 %2$s" + "你移除了 %1$s" + "%1$s向%2$s发送了加入房间的邀请" + "你邀请 %1$s 加入房间" + "%1$s 撤销了 %2$s 加入房间的邀请" + "你撤销了 %1$s 加入房间的邀请" + "%1$s 将主题改为:%2$s" + "你将主题改为:%1$s" + "%1$s 移除了房间主题" + "你移除了房间主题" + "%1$s 解禁了 %2$s" + "你解禁了 %1$s" + "%1$s 对其成员资格进行了未知更改" + diff --git a/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatterTest.kt b/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatterTest.kt index 9ee1dce352..f5e151b541 100644 --- a/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatterTest.kt +++ b/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatterTest.kt @@ -254,9 +254,9 @@ class DefaultRoomLastMessageFormatterTest { @Test @Config(qualifiers = "en") fun `Membership change - joined`() { - val otherName = "Someone" - val youContent = RoomMembershipContent(A_USER_ID, MembershipChange.JOINED) - val someoneContent = RoomMembershipContent(UserId("@someone_else:domain"), MembershipChange.JOINED) + val otherName = "Other" + val youContent = RoomMembershipContent(A_USER_ID, null, MembershipChange.JOINED) + val someoneContent = RoomMembershipContent(UserId("@someone_else:domain"), otherName, MembershipChange.JOINED) val youJoinedRoomEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = youContent) val youJoinedRoom = formatter.format(youJoinedRoomEvent, false) @@ -270,9 +270,9 @@ class DefaultRoomLastMessageFormatterTest { @Test @Config(qualifiers = "en") fun `Membership change - left`() { - val otherName = "Someone" - val youContent = RoomMembershipContent(A_USER_ID, MembershipChange.LEFT) - val someoneContent = RoomMembershipContent(UserId("@someone_else:domain"), MembershipChange.LEFT) + val otherName = "Other" + val youContent = RoomMembershipContent(A_USER_ID, null, MembershipChange.LEFT) + val someoneContent = RoomMembershipContent(UserId("@someone_else:domain"), otherName, MembershipChange.LEFT) val youLeftRoomEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = youContent) val youLeftRoom = formatter.format(youLeftRoomEvent, false) @@ -286,67 +286,71 @@ class DefaultRoomLastMessageFormatterTest { @Test @Config(qualifiers = "en") fun `Membership change - banned`() { - val otherName = "Someone" - val youContent = RoomMembershipContent(UserId("@someone_else:domain"), MembershipChange.BANNED) - val youKickedContent = RoomMembershipContent(UserId("@someone_else:domain"), MembershipChange.KICKED_AND_BANNED) - val someoneContent = RoomMembershipContent(UserId("@someone_else:domain"), MembershipChange.BANNED) - val someoneKickedContent = RoomMembershipContent(UserId("@someone_else:domain"), MembershipChange.KICKED_AND_BANNED) + val otherName = "Other" + val third = "Someone" + val youContent = RoomMembershipContent(UserId("@someone_else:domain"), third, MembershipChange.BANNED) + val youKickedContent = RoomMembershipContent(UserId("@someone_else:domain"), third, MembershipChange.KICKED_AND_BANNED) + val someoneContent = RoomMembershipContent(UserId("@someone_else:domain"), third, MembershipChange.BANNED) + val someoneKickedContent = RoomMembershipContent(UserId("@someone_else:domain"), third, MembershipChange.KICKED_AND_BANNED) val youBannedEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = youContent) val youBanned = formatter.format(youBannedEvent, false) - assertThat(youBanned).isEqualTo("You banned ${youContent.userId}") + assertThat(youBanned).isEqualTo("You banned $third") val youKickBannedEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = youKickedContent) val youKickedBanned = formatter.format(youKickBannedEvent, false) - assertThat(youKickedBanned).isEqualTo("You banned ${youContent.userId}") + assertThat(youKickedBanned).isEqualTo("You banned $third") val someoneBannedEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent) val someoneBanned = formatter.format(someoneBannedEvent, false) - assertThat(someoneBanned).isEqualTo("$otherName banned ${someoneContent.userId}") + assertThat(someoneBanned).isEqualTo("$otherName banned $third") val someoneKickBannedEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = someoneKickedContent) val someoneKickBanned = formatter.format(someoneKickBannedEvent, false) - assertThat(someoneKickBanned).isEqualTo("$otherName banned ${someoneContent.userId}") + assertThat(someoneKickBanned).isEqualTo("$otherName banned $third") } @Test @Config(qualifiers = "en") fun `Membership change - unban`() { - val otherName = "Someone" - val youContent = RoomMembershipContent(UserId("@someone_else:domain"), MembershipChange.UNBANNED) - val someoneContent = RoomMembershipContent(UserId("@someone_else:domain"), MembershipChange.UNBANNED) + val otherName = "Other" + val third = "Someone" + val youContent = RoomMembershipContent(UserId("@someone_else:domain"), third, MembershipChange.UNBANNED) + val someoneContent = RoomMembershipContent(UserId("@someone_else:domain"), third, MembershipChange.UNBANNED) val youUnbannedEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = youContent) val youUnbanned = formatter.format(youUnbannedEvent, false) - assertThat(youUnbanned).isEqualTo("You unbanned ${youContent.userId}") + assertThat(youUnbanned).isEqualTo("You unbanned $third") val someoneUnbannedEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent) val someoneUnbanned = formatter.format(someoneUnbannedEvent, false) - assertThat(someoneUnbanned).isEqualTo("$otherName unbanned ${someoneContent.userId}") + assertThat(someoneUnbanned).isEqualTo("$otherName unbanned $third") } @Test @Config(qualifiers = "en") fun `Membership change - kicked`() { - val otherName = "Someone" - val youContent = RoomMembershipContent(UserId("@someone_else:domain"), MembershipChange.KICKED) - val someoneContent = RoomMembershipContent(UserId("@someone_else:domain"), MembershipChange.KICKED) + val otherName = "Other" + val third = "Someone" + val youContent = RoomMembershipContent(UserId("@someone_else:domain"), third, MembershipChange.KICKED) + val someoneContent = RoomMembershipContent(UserId("@someone_else:domain"), third, MembershipChange.KICKED) val youKickedEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = youContent) val youKicked = formatter.format(youKickedEvent, false) - assertThat(youKicked).isEqualTo("You removed ${youContent.userId}") + assertThat(youKicked).isEqualTo("You removed $third") val someoneKickedEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent) val someoneKicked = formatter.format(someoneKickedEvent, false) - assertThat(someoneKicked).isEqualTo("$otherName removed ${someoneContent.userId}") + assertThat(someoneKicked).isEqualTo("$otherName removed $third") } @Test @Config(qualifiers = "en") fun `Membership change - invited`() { - val otherName = "Someone" - val youContent = RoomMembershipContent(A_USER_ID, MembershipChange.INVITED) - val someoneContent = RoomMembershipContent(UserId("@someone_else:domain"), MembershipChange.INVITED) + val otherName = "Other" + val third = "Someone" + val youContent = RoomMembershipContent(A_USER_ID, null, MembershipChange.INVITED) + val someoneContent = RoomMembershipContent(UserId("@someone_else:domain"), third, MembershipChange.INVITED) val youWereInvitedEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = youContent) val youWereInvited = formatter.format(youWereInvitedEvent, false) @@ -354,19 +358,19 @@ class DefaultRoomLastMessageFormatterTest { val youInvitedEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = someoneContent) val youInvited = formatter.format(youInvitedEvent, false) - assertThat(youInvited).isEqualTo("You invited ${someoneContent.userId}") + assertThat(youInvited).isEqualTo("You invited $third") val someoneInvitedEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent) val someoneInvited = formatter.format(someoneInvitedEvent, false) - assertThat(someoneInvited).isEqualTo("$otherName invited ${someoneContent.userId}") + assertThat(someoneInvited).isEqualTo("$otherName invited $third") } @Test @Config(qualifiers = "en") fun `Membership change - invitation accepted`() { - val otherName = "Someone" - val youContent = RoomMembershipContent(A_USER_ID, MembershipChange.INVITATION_ACCEPTED) - val someoneContent = RoomMembershipContent(UserId("@someone_else:domain"), MembershipChange.INVITATION_ACCEPTED) + val otherName = "Other" + val youContent = RoomMembershipContent(A_USER_ID, null, MembershipChange.INVITATION_ACCEPTED) + val someoneContent = RoomMembershipContent(UserId("@someone_else:domain"), otherName, MembershipChange.INVITATION_ACCEPTED) val youAcceptedInviteEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = youContent) val youAcceptedInvite = formatter.format(youAcceptedInviteEvent, false) @@ -374,15 +378,15 @@ class DefaultRoomLastMessageFormatterTest { val someoneAcceptedInviteEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent) val someoneAcceptedInvite = formatter.format(someoneAcceptedInviteEvent, false) - assertThat(someoneAcceptedInvite).isEqualTo("${someoneContent.userId} accepted the invite") + assertThat(someoneAcceptedInvite).isEqualTo("$otherName accepted the invite") } @Test @Config(qualifiers = "en") fun `Membership change - invitation rejected`() { - val otherName = "Someone" - val youContent = RoomMembershipContent(A_USER_ID, MembershipChange.INVITATION_REJECTED) - val someoneContent = RoomMembershipContent(UserId("@someone_else:domain"), MembershipChange.INVITATION_REJECTED) + val otherName = "Other" + val youContent = RoomMembershipContent(A_USER_ID, null, MembershipChange.INVITATION_REJECTED) + val someoneContent = RoomMembershipContent(UserId("@someone_else:domain"), otherName, MembershipChange.INVITATION_REJECTED) val youRejectedInviteEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = youContent) val youRejectedInvite = formatter.format(youRejectedInviteEvent, false) @@ -390,30 +394,31 @@ class DefaultRoomLastMessageFormatterTest { val someoneRejectedInviteEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent) val someoneRejectedInvite = formatter.format(someoneRejectedInviteEvent, false) - assertThat(someoneRejectedInvite).isEqualTo("${someoneContent.userId} rejected the invitation") + assertThat(someoneRejectedInvite).isEqualTo("$otherName rejected the invitation") } @Test @Config(qualifiers = "en") fun `Membership change - invitation revoked`() { - val otherName = "Someone" - val someoneContent = RoomMembershipContent(UserId("@someone_else:domain"), MembershipChange.INVITATION_REVOKED) + val otherName = "Other" + val third = "Someone" + val someoneContent = RoomMembershipContent(UserId("@someone_else:domain"), third, MembershipChange.INVITATION_REVOKED) val youRevokedInviteEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = someoneContent) val youRevokedInvite = formatter.format(youRevokedInviteEvent, false) - assertThat(youRevokedInvite).isEqualTo("You revoked the invitation for ${someoneContent.userId} to join the room") + assertThat(youRevokedInvite).isEqualTo("You revoked the invitation for $third to join the room") val someoneRevokedInviteEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent) val someoneRevokedInvite = formatter.format(someoneRevokedInviteEvent, false) - assertThat(someoneRevokedInvite).isEqualTo("$otherName revoked the invitation for ${someoneContent.userId} to join the room") + assertThat(someoneRevokedInvite).isEqualTo("$otherName revoked the invitation for $third to join the room") } @Test @Config(qualifiers = "en") fun `Membership change - knocked`() { - val otherName = "Someone" - val youContent = RoomMembershipContent(A_USER_ID, MembershipChange.KNOCKED) - val someoneContent = RoomMembershipContent(UserId("@someone_else:domain"), MembershipChange.KNOCKED) + val otherName = "Other" + val youContent = RoomMembershipContent(A_USER_ID, null, MembershipChange.KNOCKED) + val someoneContent = RoomMembershipContent(UserId("@someone_else:domain"), otherName, MembershipChange.KNOCKED) val youKnockedEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = youContent) val youKnocked = formatter.format(youKnockedEvent, false) @@ -427,24 +432,25 @@ class DefaultRoomLastMessageFormatterTest { @Test @Config(qualifiers = "en") fun `Membership change - knock accepted`() { - val otherName = "Someone" - val someoneContent = RoomMembershipContent(UserId("@someone_else:domain"), MembershipChange.KNOCK_ACCEPTED) + val otherName = "Other" + val third = "Someone" + val someoneContent = RoomMembershipContent(UserId("@someone_else:domain"), third, MembershipChange.KNOCK_ACCEPTED) val youAcceptedKnockEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = someoneContent) val youAcceptedKnock = formatter.format(youAcceptedKnockEvent, false) - assertThat(youAcceptedKnock).isEqualTo("You allowed ${someoneContent.userId} to join") + assertThat(youAcceptedKnock).isEqualTo("You allowed $third to join") val someoneAcceptedKnockEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent) val someoneAcceptedKnock = formatter.format(someoneAcceptedKnockEvent, false) - assertThat(someoneAcceptedKnock).isEqualTo("$otherName allowed ${someoneContent.userId} to join") + assertThat(someoneAcceptedKnock).isEqualTo("$otherName allowed $third to join") } @Test @Config(qualifiers = "en") fun `Membership change - knock retracted`() { - val otherName = "Someone" - val youContent = RoomMembershipContent(A_USER_ID, MembershipChange.KNOCK_RETRACTED) - val someoneContent = RoomMembershipContent(UserId("@someone_else:domain"), MembershipChange.KNOCK_RETRACTED) + val otherName = "Other" + val youContent = RoomMembershipContent(A_USER_ID, null, MembershipChange.KNOCK_RETRACTED) + val someoneContent = RoomMembershipContent(UserId("@someone_else:domain"), null, MembershipChange.KNOCK_RETRACTED) val youRetractedKnockEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = youContent) val youRetractedKnock = formatter.format(youRetractedKnockEvent, false) @@ -458,17 +464,18 @@ class DefaultRoomLastMessageFormatterTest { @Test @Config(qualifiers = "en") fun `Membership change - knock denied`() { - val otherName = "Someone" - val youContent = RoomMembershipContent(A_USER_ID, MembershipChange.KNOCK_DENIED) - val someoneContent = RoomMembershipContent(UserId("@someone_else:domain"), MembershipChange.KNOCK_DENIED) + val otherName = "Other" + val third = "Someone" + val youContent = RoomMembershipContent(A_USER_ID, third, MembershipChange.KNOCK_DENIED) + val someoneContent = RoomMembershipContent(UserId("@someone_else:domain"), third, MembershipChange.KNOCK_DENIED) val youDeniedKnockEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = someoneContent) val youDeniedKnock = formatter.format(youDeniedKnockEvent, false) - assertThat(youDeniedKnock).isEqualTo("You rejected ${someoneContent.userId}'s request to join") + assertThat(youDeniedKnock).isEqualTo("You rejected $third's request to join") val someoneDeniedKnockEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent) val someoneDeniedKnock = formatter.format(someoneDeniedKnockEvent, false) - assertThat(someoneDeniedKnock).isEqualTo("$otherName rejected ${someoneContent.userId}'s request to join") + assertThat(someoneDeniedKnock).isEqualTo("$otherName rejected $third's request to join") val someoneDeniedYourKnockEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = youContent) val someoneDeniedYourKnock = formatter.format(someoneDeniedYourKnockEvent, false) @@ -478,9 +485,9 @@ class DefaultRoomLastMessageFormatterTest { @Test @Config(qualifiers = "en") fun `Membership change - None`() { - val otherName = "Someone" - val youContent = RoomMembershipContent(A_USER_ID, MembershipChange.NONE) - val someoneContent = RoomMembershipContent(UserId("@someone_else:domain"), MembershipChange.NONE) + val otherName = "Other" + val youContent = RoomMembershipContent(A_USER_ID, null, MembershipChange.NONE) + val someoneContent = RoomMembershipContent(UserId("@someone_else:domain"), otherName, MembershipChange.NONE) val youNoneRoomEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = youContent) val youNoneRoom = formatter.format(youNoneRoomEvent, false) @@ -497,7 +504,7 @@ class DefaultRoomLastMessageFormatterTest { val otherChanges = arrayOf(MembershipChange.ERROR, MembershipChange.NOT_IMPLEMENTED, null) val results = otherChanges.map { change -> - val content = RoomMembershipContent(A_USER_ID, change) + val content = RoomMembershipContent(A_USER_ID, null, change) val event = createRoomEvent(sentByYou = false, senderDisplayName = "Someone", content = content) val result = formatter.format(event, false) change to result @@ -513,7 +520,7 @@ class DefaultRoomLastMessageFormatterTest { @Test @Config(qualifiers = "en") fun `Room state change - avatar`() { - val otherName = "Someone" + val otherName = "Other" val changedContent = StateContent("", OtherState.RoomAvatar("new_avatar")) val removedContent = StateContent("", OtherState.RoomAvatar(null)) @@ -537,7 +544,7 @@ class DefaultRoomLastMessageFormatterTest { @Test @Config(qualifiers = "en") fun `Room state change - create`() { - val otherName = "Someone" + val otherName = "Other" val content = StateContent("", OtherState.RoomCreate) val youCreatedRoomMessage = createRoomEvent(sentByYou = true, senderDisplayName = null, content = content) @@ -552,7 +559,7 @@ class DefaultRoomLastMessageFormatterTest { @Test @Config(qualifiers = "en") fun `Room state change - encryption`() { - val otherName = "Someone" + val otherName = "Other" val content = StateContent("", OtherState.RoomEncryption) val youCreatedRoomMessage = createRoomEvent(sentByYou = true, senderDisplayName = null, content = content) @@ -567,7 +574,7 @@ class DefaultRoomLastMessageFormatterTest { @Test @Config(qualifiers = "en") fun `Room state change - room name`() { - val otherName = "Someone" + val otherName = "Other" val newName = "New name" val changedContent = StateContent("", OtherState.RoomName(newName)) val removedContent = StateContent("", OtherState.RoomName(null)) @@ -592,7 +599,7 @@ class DefaultRoomLastMessageFormatterTest { @Test @Config(qualifiers = "en") fun `Room state change - third party invite`() { - val otherName = "Someone" + val otherName = "Other" val inviteeName = "Alice" val changedContent = StateContent("", OtherState.RoomThirdPartyInvite(inviteeName)) val removedContent = StateContent("", OtherState.RoomThirdPartyInvite(null)) @@ -617,7 +624,7 @@ class DefaultRoomLastMessageFormatterTest { @Test @Config(qualifiers = "en") fun `Room state change - room topic`() { - val otherName = "Someone" + val otherName = "Other" val roomTopic = "New topic" val changedContent = StateContent("", OtherState.RoomTopic(roomTopic)) val removedContent = StateContent("", OtherState.RoomTopic(null)) @@ -677,7 +684,7 @@ class DefaultRoomLastMessageFormatterTest { @Test @Config(qualifiers = "en") fun `Profile change - avatar`() { - val otherName = "Someone" + val otherName = "Other" val changedContent = aProfileChangeMessageContent(avatarUrl = "new_avatar_url", prevAvatarUrl = "old_avatar_url") val setContent = aProfileChangeMessageContent(avatarUrl = "new_avatar_url", prevAvatarUrl = null) val removedContent = aProfileChangeMessageContent(avatarUrl = null, prevAvatarUrl = "old_avatar_url") @@ -722,7 +729,7 @@ class DefaultRoomLastMessageFormatterTest { fun `Profile change - display name`() { val newDisplayName = "New" val oldDisplayName = "Old" - val otherName = "Someone" + val otherName = "Other" val changedContent = aProfileChangeMessageContent(displayName = newDisplayName, prevDisplayName = oldDisplayName) val setContent = aProfileChangeMessageContent(displayName = newDisplayName, prevDisplayName = null) val removedContent = aProfileChangeMessageContent(displayName = null, prevDisplayName = oldDisplayName) 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 98e3818ad3..1a0602fd15 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 @@ -81,5 +81,12 @@ enum class FeatureFlags( description = "Allow user to search for public rooms in their homeserver", defaultValue = false, isFinished = false, - ) + ), + ShowBlockedUsersDetails( + key = "feature.showBlockedUsersDetails", + title = "Show blocked users details", + description = "Show the name and avatar of blocked users in the blocked users list", + defaultValue = false, + isFinished = false, + ), } diff --git a/libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/StaticFeatureFlagProvider.kt b/libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/StaticFeatureFlagProvider.kt index 2f01633858..d8b485871a 100644 --- a/libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/StaticFeatureFlagProvider.kt +++ b/libraries/featureflag/impl/src/main/kotlin/io/element/android/libraries/featureflag/impl/StaticFeatureFlagProvider.kt @@ -41,6 +41,7 @@ class StaticFeatureFlagProvider @Inject constructor() : FeatureFlags.Mentions -> true FeatureFlags.MarkAsUnread -> true FeatureFlags.RoomDirectorySearch -> false + FeatureFlags.ShowBlockedUsersDetails -> false } } else { false diff --git a/libraries/indicator/impl/src/main/kotlin/io/element/android/libraries/indicator/impl/DefaultIndicatorService.kt b/libraries/indicator/impl/src/main/kotlin/io/element/android/libraries/indicator/impl/DefaultIndicatorService.kt index aae0f1f913..6b72b31d19 100644 --- a/libraries/indicator/impl/src/main/kotlin/io/element/android/libraries/indicator/impl/DefaultIndicatorService.kt +++ b/libraries/indicator/impl/src/main/kotlin/io/element/android/libraries/indicator/impl/DefaultIndicatorService.kt @@ -38,7 +38,7 @@ class DefaultIndicatorService @Inject constructor( ) : IndicatorService { @Composable override fun showRoomListTopBarIndicator(): State { - val canVerifySession by sessionVerificationService.canVerifySessionFlow.collectAsState(initial = false) + val canVerifySession by sessionVerificationService.needsSessionVerification.collectAsState(initial = false) val settingChatBackupIndicator = showSettingChatBackupIndicator() return remember { diff --git a/libraries/matrix/api/build.gradle.kts b/libraries/matrix/api/build.gradle.kts index 6219296732..b551dfa919 100644 --- a/libraries/matrix/api/build.gradle.kts +++ b/libraries/matrix/api/build.gradle.kts @@ -38,6 +38,7 @@ dependencies { implementation(libs.dagger) implementation(projects.libraries.androidutils) implementation(projects.libraries.core) + implementation(projects.services.analytics.api) implementation(libs.serialization.json) api(projects.libraries.sessionStorage.api) implementation(libs.coroutines.core) 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 fb20e16c9d..a4b808d653 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 @@ -19,7 +19,6 @@ package io.element.android.libraries.matrix.api import io.element.android.libraries.matrix.api.core.ProgressCallback import io.element.android.libraries.matrix.api.core.RoomAlias import io.element.android.libraries.matrix.api.core.RoomId -import io.element.android.libraries.matrix.api.core.RoomIdOrAlias import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.createroom.CreateRoomParameters @@ -32,6 +31,7 @@ import io.element.android.libraries.matrix.api.pusher.PushersService import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoomInfo 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.api.room.preview.RoomPreview import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryService import io.element.android.libraries.matrix.api.roomlist.RoomListService @@ -66,6 +66,7 @@ interface MatrixClient : Closeable { suspend fun uploadAvatar(mimeType: String, data: ByteArray): Result suspend fun removeAvatar(): Result suspend fun joinRoom(roomId: RoomId): Result + suspend fun joinRoomByIdOrAlias(roomId: RoomId, serverNames: List): Result suspend fun knockRoom(roomId: RoomId): Result fun syncService(): SyncService fun sessionVerificationService(): SessionVerificationService @@ -102,6 +103,6 @@ interface MatrixClient : Closeable { suspend fun trackRecentlyVisitedRoom(roomId: RoomId): Result suspend fun getRecentlyVisitedRooms(): Result> - suspend fun resolveRoomAlias(roomAlias: RoomAlias): Result - suspend fun getRoomPreview(roomIdOrAlias: RoomIdOrAlias): Result + suspend fun resolveRoomAlias(roomAlias: RoomAlias): Result + suspend fun getRoomPreviewFromRoomId(roomId: RoomId, serverNames: List): Result } diff --git a/services/analytics/api/src/main/kotlin/io/element/android/services/analytics/api/extensions/ViewRoomExt.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/analytics/ViewRoomExt.kt similarity index 81% rename from services/analytics/api/src/main/kotlin/io/element/android/services/analytics/api/extensions/ViewRoomExt.kt rename to libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/analytics/ViewRoomExt.kt index 2845129fa8..ecc5ce8d7c 100644 --- a/services/analytics/api/src/main/kotlin/io/element/android/services/analytics/api/extensions/ViewRoomExt.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/analytics/ViewRoomExt.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2022 New Vector Ltd + * Copyright (c) 2024 New Vector Ltd * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,12 +14,16 @@ * limitations under the License. */ -package io.element.android.services.analytics.api.extensions +package io.element.android.libraries.matrix.api.analytics import im.vector.app.features.analytics.plan.ViewRoom import io.element.android.libraries.matrix.api.room.MatrixRoom -fun MatrixRoom.toAnalyticsViewRoom(trigger: ViewRoom.Trigger? = null, selectedSpace: MatrixRoom? = null, viaKeyboard: Boolean? = null): ViewRoom { +fun MatrixRoom.toAnalyticsViewRoom( + trigger: ViewRoom.Trigger? = null, + selectedSpace: MatrixRoom? = null, + viaKeyboard: Boolean? = null, +): ViewRoom { val activeSpace = selectedSpace?.toActiveSpace() ?: ViewRoom.ActiveSpace.Home return ViewRoom( diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/EncryptionService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/EncryptionService.kt index 36c786a26f..f47487c634 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/EncryptionService.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/EncryptionService.kt @@ -50,4 +50,16 @@ interface EncryptionService { * Wait for backup upload steady state. */ fun waitForBackupUploadSteadyState(): Flow + + /** + * Get the public curve25519 key of our own device in base64. This is usually what is + * called the identity key of the device. + */ + suspend fun deviceCurve25519(): String? + + /** + * Get the public ed25519 key of our own device. This is usually what is + * called the fingerprint of the device. + */ + suspend fun deviceEd25519(): String? } 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 9df9698eec..55bce7c5f8 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 @@ -52,6 +52,10 @@ sealed interface NotificationContent { data class CallInvite( val senderId: UserId, ) : MessageLike + data class CallNotify( + val senderId: UserId, + val type: CallNotifyType, + ) : MessageLike data object CallHangup : MessageLike data object CallCandidates : MessageLike @@ -108,3 +112,8 @@ sealed interface NotificationContent { data object SpaceParent : StateEvent } } + +enum class CallNotifyType { + RING, + NOTIFY +} diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/pusher/PushersService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/pusher/PushersService.kt index 71a642965f..2a16e7be35 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/pusher/PushersService.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/pusher/PushersService.kt @@ -18,5 +18,5 @@ package io.element.android.libraries.matrix.api.pusher interface PushersService { suspend fun setHttpPusher(setHttpPusherData: SetHttpPusherData): Result - suspend fun unsetHttpPusher(): Result + suspend fun unsetHttpPusher(unsetHttpPusherData: UnsetHttpPusherData): Result } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/pusher/UnsetHttpPusherData.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/pusher/UnsetHttpPusherData.kt new file mode 100644 index 0000000000..2bd91a6d02 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/pusher/UnsetHttpPusherData.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.matrix.api.pusher + +data class UnsetHttpPusherData( + val pushKey: String, + val appId: String, +) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoomInfo.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoomInfo.kt index eea1cc9c1b..8379d30781 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoomInfo.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/MatrixRoomInfo.kt @@ -27,7 +27,10 @@ import kotlinx.collections.immutable.ImmutableMap @Immutable data class MatrixRoomInfo( val id: RoomId, + /** The room's name from the room state event if received from sync, or one that's been computed otherwise. */ val name: String?, + /** Room name as defined by the room state event only. */ + val rawName: String?, val topic: String?, val avatarUrl: String?, val isDirect: Boolean, diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/alias/ResolvedRoomAlias.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/alias/ResolvedRoomAlias.kt new file mode 100644 index 0000000000..9046664917 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/alias/ResolvedRoomAlias.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.matrix.api.room.alias + +import io.element.android.libraries.matrix.api.core.RoomId + +/** + * Information about a room, that was resolved from a room alias. + */ +data class ResolvedRoomAlias( + /** + * The room ID that the alias resolved to. + */ + val roomId: RoomId, + /** + * A list of servers that can be used to find the room by its room ID. + */ + val servers: List +) diff --git a/features/roomdirectory/impl/src/test/kotlin/io/element/android/features/roomdirectory/impl/root/FakeJoinRoom.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/join/JoinRoom.kt similarity index 69% rename from features/roomdirectory/impl/src/test/kotlin/io/element/android/features/roomdirectory/impl/root/FakeJoinRoom.kt rename to libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/join/JoinRoom.kt index 6251bcaefa..fe6a2d9e47 100644 --- a/features/roomdirectory/impl/src/test/kotlin/io/element/android/features/roomdirectory/impl/root/FakeJoinRoom.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/room/join/JoinRoom.kt @@ -14,13 +14,15 @@ * limitations under the License. */ -package io.element.android.features.roomdirectory.impl.root +package io.element.android.libraries.matrix.api.room.join -import io.element.android.features.roomdirectory.impl.root.di.JoinRoom +import im.vector.app.features.analytics.plan.JoinedRoom import io.element.android.libraries.matrix.api.core.RoomId -class FakeJoinRoom( - var lambda: (RoomId) -> Result = { Result.success(Unit) } -) : JoinRoom { - override suspend fun invoke(roomId: RoomId) = lambda(roomId) +interface JoinRoom { + suspend operator fun invoke( + roomId: RoomId, + serverNames: List, + trigger: JoinedRoom.Trigger, + ): 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 c0aac298a8..0150c78a4a 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 @@ -73,6 +73,7 @@ data class UnableToDecryptContent( data class RoomMembershipContent( val userId: UserId, + val userDisplayName: String?, val change: MembershipChange? ) : EventContent diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/verification/SessionVerificationService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/verification/SessionVerificationService.kt index 8d22fc174e..01687d754a 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/verification/SessionVerificationService.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/verification/SessionVerificationService.kt @@ -26,12 +26,6 @@ interface SessionVerificationService { */ val verificationFlowState: StateFlow - /** - * 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. - */ - val isReady: StateFlow - /** * Returns whether the current verification status is either: [SessionVerifiedStatus.Unknown], [SessionVerifiedStatus.NotVerified] * or [SessionVerifiedStatus.Verified]. @@ -39,9 +33,9 @@ interface SessionVerificationService { val sessionVerifiedStatus: StateFlow /** - * Returns whether the current session needs to be verified and the SDK is ready to start the verification. + * Returns whether the current session needs to be verified. */ - val canVerifySessionFlow: Flow + val needsSessionVerification: Flow /** * Request verification of the current session. 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 7b6ec13268..2dd28a1ae0 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 @@ -25,7 +25,6 @@ import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.ProgressCallback import io.element.android.libraries.matrix.api.core.RoomAlias import io.element.android.libraries.matrix.api.core.RoomId -import io.element.android.libraries.matrix.api.core.RoomIdOrAlias 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.RoomPreset @@ -39,6 +38,7 @@ import io.element.android.libraries.matrix.api.pusher.PushersService import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoomInfo 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.api.room.preview.RoomPreview import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryService import io.element.android.libraries.matrix.api.roomlist.RoomListService @@ -443,6 +443,23 @@ class RustMatrixClient( } } + override suspend fun joinRoomByIdOrAlias( + roomId: RoomId, + serverNames: List, + ): Result = withContext(sessionDispatcher) { + runCatching { + client.joinRoomByIdOrAlias( + roomIdOrAlias = roomId.value, + serverNames = serverNames, + ).destroy() + try { + awaitRoom(roomId, 10.seconds) + } catch (e: Exception) { + Timber.e(e, "Timeout waiting for the room to be available in the room list") + } + } + } + override suspend fun knockRoom(roomId: RoomId): Result { return Result.failure(NotImplementedError("Not yet implemented")) } @@ -459,15 +476,22 @@ class RustMatrixClient( } } - override suspend fun resolveRoomAlias(roomAlias: RoomAlias): Result = withContext(sessionDispatcher) { + override suspend fun resolveRoomAlias(roomAlias: RoomAlias): Result = withContext(sessionDispatcher) { runCatching { - client.resolveRoomAlias(roomAlias.value).roomId.let(::RoomId) + val result = client.resolveRoomAlias(roomAlias.value) + ResolvedRoomAlias( + roomId = RoomId(result.roomId), + servers = result.servers, + ) } } - override suspend fun getRoomPreview(roomIdOrAlias: RoomIdOrAlias): Result = withContext(sessionDispatcher) { + override suspend fun getRoomPreviewFromRoomId(roomId: RoomId, serverNames: List): Result = withContext(sessionDispatcher) { runCatching { - client.getRoomPreview(roomIdOrAlias.identifier).let(RoomPreviewMapper::map) + client.getRoomPreviewFromRoomId( + roomId = roomId.value, + viaServers = serverNames, + ).let(RoomPreviewMapper::map) } } diff --git a/services/analytics/api/src/main/kotlin/io/element/android/services/analytics/api/extensions/JoinedRoomExt.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/analytics/JoinedRoomExt.kt similarity index 88% rename from services/analytics/api/src/main/kotlin/io/element/android/services/analytics/api/extensions/JoinedRoomExt.kt rename to libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/analytics/JoinedRoomExt.kt index c8ab1fad06..955a6b0f8a 100644 --- a/services/analytics/api/src/main/kotlin/io/element/android/services/analytics/api/extensions/JoinedRoomExt.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/analytics/JoinedRoomExt.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2021 New Vector Ltd + * Copyright (c) 2024 New Vector Ltd * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,12 +14,12 @@ * limitations under the License. */ -package io.element.android.services.analytics.api.extensions +package io.element.android.libraries.matrix.impl.analytics import im.vector.app.features.analytics.plan.JoinedRoom import io.element.android.libraries.matrix.api.room.MatrixRoom -fun Long?.toAnalyticsRoomSize(): JoinedRoom.RoomSize { +private fun Long?.toAnalyticsRoomSize(): JoinedRoom.RoomSize { return when (this) { null, 2L -> JoinedRoom.RoomSize.Two diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/RustEncryptionService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/RustEncryptionService.kt index f5a6390989..68ab4a611e 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/RustEncryptionService.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/RustEncryptionService.kt @@ -190,4 +190,12 @@ internal class RustEncryptionService( it.mapRecoveryException() } } + + override suspend fun deviceCurve25519(): String? { + return service.curve25519Key() + } + + override suspend fun deviceEd25519(): String? { + return service.ed25519Key() + } } 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 f599b09079..4591a2ef52 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 @@ -17,10 +17,12 @@ package io.element.android.libraries.matrix.impl.notification import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.notification.CallNotifyType import io.element.android.libraries.matrix.api.notification.NotificationContent import io.element.android.libraries.matrix.impl.room.member.RoomMemberMapper import io.element.android.libraries.matrix.impl.timeline.item.event.EventMessageMapper import org.matrix.rustcomponents.sdk.MessageLikeEventContent +import org.matrix.rustcomponents.sdk.NotifyType import org.matrix.rustcomponents.sdk.StateEventContent import org.matrix.rustcomponents.sdk.TimelineEvent import org.matrix.rustcomponents.sdk.TimelineEventType @@ -79,6 +81,7 @@ private fun MessageLikeEventContent.toContent(senderId: UserId): NotificationCon MessageLikeEventContent.CallCandidates -> NotificationContent.MessageLike.CallCandidates MessageLikeEventContent.CallHangup -> NotificationContent.MessageLike.CallHangup MessageLikeEventContent.CallInvite -> NotificationContent.MessageLike.CallInvite(senderId) + is MessageLikeEventContent.CallNotify -> NotificationContent.MessageLike.CallNotify(senderId, notifyType.map()) MessageLikeEventContent.KeyVerificationAccept -> NotificationContent.MessageLike.KeyVerificationAccept MessageLikeEventContent.KeyVerificationCancel -> NotificationContent.MessageLike.KeyVerificationCancel MessageLikeEventContent.KeyVerificationDone -> NotificationContent.MessageLike.KeyVerificationDone @@ -97,3 +100,8 @@ private fun MessageLikeEventContent.toContent(senderId: UserId): NotificationCon } } } + +private fun NotifyType.map(): CallNotifyType = when (this) { + NotifyType.NOTIFY -> CallNotifyType.NOTIFY + NotifyType.RING -> CallNotifyType.RING +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/pushers/RustPushersService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/pushers/RustPushersService.kt index 60ca4df311..2686d03c6b 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/pushers/RustPushersService.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/pushers/RustPushersService.kt @@ -19,6 +19,7 @@ package io.element.android.libraries.matrix.impl.pushers import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.matrix.api.pusher.PushersService import io.element.android.libraries.matrix.api.pusher.SetHttpPusherData +import io.element.android.libraries.matrix.api.pusher.UnsetHttpPusherData import kotlinx.coroutines.withContext import org.matrix.rustcomponents.sdk.Client import org.matrix.rustcomponents.sdk.HttpPusherData @@ -54,8 +55,16 @@ class RustPushersService( } } - override suspend fun unsetHttpPusher(): Result { - // TODO Missing client API. We need to set the pusher with Kind == null, but we do not have access to this field from the SDK. - return Result.success(Unit) + override suspend fun unsetHttpPusher(unsetHttpPusherData: UnsetHttpPusherData): Result { + return withContext(dispatchers.io) { + runCatching { + client.deletePusher( + identifiers = PusherIdentifiers( + pushkey = unsetHttpPusherData.pushKey, + appId = unsetHttpPusherData.appId + ), + ) + } + } } } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/MatrixRoomInfoMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/MatrixRoomInfoMapper.kt index 3fe4dcf1b7..8063c67069 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/MatrixRoomInfoMapper.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/MatrixRoomInfoMapper.kt @@ -39,6 +39,7 @@ class MatrixRoomInfoMapper( return MatrixRoomInfo( id = RoomId(it.id), name = it.displayName, + rawName = it.rawName, topic = it.topic, avatarUrl = it.avatarUrl, isDirect = it.isDirect, 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 1b6b364844..771bf59680 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 @@ -41,7 +41,8 @@ class RoomSyncSubscriber( RequiredState(key = EventType.STATE_ROOM_JOIN_RULES, value = ""), RequiredState(key = EventType.STATE_ROOM_POWER_LEVELS, value = ""), ), - timelineLimit = null + timelineLimit = null, + includeHeroes = true, ) suspend fun subscribe(roomId: RoomId) = mutex.withLock { diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/join/DefaultJoinRoom.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/join/DefaultJoinRoom.kt new file mode 100644 index 0000000000..d2ce4e61d5 --- /dev/null +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/join/DefaultJoinRoom.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.matrix.impl.room.join + +import com.squareup.anvil.annotations.ContributesBinding +import im.vector.app.features.analytics.plan.JoinedRoom +import io.element.android.libraries.di.SessionScope +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.join.JoinRoom +import io.element.android.libraries.matrix.impl.analytics.toAnalyticsJoinedRoom +import io.element.android.services.analytics.api.AnalyticsService +import javax.inject.Inject + +@ContributesBinding(SessionScope::class) +class DefaultJoinRoom @Inject constructor( + private val client: MatrixClient, + private val analyticsService: AnalyticsService, +) : JoinRoom { + override suspend fun invoke( + roomId: RoomId, + serverNames: List, + trigger: JoinedRoom.Trigger, + ): Result { + return if (serverNames.isEmpty()) { + client.joinRoom(roomId) + } else { + client.joinRoomByIdOrAlias(roomId, serverNames) + }.onSuccess { + client.getRoom(roomId)?.use { room -> + analyticsService.capture(room.toAnalyticsJoinedRoom(trigger)) + } + } + } +} diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/MatrixTimelineItemMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/MatrixTimelineItemMapper.kt index 5bf55105cc..c8cb4eef1c 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/MatrixTimelineItemMapper.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/MatrixTimelineItemMapper.kt @@ -31,7 +31,7 @@ class MatrixTimelineItemMapper( private val eventTimelineItemMapper: EventTimelineItemMapper = EventTimelineItemMapper(), ) { fun map(timelineItem: TimelineItem): MatrixTimelineItem = timelineItem.use { - val uniqueId = timelineItem.uniqueId().toString() + val uniqueId = timelineItem.uniqueId() val asEvent = it.asEvent() if (asEvent != null) { val eventTimelineItem = eventTimelineItemMapper.map(asEvent) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt index fb051e7a35..fee898988e 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/RustTimeline.kt @@ -174,23 +174,25 @@ class RustTimeline( // Use NonCancellable to avoid breaking the timeline when the coroutine is cancelled. override suspend fun paginate(direction: Timeline.PaginationDirection): Result = withContext(NonCancellable) { - initLatch.await() - runCatching { - if (!canPaginate(direction)) throw TimelineException.CannotPaginate - updatePaginationStatus(direction) { it.copy(isPaginating = true) } - when (direction) { - Timeline.PaginationDirection.BACKWARDS -> inner.paginateBackwards(PAGINATION_SIZE.toUShort()) - Timeline.PaginationDirection.FORWARDS -> inner.focusedPaginateForwards(PAGINATION_SIZE.toUShort()) + withContext(dispatcher) { + initLatch.await() + runCatching { + if (!canPaginate(direction)) throw TimelineException.CannotPaginate + updatePaginationStatus(direction) { it.copy(isPaginating = true) } + when (direction) { + Timeline.PaginationDirection.BACKWARDS -> inner.paginateBackwards(PAGINATION_SIZE.toUShort()) + Timeline.PaginationDirection.FORWARDS -> inner.focusedPaginateForwards(PAGINATION_SIZE.toUShort()) + } + }.onFailure { error -> + updatePaginationStatus(direction) { it.copy(isPaginating = false) } + if (error is TimelineException.CannotPaginate) { + Timber.d("Can't paginate $direction on room ${matrixRoom.roomId} with paginationStatus: ${backPaginationStatus.value}") + } else { + Timber.e(error, "Error paginating $direction on room ${matrixRoom.roomId}") + } + }.onSuccess { hasReachedEnd -> + updatePaginationStatus(direction) { it.copy(isPaginating = false, hasMoreToLoad = !hasReachedEnd) } } - }.onFailure { error -> - updatePaginationStatus(direction) { it.copy(isPaginating = false) } - if (error is TimelineException.CannotPaginate) { - Timber.d("Can't paginate $direction on room ${matrixRoom.roomId} with paginationStatus: ${backPaginationStatus.value}") - } else { - Timber.e(error, "Error paginating $direction on room ${matrixRoom.roomId}") - } - }.onSuccess { hasReachedEnd -> - updatePaginationStatus(direction) { it.copy(isPaginating = false, hasMoreToLoad = !hasReachedEnd) } } } @@ -214,18 +216,20 @@ class RustTimeline( backPaginationStatus.map { it.hasMoreToLoad }.distinctUntilChanged(), forwardPaginationStatus.map { it.hasMoreToLoad }.distinctUntilChanged(), ) { timelineItems, hasMoreToLoadBackward, hasMoreToLoadForward -> - timelineItems - .let { items -> encryptedHistoryPostProcessor.process(items) } - .let { items -> - roomBeginningPostProcessor.process( - items = items, - isDm = matrixRoom.isDm, - hasMoreToLoadBackwards = hasMoreToLoadBackward - ) - } - .let { items -> loadingIndicatorsPostProcessor.process(items, hasMoreToLoadBackward, hasMoreToLoadForward) } - // Keep lastForwardIndicatorsPostProcessor last - .let { items -> lastForwardIndicatorsPostProcessor.process(items) } + withContext(dispatcher) { + timelineItems + .let { items -> encryptedHistoryPostProcessor.process(items) } + .let { items -> + roomBeginningPostProcessor.process( + items = items, + isDm = matrixRoom.isDm, + hasMoreToLoadBackwards = hasMoreToLoadBackward + ) + } + .let { items -> loadingIndicatorsPostProcessor.process(items, hasMoreToLoadBackward, hasMoreToLoadForward) } + // Keep lastForwardIndicatorsPostProcessor last + .let { items -> lastForwardIndicatorsPostProcessor.process(items) } + } } override fun close() { 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 14ea994d38..04e3632d09 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 @@ -88,8 +88,9 @@ class TimelineEventContentMapper(private val eventMessageMapper: EventMessageMap } is TimelineItemContentKind.RoomMembership -> { RoomMembershipContent( - UserId(kind.userId), - kind.change?.map() + userId = UserId(kind.userId), + userDisplayName = kind.userDisplayName, + change = kind.change?.map() ) } is TimelineItemContentKind.State -> { 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 88403e8928..5b6d960d09 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 @@ -30,8 +30,8 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @@ -80,10 +80,14 @@ class RustSessionVerificationService( private val _sessionVerifiedStatus = MutableStateFlow(SessionVerifiedStatus.Unknown) override val sessionVerifiedStatus: StateFlow = _sessionVerifiedStatus.asStateFlow() - override val isReady = isSyncServiceReady.stateIn(sessionCoroutineScope, SharingStarted.Eagerly, false) + /** + * 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) - override val canVerifySessionFlow = combine(sessionVerifiedStatus, isReady) { verificationStatus, isReady -> - isReady && verificationStatus == SessionVerifiedStatus.NotVerified + override val needsSessionVerification = sessionVerifiedStatus.map { verificationStatus -> + verificationStatus == SessionVerifiedStatus.NotVerified } init { 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 new file mode 100644 index 0000000000..6296f219de --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/join/DefaultJoinRoomTest.kt @@ -0,0 +1,103 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.matrix.impl.room.join + +import com.google.common.truth.Truth.assertThat +import im.vector.app.features.analytics.plan.JoinedRoom +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.impl.analytics.toAnalyticsJoinedRoom +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.matrix.test.A_SERVER_LIST +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.matrix.test.room.FakeMatrixRoom +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.Test + +class DefaultJoinRoomTest { + @Test + fun `when there is no server names, the classic join room API is used`() = runTest { + val joinRoomLambda = lambdaRecorder { _: RoomId -> Result.success(Unit) } + val joinRoomByIdOrAliasLambda = lambdaRecorder { _: RoomId, _: List -> Result.success(Unit) } + val roomResult = FakeMatrixRoom() + val aTrigger = JoinedRoom.Trigger.MobilePermalink + val client: MatrixClient = FakeMatrixClient().also { + it.joinRoomLambda = joinRoomLambda + it.joinRoomByIdOrAliasLambda = joinRoomByIdOrAliasLambda + it.givenGetRoomResult( + roomId = A_ROOM_ID, + result = roomResult + ) + } + val analyticsService = FakeAnalyticsService() + val sut = DefaultJoinRoom( + client = client, + analyticsService = analyticsService, + ) + sut.invoke(A_ROOM_ID, emptyList(), aTrigger) + joinRoomByIdOrAliasLambda + .assertions() + .isNeverCalled() + joinRoomLambda + .assertions() + .isCalledOnce() + .with( + value(A_ROOM_ID) + ) + assertThat(analyticsService.capturedEvents).containsExactly( + roomResult.toAnalyticsJoinedRoom(aTrigger) + ) + } + + @Test + fun `when server names are available, joinRoomByIdOrAlias API is used`() = runTest { + val joinRoomLambda = lambdaRecorder { _: RoomId -> Result.success(Unit) } + val joinRoomByIdOrAliasLambda = lambdaRecorder { _: RoomId, _: List -> Result.success(Unit) } + val roomResult = FakeMatrixRoom() + val aTrigger = JoinedRoom.Trigger.MobilePermalink + val client: MatrixClient = FakeMatrixClient().also { + it.joinRoomLambda = joinRoomLambda + it.joinRoomByIdOrAliasLambda = joinRoomByIdOrAliasLambda + it.givenGetRoomResult( + roomId = A_ROOM_ID, + result = roomResult + ) + } + val analyticsService = FakeAnalyticsService() + val sut = DefaultJoinRoom( + client = client, + analyticsService = analyticsService, + ) + sut.invoke(A_ROOM_ID, A_SERVER_LIST, aTrigger) + joinRoomByIdOrAliasLambda + .assertions() + .isCalledOnce() + .with( + value(A_ROOM_ID), + value(A_SERVER_LIST) + ) + joinRoomLambda + .assertions() + .isNeverCalled() + assertThat(analyticsService.capturedEvents).containsExactly( + roomResult.toAnalyticsJoinedRoom(aTrigger) + ) + } +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/RoomBeginningPostProcessorTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/RoomBeginningPostProcessorTest.kt index 2c72481a25..63b0664bc7 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/RoomBeginningPostProcessorTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/RoomBeginningPostProcessorTest.kt @@ -34,7 +34,7 @@ class RoomBeginningPostProcessorTest { fun `processor removes room creation event and self-join event from DM timeline`() { val timelineItems = listOf( MatrixTimelineItem.Event("m.room.create", anEventTimelineItem(sender = A_USER_ID, content = StateContent("", OtherState.RoomCreate))), - MatrixTimelineItem.Event("m.room.member", anEventTimelineItem(content = RoomMembershipContent(A_USER_ID, MembershipChange.JOINED))), + MatrixTimelineItem.Event("m.room.member", anEventTimelineItem(content = RoomMembershipContent(A_USER_ID, null, MembershipChange.JOINED))), ) val processor = RoomBeginningPostProcessor() val processedItems = processor.process(timelineItems, isDm = true, hasMoreToLoadBackwards = false) @@ -44,13 +44,13 @@ class RoomBeginningPostProcessorTest { @Test fun `processor removes room creation event and self-join event from DM timeline even if they're not the first items`() { val timelineItems = listOf( - MatrixTimelineItem.Event("m.room.member_other", anEventTimelineItem(content = RoomMembershipContent(A_USER_ID_2, MembershipChange.JOINED))), + MatrixTimelineItem.Event("m.room.member_other", anEventTimelineItem(content = RoomMembershipContent(A_USER_ID_2, null, MembershipChange.JOINED))), MatrixTimelineItem.Event("m.room.create", anEventTimelineItem(sender = A_USER_ID, content = StateContent("", OtherState.RoomCreate))), MatrixTimelineItem.Event("m.room.message", anEventTimelineItem(content = aMessageContent("hi"))), - MatrixTimelineItem.Event("m.room.member", anEventTimelineItem(content = RoomMembershipContent(A_USER_ID, MembershipChange.JOINED))), + MatrixTimelineItem.Event("m.room.member", anEventTimelineItem(content = RoomMembershipContent(A_USER_ID, null, MembershipChange.JOINED))), ) val expected = listOf( - MatrixTimelineItem.Event("m.room.member_other", anEventTimelineItem(content = RoomMembershipContent(A_USER_ID_2, MembershipChange.JOINED))), + MatrixTimelineItem.Event("m.room.member_other", anEventTimelineItem(content = RoomMembershipContent(A_USER_ID_2, null, MembershipChange.JOINED))), MatrixTimelineItem.Event("m.room.message", anEventTimelineItem(content = aMessageContent("hi"))), ) val processor = RoomBeginningPostProcessor() @@ -62,7 +62,7 @@ class RoomBeginningPostProcessorTest { fun `processor will add beginning of room item if it's not a DM`() { val timelineItems = listOf( MatrixTimelineItem.Event("m.room.create", anEventTimelineItem(sender = A_USER_ID, content = StateContent("", OtherState.RoomCreate))), - MatrixTimelineItem.Event("m.room.member", anEventTimelineItem(content = RoomMembershipContent(A_USER_ID, MembershipChange.JOINED))), + MatrixTimelineItem.Event("m.room.member", anEventTimelineItem(content = RoomMembershipContent(A_USER_ID, null, MembershipChange.JOINED))), ) val processor = RoomBeginningPostProcessor() val processedItems = processor.process(timelineItems, isDm = false, hasMoreToLoadBackwards = false) @@ -85,7 +85,7 @@ class RoomBeginningPostProcessorTest { fun `processor won't remove items if it's not at the start of the timeline`() { val timelineItems = listOf( MatrixTimelineItem.Event("m.room.create", anEventTimelineItem(sender = A_USER_ID, content = StateContent("", OtherState.RoomCreate))), - MatrixTimelineItem.Event("m.room.member", anEventTimelineItem(content = RoomMembershipContent(A_USER_ID, MembershipChange.JOINED))), + MatrixTimelineItem.Event("m.room.member", anEventTimelineItem(content = RoomMembershipContent(A_USER_ID, null, MembershipChange.JOINED))), ) val processor = RoomBeginningPostProcessor() val processedItems = processor.process(timelineItems, isDm = true, hasMoreToLoadBackwards = true) @@ -95,7 +95,7 @@ class RoomBeginningPostProcessorTest { @Test fun `processor won't remove the first member join event if it can't find the room creation event`() { val timelineItems = listOf( - MatrixTimelineItem.Event("m.room.member", anEventTimelineItem(content = RoomMembershipContent(A_USER_ID, MembershipChange.JOINED))), + MatrixTimelineItem.Event("m.room.member", anEventTimelineItem(content = RoomMembershipContent(A_USER_ID, null, MembershipChange.JOINED))), ) val processor = RoomBeginningPostProcessor() val processedItems = processor.process(timelineItems, isDm = true, hasMoreToLoadBackwards = true) @@ -106,7 +106,7 @@ class RoomBeginningPostProcessorTest { fun `processor won't remove the first member join event if it's not from the room creator`() { val timelineItems = listOf( MatrixTimelineItem.Event("m.room.create", anEventTimelineItem(sender = A_USER_ID, content = StateContent("", OtherState.RoomCreate))), - MatrixTimelineItem.Event("m.room.member", anEventTimelineItem(content = RoomMembershipContent(A_USER_ID_2, MembershipChange.JOINED))), + MatrixTimelineItem.Event("m.room.member", anEventTimelineItem(content = RoomMembershipContent(A_USER_ID_2, null, MembershipChange.JOINED))), ) val processor = RoomBeginningPostProcessor() val processedItems = processor.process(timelineItems, isDm = true, hasMoreToLoadBackwards = true) diff --git a/libraries/matrix/test/build.gradle.kts b/libraries/matrix/test/build.gradle.kts index 9c41948bd7..8e06adeece 100644 --- a/libraries/matrix/test/build.gradle.kts +++ b/libraries/matrix/test/build.gradle.kts @@ -27,6 +27,7 @@ dependencies { api(projects.libraries.matrix.api) api(libs.coroutines.core) implementation(libs.coroutines.test) + implementation(projects.services.analytics.api) implementation(projects.tests.testutils) implementation(libs.kotlinx.collections.immutable) } 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 015f764ff5..be029705fb 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 @@ -20,7 +20,6 @@ import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.ProgressCallback import io.element.android.libraries.matrix.api.core.RoomAlias import io.element.android.libraries.matrix.api.core.RoomId -import io.element.android.libraries.matrix.api.core.RoomIdOrAlias import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.createroom.CreateRoomParameters @@ -33,6 +32,7 @@ import io.element.android.libraries.matrix.api.pusher.PushersService import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoomInfo 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.api.room.preview.RoomPreview import io.element.android.libraries.matrix.api.roomdirectory.RoomDirectoryService import io.element.android.libraries.matrix.api.roomlist.RoomListService @@ -40,7 +40,7 @@ import io.element.android.libraries.matrix.api.user.MatrixSearchUserResults import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.verification.SessionVerificationService import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService -import io.element.android.libraries.matrix.test.media.FakeMediaLoader +import io.element.android.libraries.matrix.test.media.FakeMatrixMediaLoader import io.element.android.libraries.matrix.test.notification.FakeNotificationService import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService import io.element.android.libraries.matrix.test.pushers.FakePushersService @@ -53,7 +53,6 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.flowOf @@ -67,7 +66,7 @@ class FakeMatrixClient( private val userDisplayName: String? = A_USER_NAME, private val userAvatarUrl: String? = AN_AVATAR_URL, override val roomListService: RoomListService = FakeRoomListService(), - override val mediaLoader: MatrixMediaLoader = FakeMediaLoader(), + override val mediaLoader: MatrixMediaLoader = FakeMatrixMediaLoader(), private val sessionVerificationService: FakeSessionVerificationService = FakeSessionVerificationService(), private val pushersService: FakePushersService = FakePushersService(), private val notificationService: FakeNotificationService = FakeNotificationService(), @@ -76,8 +75,8 @@ class FakeMatrixClient( private val encryptionService: FakeEncryptionService = FakeEncryptionService(), private val roomDirectoryService: RoomDirectoryService = FakeRoomDirectoryService(), private val accountManagementUrlString: Result = Result.success(null), - private val resolveRoomAliasResult: (RoomAlias) -> Result = { Result.success(A_ROOM_ID) }, - private val getRoomPreviewResult: (RoomIdOrAlias) -> Result = { Result.failure(AN_EXCEPTION) }, + private val resolveRoomAliasResult: (RoomAlias) -> Result = { Result.success(ResolvedRoomAlias(A_ROOM_ID, emptyList())) }, + private val getRoomPreviewFromRoomIdResult: (RoomId, List) -> Result = { _, _ -> Result.failure(AN_EXCEPTION) }, ) : MatrixClient { var setDisplayNameCalled: Boolean = false private set @@ -95,7 +94,6 @@ class FakeMatrixClient( private var createRoomResult: Result = Result.success(A_ROOM_ID) private var createDmResult: Result = Result.success(A_ROOM_ID) private var findDmResult: RoomId? = A_ROOM_ID - private var logoutFailure: Throwable? = null private val getRoomResults = mutableMapOf() private val searchUserResults = mutableMapOf>() private val getProfileResults = mutableMapOf>() @@ -106,12 +104,18 @@ class FakeMatrixClient( var joinRoomLambda: (RoomId) -> Result = { Result.success(Unit) } + var joinRoomByIdOrAliasLambda: (RoomId, List) -> Result = { _, _ -> + Result.success(Unit) + } var knockRoomLambda: (RoomId) -> Result = { Result.success(Unit) } var getRoomInfoFlowLambda = { _: RoomId -> flowOf>(Optional.empty()) } + var logoutLambda: (Boolean) -> String? = { + null + } override suspend fun getRoom(roomId: RoomId): MatrixRoom? { return getRoomResults[roomId] @@ -156,12 +160,8 @@ class FakeMatrixClient( override suspend fun clearCache() { } - override suspend fun logout(ignoreSdkError: Boolean): String? { - delay(100) - if (ignoreSdkError.not()) { - logoutFailure?.let { throw it } - } - return null + override suspend fun logout(ignoreSdkError: Boolean): String? = simulateLongTask { + return logoutLambda(ignoreSdkError) } override fun close() = Unit @@ -201,6 +201,10 @@ class FakeMatrixClient( override suspend fun joinRoom(roomId: RoomId): Result = joinRoomLambda(roomId) + override suspend fun joinRoomByIdOrAlias(roomId: RoomId, serverNames: List): Result { + return joinRoomByIdOrAliasLambda(roomId, serverNames) + } + override suspend fun knockRoom(roomId: RoomId): Result = knockRoomLambda(roomId) override fun sessionVerificationService(): SessionVerificationService = sessionVerificationService @@ -221,10 +225,6 @@ class FakeMatrixClient( // Mocks - fun givenLogoutError(failure: Throwable?) { - logoutFailure = failure - } - fun givenCreateRoomResult(result: Result) { createRoomResult = result } @@ -285,12 +285,12 @@ class FakeMatrixClient( return Result.success(Unit) } - override suspend fun resolveRoomAlias(roomAlias: RoomAlias): Result = simulateLongTask { + override suspend fun resolveRoomAlias(roomAlias: RoomAlias): Result = simulateLongTask { resolveRoomAliasResult(roomAlias) } - override suspend fun getRoomPreview(roomIdOrAlias: RoomIdOrAlias): Result = simulateLongTask { - getRoomPreviewResult(roomIdOrAlias) + override suspend fun getRoomPreviewFromRoomId(roomId: RoomId, serverNames: List) = simulateLongTask { + getRoomPreviewFromRoomIdResult(roomId, serverNames) } override suspend fun getRecentlyVisitedRooms(): Result> { 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 96346574ce..d10d1ad0d2 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 @@ -30,6 +30,7 @@ import io.element.android.libraries.matrix.api.room.RoomNotificationSettings const val A_USER_NAME = "alice" const val A_PASSWORD = "password" +const val A_SECRET = "secret" val A_USER_ID = UserId("@alice:server.org") val A_USER_ID_2 = UserId("@bob:server.org") @@ -56,6 +57,7 @@ val A_TRANSACTION_ID = TransactionId("aTransactionId") const val A_UNIQUE_ID = "aUniqueId" const val A_ROOM_NAME = "A room name" +const val A_ROOM_RAW_NAME = "A room raw name" const val A_MESSAGE = "Hello world!" const val A_REPLY = "OK, I'll be there!" const val ANOTHER_MESSAGE = "Hello universe!" @@ -76,3 +78,5 @@ val A_THROWABLE = Throwable(A_FAILURE_REASON) val AN_EXCEPTION = Exception(A_FAILURE_REASON) const val A_RECOVERY_KEY = "1234 5678" + +val A_SERVER_LIST = listOf("server1", "server2") diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/auth/FakeAuthenticationService.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/auth/FakeMatrixAuthenticationService.kt similarity index 89% rename from libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/auth/FakeAuthenticationService.kt rename to libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/auth/FakeMatrixAuthenticationService.kt index 2cf6b77a78..9cec3cbecf 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/auth/FakeAuthenticationService.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/auth/FakeMatrixAuthenticationService.kt @@ -31,7 +31,9 @@ import kotlinx.coroutines.flow.flowOf val A_OIDC_DATA = OidcDetails(url = "a-url") -class FakeAuthenticationService : MatrixAuthenticationService { +class FakeMatrixAuthenticationService( + private val matrixClientResult: ((SessionId) -> Result)? = null +) : MatrixAuthenticationService { private val homeserver = MutableStateFlow(null) private var oidcError: Throwable? = null private var oidcCancelError: Throwable? = null @@ -39,15 +41,18 @@ class FakeAuthenticationService : MatrixAuthenticationService { private var changeServerError: Throwable? = null private var matrixClient: MatrixClient? = null + var getLatestSessionIdLambda: (() -> SessionId?) = { null } + override fun loggedInStateFlow(): Flow { return flowOf(LoggedInState.NotLoggedIn) } - override suspend fun getLatestSessionId(): SessionId? { - return null - } + override suspend fun getLatestSessionId(): SessionId? = getLatestSessionIdLambda() override suspend fun restoreSession(sessionId: SessionId): Result { + if (matrixClientResult != null) { + return matrixClientResult.invoke(sessionId) + } return if (matrixClient != null) { Result.success(matrixClient!!) } else { diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/core/BuildMeta.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/core/BuildMeta.kt index 2a70a56051..52c15d05a4 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/core/BuildMeta.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/core/BuildMeta.kt @@ -28,7 +28,7 @@ fun aBuildMeta( applicationId: String = "", lowPrivacyLoggingEnabled: Boolean = true, versionName: String = "", - versionCode: Int = 0, + versionCode: Long = 0, gitRevision: String = "", gitBranchName: String = "", flavorDescription: String = "", diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/encryption/FakeEncryptionService.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/encryption/FakeEncryptionService.kt index cc7f53eca3..b864c69b0b 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/encryption/FakeEncryptionService.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/encryption/FakeEncryptionService.kt @@ -39,6 +39,9 @@ class FakeEncryptionService : EncryptionService { private var enableBackupsFailure: Exception? = null + private var curve25519: String? = null + private var ed25519: String? = null + fun givenEnableBackupsFailure(exception: Exception?) { enableBackupsFailure = exception } @@ -94,6 +97,15 @@ class FakeEncryptionService : EncryptionService { return waitForBackupUploadSteadyStateFlow } + fun givenDeviceKeys(curve25519: String?, ed25519: String?) { + this.curve25519 = curve25519 + this.ed25519 = ed25519 + } + + override suspend fun deviceCurve25519(): String? = curve25519 + + override suspend fun deviceEd25519(): String? = ed25519 + suspend fun emitBackupState(state: BackupState) { backupStateStateFlow.emit(state) } diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/media/FakeMediaLoader.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/media/FakeMatrixMediaLoader.kt similarity index 97% rename from libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/media/FakeMediaLoader.kt rename to libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/media/FakeMatrixMediaLoader.kt index c31beb8e4a..b161a082e7 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/media/FakeMediaLoader.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/media/FakeMatrixMediaLoader.kt @@ -21,7 +21,7 @@ import io.element.android.libraries.matrix.api.media.MediaFile import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.tests.testutils.simulateLongTask -class FakeMediaLoader : MatrixMediaLoader { +class FakeMatrixMediaLoader : MatrixMediaLoader { var shouldFail = false var path: String = "" diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/permalink/FakePermalinkBuilder.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/permalink/FakePermalinkBuilder.kt index 273899e762..f700c3b6af 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/permalink/FakePermalinkBuilder.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/permalink/FakePermalinkBuilder.kt @@ -20,9 +20,9 @@ import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder class FakePermalinkBuilder( - private val result: () -> Result = { Result.failure(Exception("Not implemented")) } + private val result: (UserId) -> Result = { Result.failure(Exception("Not implemented")) } ) : PermalinkBuilder { override fun permalinkForUser(userId: UserId): Result { - return result() + return result(userId) } } diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/permalink/FakePermalinkParser.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/permalink/FakePermalinkParser.kt index d1ffb70f99..525746e690 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/permalink/FakePermalinkParser.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/permalink/FakePermalinkParser.kt @@ -18,9 +18,10 @@ package io.element.android.libraries.matrix.test.permalink import io.element.android.libraries.matrix.api.permalink.PermalinkData import io.element.android.libraries.matrix.api.permalink.PermalinkParser +import io.element.android.tests.testutils.lambda.lambdaError class FakePermalinkParser( - private var result: () -> PermalinkData = { TODO("Not implemented") } + private var result: () -> PermalinkData = { lambdaError() } ) : PermalinkParser { fun givenResult(result: PermalinkData) { this.result = { result } diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/pushers/FakePushersService.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/pushers/FakePushersService.kt index 6ff7e4a20b..3ede3b272f 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/pushers/FakePushersService.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/pushers/FakePushersService.kt @@ -18,8 +18,13 @@ package io.element.android.libraries.matrix.test.pushers import io.element.android.libraries.matrix.api.pusher.PushersService import io.element.android.libraries.matrix.api.pusher.SetHttpPusherData +import io.element.android.libraries.matrix.api.pusher.UnsetHttpPusherData +import io.element.android.tests.testutils.lambda.lambdaError -class FakePushersService : PushersService { - override suspend fun setHttpPusher(setHttpPusherData: SetHttpPusherData) = Result.success(Unit) - override suspend fun unsetHttpPusher(): Result = Result.success(Unit) +class FakePushersService( + private val setHttpPusherResult: (SetHttpPusherData) -> Result = { lambdaError() }, + private val unsetHttpPusherResult: (UnsetHttpPusherData) -> Result = { lambdaError() }, +) : PushersService { + override suspend fun setHttpPusher(setHttpPusherData: SetHttpPusherData) = setHttpPusherResult(setHttpPusherData) + override suspend fun unsetHttpPusher(unsetHttpPusherData: UnsetHttpPusherData): Result = unsetHttpPusherResult(unsetHttpPusherData) } diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt index fff0ce8247..4b4a8e1af0 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt @@ -55,7 +55,7 @@ import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.media.FakeMediaUploadHandler import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService import io.element.android.libraries.matrix.test.timeline.FakeTimeline -import io.element.android.libraries.matrix.test.widget.FakeWidgetDriver +import io.element.android.libraries.matrix.test.widget.FakeMatrixWidgetDriver import io.element.android.tests.testutils.simulateLongTask import kotlinx.collections.immutable.ImmutableMap import kotlinx.collections.immutable.persistentMapOf @@ -125,7 +125,7 @@ class FakeMatrixRoom( private var endPollResult = Result.success(Unit) private var progressCallbackValues = emptyList>() private var generateWidgetWebViewUrlResult = Result.success("https://call.element.io") - private var getWidgetDriverResult: Result = Result.success(FakeWidgetDriver()) + private var getWidgetDriverResult: Result = Result.success(FakeMatrixWidgetDriver()) private var canUserTriggerRoomNotificationResult: Result = Result.success(true) private var canUserJoinCallResult: Result = Result.success(true) private var setIsFavoriteResult = Result.success(Unit) @@ -735,6 +735,7 @@ data class EndPollInvocation( fun aRoomInfo( id: RoomId = A_ROOM_ID, name: String? = A_ROOM_NAME, + rawName: String? = name, topic: String? = "A topic", avatarUrl: String? = AN_AVATAR_URL, isDirect: Boolean = false, @@ -759,6 +760,7 @@ fun aRoomInfo( ) = MatrixRoomInfo( id = id, name = name, + rawName = rawName, topic = topic, avatarUrl = avatarUrl, isDirect = isDirect, diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/join/FakeJoinRoom.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/join/FakeJoinRoom.kt new file mode 100644 index 0000000000..55eade69d4 --- /dev/null +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/join/FakeJoinRoom.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.matrix.test.room.join + +import im.vector.app.features.analytics.plan.JoinedRoom +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.room.join.JoinRoom +import io.element.android.tests.testutils.simulateLongTask + +class FakeJoinRoom( + var lambda: (RoomId, List, JoinedRoom.Trigger) -> Result +) : JoinRoom { + override suspend fun invoke( + roomId: RoomId, + serverNames: List, + trigger: JoinedRoom.Trigger, + ): Result = simulateLongTask { + lambda(roomId, serverNames, trigger) + } +} diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/verification/FakeSessionVerificationService.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/verification/FakeSessionVerificationService.kt index cde77d7c61..780758acbe 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/verification/FakeSessionVerificationService.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/verification/FakeSessionVerificationService.kt @@ -25,17 +25,14 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow class FakeSessionVerificationService : SessionVerificationService { - private val _isReady = MutableStateFlow(false) private val _sessionVerifiedStatus = MutableStateFlow(SessionVerifiedStatus.Unknown) private var _verificationFlowState = MutableStateFlow(VerificationFlowState.Initial) - private var _canVerifySessionFlow = MutableStateFlow(true) + private var _needsSessionVerification = MutableStateFlow(true) var shouldFail = false override val verificationFlowState: StateFlow = _verificationFlowState override val sessionVerifiedStatus: StateFlow = _sessionVerifiedStatus - override val canVerifySessionFlow: Flow = _canVerifySessionFlow - - override val isReady: StateFlow = _isReady + override val needsSessionVerification: Flow = _needsSessionVerification override suspend fun requestVerification() { if (!shouldFail) { @@ -85,12 +82,8 @@ class FakeSessionVerificationService : SessionVerificationService { _verificationFlowState.value = state } - fun givenCanVerifySession(canVerify: Boolean) { - _canVerifySessionFlow.value = canVerify - } - - fun givenIsReady(value: Boolean) { - _isReady.value = value + fun givenNeedsSessionVerification(needsVerification: Boolean) { + _needsSessionVerification.value = needsVerification } override suspend fun reset() { diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/widget/FakeWidgetDriver.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/widget/FakeMatrixWidgetDriver.kt similarity index 98% rename from libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/widget/FakeWidgetDriver.kt rename to libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/widget/FakeMatrixWidgetDriver.kt index a64691fb40..be60b0011b 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/widget/FakeWidgetDriver.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/widget/FakeMatrixWidgetDriver.kt @@ -20,7 +20,7 @@ import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver import kotlinx.coroutines.flow.MutableSharedFlow import java.util.UUID -class FakeWidgetDriver( +class FakeMatrixWidgetDriver( override val id: String = UUID.randomUUID().toString(), ) : MatrixWidgetDriver { private val _sentMessages = mutableListOf() diff --git a/libraries/matrixui/build.gradle.kts b/libraries/matrixui/build.gradle.kts index 6d955a7c05..ed7f3d068d 100644 --- a/libraries/matrixui/build.gradle.kts +++ b/libraries/matrixui/build.gradle.kts @@ -39,6 +39,7 @@ dependencies { implementation(projects.libraries.designsystem) implementation(projects.libraries.core) implementation(projects.libraries.uiStrings) + implementation(projects.libraries.testtags) implementation(libs.coil.compose) implementation(libs.coil.gif) implementation(libs.jsoup) diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/AvatarActionBottomSheet.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/AvatarActionBottomSheet.kt index fd154928ef..41aecd16f3 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/AvatarActionBottomSheet.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/AvatarActionBottomSheet.kt @@ -14,25 +14,23 @@ * limitations under the License. */ -@file:OptIn(ExperimentalMaterialApi::class) -@file:Suppress("UsingMaterialAndMaterial3Libraries") +@file:OptIn(ExperimentalMaterial3Api::class) package io.element.android.libraries.matrix.ui.components +import androidx.activity.compose.BackHandler import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.imePadding import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.material.ExperimentalMaterialApi -import androidx.compose.material.ModalBottomSheetState -import androidx.compose.material.ModalBottomSheetValue +import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.stringResource import io.element.android.compound.theme.ElementTheme import io.element.android.libraries.designsystem.components.list.ListItemContent @@ -41,49 +39,60 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.components.IconSource import io.element.android.libraries.designsystem.theme.components.ListItem import io.element.android.libraries.designsystem.theme.components.ListItemStyle -import io.element.android.libraries.designsystem.theme.components.ModalBottomSheetLayout +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.ui.media.AvatarAction import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf -import kotlinx.coroutines.launch +@OptIn(ExperimentalMaterial3Api::class) @Composable fun AvatarActionBottomSheet( actions: ImmutableList, - modalBottomSheetState: ModalBottomSheetState, - onActionSelected: (action: AvatarAction) -> Unit, + isVisible: Boolean, + onSelectAction: (action: AvatarAction) -> Unit, + onDismiss: () -> Unit, modifier: Modifier = Modifier, ) { val coroutineScope = rememberCoroutineScope() - fun onItemActionClicked(itemAction: AvatarAction) { - onActionSelected(itemAction) - coroutineScope.launch { - modalBottomSheetState.hide() - } + val sheetState = rememberModalBottomSheetState( + skipPartiallyExpanded = true + ) + + BackHandler(enabled = isVisible) { + sheetState.hide(coroutineScope, then = { onDismiss() }) } - ModalBottomSheetLayout( - modifier = modifier, - sheetState = modalBottomSheetState, - displayHandle = true, - sheetContent = { + fun onItemActionClick(itemAction: AvatarAction) { + onSelectAction(itemAction) + sheetState.hide(coroutineScope, then = { onDismiss() }) + } + + if (isVisible) { + ModalBottomSheet( + onDismissRequest = { + sheetState.hide(coroutineScope, then = { onDismiss() }) + }, + modifier = modifier, + sheetState = sheetState, + ) { AvatarActionBottomSheetContent( actions = actions, - onActionClicked = ::onItemActionClicked, + onActionClick = ::onItemActionClick, modifier = Modifier .navigationBarsPadding() .imePadding() ) } - ) + } } @Composable private fun AvatarActionBottomSheetContent( actions: ImmutableList, modifier: Modifier = Modifier, - onActionClicked: (AvatarAction) -> Unit = { }, + onActionClick: (AvatarAction) -> Unit = { }, ) { LazyColumn( modifier = modifier.fillMaxWidth() @@ -92,7 +101,7 @@ private fun AvatarActionBottomSheetContent( items = actions, ) { action -> ListItem( - modifier = Modifier.clickable { onActionClicked(action) }, + modifier = Modifier.clickable { onActionClick(action) }, headlineContent = { Text( text = stringResource(action.titleResId), @@ -115,10 +124,8 @@ private fun AvatarActionBottomSheetContent( internal fun AvatarActionBottomSheetPreview() = ElementPreview { AvatarActionBottomSheet( actions = persistentListOf(AvatarAction.TakePhoto, AvatarAction.ChoosePhoto, AvatarAction.Remove), - modalBottomSheetState = ModalBottomSheetState( - initialValue = ModalBottomSheetValue.Expanded, - density = LocalDensity.current, - ), - onActionSelected = { }, + isVisible = true, + onSelectAction = { }, + onDismiss = { }, ) } 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 index 07010329d3..c9dc2c7c5a 100644 --- 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 @@ -33,40 +33,48 @@ 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.tooling.preview.PreviewParameter +import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.unit.dp 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.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.testtags.TestTags +import io.element.android.libraries.testtags.testTag @Composable fun EditableAvatarView( - userId: String?, + matrixId: String, displayName: String?, avatarUrl: Uri?, avatarSize: AvatarSize, - onAvatarClicked: () -> Unit, + onAvatarClick: () -> Unit, modifier: Modifier = Modifier, ) { - Column(modifier = modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally) { + Column( + modifier = modifier.fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + ) { Box( modifier = Modifier .size(avatarSize.dp) .clickable( interactionSource = remember { MutableInteractionSource() }, - onClick = onAvatarClicked, + onClick = onAvatarClick, indication = rememberRipple(bounded = false), ) + .testTag(TestTags.editAvatar) ) { when (avatarUrl?.scheme) { null, "mxc" -> { - userId?.let { - Avatar( - avatarData = AvatarData(it, displayName, avatarUrl?.toString(), size = avatarSize), - modifier = Modifier.fillMaxSize(), - ) - } + Avatar( + avatarData = AvatarData(matrixId, displayName, avatarUrl?.toString(), size = avatarSize), + modifier = Modifier.fillMaxSize(), + ) } else -> { UnsavedAvatar( @@ -94,3 +102,26 @@ fun EditableAvatarView( } } } + +@PreviewsDayNight +@Composable +internal fun EditableAvatarViewPreview( + @PreviewParameter(EditableAvatarViewUriProvider::class) uri: Uri? +) = ElementPreview { + EditableAvatarView( + matrixId = "id", + displayName = "A room", + avatarUrl = uri, + avatarSize = AvatarSize.EditRoomDetails, + onAvatarClick = {}, + ) +} + +open class EditableAvatarViewUriProvider : PreviewParameterProvider { + override val values: Sequence + get() = sequenceOf( + null, + Uri.parse("mxc://matrix.org/123456"), + Uri.parse("https://example.com/avatar.jpg"), + ) +} diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SelectedRoom.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SelectedRoom.kt index c95f3e4cde..7f8f9f2f56 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SelectedRoom.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SelectedRoom.kt @@ -50,7 +50,7 @@ import io.element.android.libraries.ui.strings.CommonStrings @Composable fun SelectedRoom( roomSummary: RoomSummaryDetails, - onRoomRemoved: (RoomSummaryDetails) -> Unit, + onRemoveRoom: (RoomSummaryDetails) -> Unit, modifier: Modifier = Modifier, ) { Box( @@ -78,7 +78,7 @@ fun SelectedRoom( .clickable( indication = rememberRipple(), interactionSource = remember { MutableInteractionSource() }, - onClick = { onRoomRemoved(roomSummary) } + onClick = { onRemoveRoom(roomSummary) } ), ) { Icon( @@ -98,6 +98,6 @@ internal fun SelectedRoomPreview( ) = ElementPreview { SelectedRoom( roomSummary = roomSummaryDetails, - onRoomRemoved = {}, + onRemoveRoom = {}, ) } diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SelectedUser.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SelectedUser.kt index 418e2ca2ef..8e94763f3c 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SelectedUser.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SelectedUser.kt @@ -53,7 +53,7 @@ import io.element.android.libraries.ui.strings.CommonStrings fun SelectedUser( matrixUser: MatrixUser, canRemove: Boolean, - onUserRemoved: (MatrixUser) -> Unit, + onUserRemove: (MatrixUser) -> Unit, modifier: Modifier = Modifier, ) { Box( @@ -83,7 +83,7 @@ fun SelectedUser( .clickable( indication = rememberRipple(), interactionSource = remember { MutableInteractionSource() }, - onClick = { onUserRemoved(matrixUser) } + onClick = { onUserRemove(matrixUser) } ), ) { Icon( @@ -103,7 +103,7 @@ internal fun SelectedUserPreview() = ElementPreview { SelectedUser( aMatrixUser(displayName = "John Doe"), canRemove = true, - onUserRemoved = {}, + onUserRemove = {}, ) } @@ -113,6 +113,6 @@ internal fun SelectedUserCannotRemovePreview() = ElementPreview { SelectedUser( aMatrixUser(), canRemove = false, - onUserRemoved = {}, + onUserRemove = {}, ) } diff --git a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SelectedUsersRowList.kt b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SelectedUsersRowList.kt index 065f23a661..9299f4eecc 100644 --- a/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SelectedUsersRowList.kt +++ b/libraries/matrixui/src/main/kotlin/io/element/android/libraries/matrix/ui/components/SelectedUsersRowList.kt @@ -48,7 +48,7 @@ import kotlin.math.floor @Composable fun SelectedUsersRowList( selectedUsers: ImmutableList, - onUserRemoved: (MatrixUser) -> Unit, + onUserRemove: (MatrixUser) -> Unit, modifier: Modifier = Modifier, autoScroll: Boolean = false, canDeselect: (MatrixUser) -> Boolean = { true }, @@ -112,7 +112,7 @@ fun SelectedUsersRowList( SelectedUser( matrixUser = selectedUser, canRemove = canDeselect(selectedUser), - onUserRemoved = onUserRemoved, + onUserRemove = onUserRemove, ) }, measurePolicy = { measurables, constraints -> @@ -137,7 +137,7 @@ internal fun SelectedUsersRowListPreview() = ElementPreview { // Two users that will be visible with no scrolling SelectedUsersRowList( selectedUsers = aMatrixUserList().take(2).toImmutableList(), - onUserRemoved = {}, + onUserRemove = {}, modifier = Modifier .width(200.dp) .border(1.dp, Color.Red) @@ -147,7 +147,7 @@ internal fun SelectedUsersRowListPreview() = ElementPreview { for (i in 0..5) { SelectedUsersRowList( selectedUsers = aMatrixUserList().take(6).toImmutableList(), - onUserRemoved = {}, + onUserRemove = {}, modifier = Modifier .width((200 + i * 20).dp) .border(1.dp, Color.Red) 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 index aa3121146a..ab3c80a6e2 100644 --- 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 @@ -62,3 +62,21 @@ fun MatrixRoom.isOwnUserAdmin(): Boolean { val powerLevel = roomInfo?.userPowerLevels?.get(sessionId) ?: 0L return RoomMember.Role.forPowerLevel(powerLevel) == RoomMember.Role.ADMIN } + +@Composable +fun MatrixRoom.rawName(): String? { + val roomInfo by roomInfoFlow.collectAsState(initial = null) + return roomInfo?.rawName +} + +@Composable +fun MatrixRoom.topic(): String? { + val roomInfo by roomInfoFlow.collectAsState(initial = null) + return roomInfo?.topic +} + +@Composable +fun MatrixRoom.avatarUrl(): String? { + val roomInfo by roomInfoFlow.collectAsState(initial = null) + return roomInfo?.avatarUrl +} diff --git a/libraries/matrixui/src/main/res/values-ka/translations.xml b/libraries/matrixui/src/main/res/values-ka/translations.xml new file mode 100644 index 0000000000..07619e7dfd --- /dev/null +++ b/libraries/matrixui/src/main/res/values-ka/translations.xml @@ -0,0 +1,4 @@ + + + "%1$s (%2$s) მოგიწვიათ" + diff --git a/libraries/matrixui/src/main/res/values-pt-rBR/translations.xml b/libraries/matrixui/src/main/res/values-pt-rBR/translations.xml new file mode 100644 index 0000000000..5b5f826912 --- /dev/null +++ b/libraries/matrixui/src/main/res/values-pt-rBR/translations.xml @@ -0,0 +1,4 @@ + + + "%1$s (%2$s) convidou-te" + diff --git a/libraries/matrixui/src/main/res/values-zh/translations.xml b/libraries/matrixui/src/main/res/values-zh/translations.xml new file mode 100644 index 0000000000..6b0d478317 --- /dev/null +++ b/libraries/matrixui/src/main/res/values-zh/translations.xml @@ -0,0 +1,4 @@ + + + "%1$s (%2$s)邀请了你" + diff --git a/libraries/mediaupload/impl/src/test/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessorTest.kt b/libraries/mediaupload/impl/src/test/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessorTest.kt index 3d0f9be181..fbe43426aa 100644 --- a/libraries/mediaupload/impl/src/test/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessorTest.kt +++ b/libraries/mediaupload/impl/src/test/kotlin/io/element/android/libraries/mediaupload/impl/AndroidMediaPreProcessorTest.kt @@ -58,17 +58,16 @@ class AndroidMediaPreProcessorTest { val data = result.getOrThrow() assertThat(data.file.path).endsWith("image.png") val info = data as MediaUploadInfo.Image - // Computing thumbnailFile is failing with Robolectric - assertThat(info.thumbnailFile).isNull() + assertThat(info.thumbnailFile).isNotNull() assertThat(info.imageInfo).isEqualTo( ImageInfo( height = 1_178, width = 1_818, mimetype = MimeTypes.Png, size = 114_867, - thumbnailInfo = null, + ThumbnailInfo(height = 294, width = 454, mimetype = "image/jpeg", size = 4567), thumbnailSource = null, - blurhash = null, + blurhash = "K13]7q%zWC00R4of%\$baad" ) ) assertThat(file.exists()).isTrue() @@ -88,7 +87,6 @@ class AndroidMediaPreProcessorTest { val data = result.getOrThrow() assertThat(data.file.path).endsWith("image.png") val info = data as MediaUploadInfo.Image - // Computing thumbnailFile is failing with Robolectric assertThat(info.thumbnailFile).isNull() assertThat(info.imageInfo).isEqualTo( ImageInfo( diff --git a/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/viewer/MediaViewerNode.kt b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/viewer/MediaViewerNode.kt index 09c8397f50..8504158906 100644 --- a/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/viewer/MediaViewerNode.kt +++ b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/viewer/MediaViewerNode.kt @@ -56,7 +56,7 @@ open class MediaViewerNode @AssistedInject constructor( MediaViewerView( state = state, modifier = modifier, - onBackPressed = this::navigateUp + onBackClick = this::navigateUp ) } } diff --git a/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/viewer/MediaViewerView.kt b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/viewer/MediaViewerView.kt index 1e8261d431..ac9db95644 100644 --- a/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/viewer/MediaViewerView.kt +++ b/libraries/mediaviewer/api/src/main/kotlin/io/element/android/libraries/mediaviewer/api/viewer/MediaViewerView.kt @@ -84,7 +84,7 @@ import kotlin.time.Duration @Composable fun MediaViewerView( state: MediaViewerState, - onBackPressed: () -> Unit, + onBackClick: () -> Unit, modifier: Modifier = Modifier, ) { val snackbarHostState = rememberSnackbarHostState(snackbarMessage = state.snackbarMessage) @@ -99,9 +99,9 @@ fun MediaViewerView( showOverlay = showOverlay, state = state, onDismiss = { - onBackPressed() + onBackClick() }, - onShowOverlayChanged = { + onShowOverlayChange = { showOverlay = it } ) @@ -109,7 +109,7 @@ fun MediaViewerView( MediaViewerTopBar( actionsEnabled = state.downloadedMedia is AsyncData.Success, mimeType = state.mediaInfo.mimeType, - onBackPressed = onBackPressed, + onBackClick = onBackClick, canDownload = state.canDownload, canShare = state.canShare, eventSink = state.eventSink @@ -123,7 +123,7 @@ private fun MediaViewerPage( showOverlay: Boolean, state: MediaViewerState, onDismiss: () -> Unit, - onShowOverlayChanged: (Boolean) -> Unit, + onShowOverlayChange: (Boolean) -> Unit, modifier: Modifier = Modifier, ) { fun onRetry() { @@ -135,7 +135,7 @@ private fun MediaViewerPage( } val currentShowOverlay by rememberUpdatedState(showOverlay) - val currentOnShowOverlayChanged by rememberUpdatedState(onShowOverlayChanged) + val currentOnShowOverlayChange by rememberUpdatedState(onShowOverlayChange) val flickState = rememberFlickToDismissState(dismissThresholdRatio = 0.1f, rotateOnDrag = false) DismissFlickEffects( @@ -145,7 +145,7 @@ private fun MediaViewerPage( onDismiss() }, onDragging = { - currentOnShowOverlayChanged(false) + currentOnShowOverlayChange(false) } ) @@ -171,7 +171,7 @@ private fun MediaViewerPage( LaunchedEffect(playableState) { if (playableState is PlayableState.Playable) { - currentOnShowOverlayChanged(playableState.isShowingControls) + currentOnShowOverlayChange(playableState.isShowingControls) } } @@ -182,7 +182,7 @@ private fun MediaViewerPage( mediaInfo = state.mediaInfo, onClick = { if (playableState is PlayableState.NotPlayable) { - currentOnShowOverlayChanged(!currentShowOverlay) + currentOnShowOverlayChange(!currentShowOverlay) } }, ) @@ -263,7 +263,7 @@ private fun MediaViewerTopBar( canDownload: Boolean, canShare: Boolean, mimeType: String, - onBackPressed: () -> Unit, + onBackClick: () -> Unit, eventSink: (MediaViewerEvents) -> Unit, ) { TopAppBar( @@ -271,7 +271,7 @@ private fun MediaViewerTopBar( colors = TopAppBarDefaults.topAppBarColors( containerColor = Color.Transparent.copy(0.6f), ), - navigationIcon = { BackButton(onClick = onBackPressed) }, + navigationIcon = { BackButton(onClick = onBackClick) }, actions = { IconButton( enabled = actionsEnabled, @@ -386,6 +386,6 @@ private fun backgroundColorFor(flickState: FlickToDismissState): Color { internal fun MediaViewerViewPreview(@PreviewParameter(MediaViewerStateProvider::class) state: MediaViewerState) = ElementPreviewDark { MediaViewerView( state = state, - onBackPressed = {} + onBackClick = {} ) } diff --git a/libraries/mediaviewer/api/src/test/kotlin/io/element/android/libraries/mediaviewer/MediaViewerPresenterTest.kt b/libraries/mediaviewer/api/src/test/kotlin/io/element/android/libraries/mediaviewer/MediaViewerPresenterTest.kt index 42a0a93c64..67d8f7c3a1 100644 --- a/libraries/mediaviewer/api/src/test/kotlin/io/element/android/libraries/mediaviewer/MediaViewerPresenterTest.kt +++ b/libraries/mediaviewer/api/src/test/kotlin/io/element/android/libraries/mediaviewer/MediaViewerPresenterTest.kt @@ -25,7 +25,7 @@ import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher -import io.element.android.libraries.matrix.test.media.FakeMediaLoader +import io.element.android.libraries.matrix.test.media.FakeMatrixMediaLoader import io.element.android.libraries.matrix.test.media.aMediaSource import io.element.android.libraries.mediaviewer.api.local.anApkMediaInfo import io.element.android.libraries.mediaviewer.api.viewer.MediaViewerEvents @@ -51,9 +51,9 @@ class MediaViewerPresenterTest { @Test fun `present - download media success scenario`() = runTest { - val mediaLoader = FakeMediaLoader() + val matrixMediaLoader = FakeMatrixMediaLoader() val mediaActions = FakeLocalMediaActions() - val presenter = createMediaViewerPresenter(mediaLoader, mediaActions) + val presenter = createMediaViewerPresenter(matrixMediaLoader, mediaActions) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { @@ -71,10 +71,10 @@ class MediaViewerPresenterTest { @Test fun `present - check all actions `() = runTest { - val mediaLoader = FakeMediaLoader() + val matrixMediaLoader = FakeMatrixMediaLoader() val mediaActions = FakeLocalMediaActions() val snackbarDispatcher = SnackbarDispatcher() - val presenter = createMediaViewerPresenter(mediaLoader, mediaActions, snackbarDispatcher) + val presenter = createMediaViewerPresenter(matrixMediaLoader, mediaActions, snackbarDispatcher) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { @@ -118,13 +118,13 @@ class MediaViewerPresenterTest { @Test fun `present - download media failure then retry with success scenario`() = runTest { - val mediaLoader = FakeMediaLoader() + val matrixMediaLoader = FakeMatrixMediaLoader() val mediaActions = FakeLocalMediaActions() - val presenter = createMediaViewerPresenter(mediaLoader, mediaActions) + val presenter = createMediaViewerPresenter(matrixMediaLoader, mediaActions) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { - mediaLoader.shouldFail = true + matrixMediaLoader.shouldFail = true val initialState = awaitItem() assertThat(initialState.downloadedMedia).isEqualTo(AsyncData.Uninitialized) assertThat(initialState.mediaInfo).isEqualTo(TESTED_MEDIA_INFO) @@ -132,7 +132,7 @@ class MediaViewerPresenterTest { assertThat(loadingState.downloadedMedia).isInstanceOf(AsyncData.Loading::class.java) val failureState = awaitItem() assertThat(failureState.downloadedMedia).isInstanceOf(AsyncData.Failure::class.java) - mediaLoader.shouldFail = false + matrixMediaLoader.shouldFail = false failureState.eventSink(MediaViewerEvents.RetryLoading) // There is one recomposition because of the retry mechanism skipItems(1) @@ -146,7 +146,7 @@ class MediaViewerPresenterTest { } private fun createMediaViewerPresenter( - mediaLoader: FakeMediaLoader, + matrixMediaLoader: FakeMatrixMediaLoader, localMediaActions: FakeLocalMediaActions, snackbarDispatcher: SnackbarDispatcher = SnackbarDispatcher(), canShare: Boolean = true, @@ -161,7 +161,7 @@ class MediaViewerPresenterTest { canDownload = canDownload, ), localMediaFactory = localMediaFactory, - mediaLoader = mediaLoader, + mediaLoader = matrixMediaLoader, localMediaActions = localMediaActions, snackbarDispatcher = snackbarDispatcher, ) diff --git a/libraries/mediaviewer/api/src/test/kotlin/io/element/android/libraries/mediaviewer/api/viewer/MediaViewerViewTest.kt b/libraries/mediaviewer/api/src/test/kotlin/io/element/android/libraries/mediaviewer/api/viewer/MediaViewerViewTest.kt index e6c210a19d..38c41fac4b 100644 --- a/libraries/mediaviewer/api/src/test/kotlin/io/element/android/libraries/mediaviewer/api/viewer/MediaViewerViewTest.kt +++ b/libraries/mediaviewer/api/src/test/kotlin/io/element/android/libraries/mediaviewer/api/viewer/MediaViewerViewTest.kt @@ -53,7 +53,7 @@ class MediaViewerViewTest { aMediaViewerState( eventSink = eventsRecorder ), - onBackPressed = callback, + onBackClick = callback, ) rule.pressBack() } @@ -127,7 +127,7 @@ class MediaViewerViewTest { mediaInfo = anImageMediaInfo(), eventSink = eventsRecorder ), - onBackPressed = callback, + onBackClick = callback, ) val imageContentDescription = rule.activity.getString(CommonStrings.common_image) rule.onNodeWithContentDescription(imageContentDescription).performTouchInput { swipeDown() } @@ -166,12 +166,12 @@ class MediaViewerViewTest { private fun AndroidComposeTestRule.setMediaViewerView( state: MediaViewerState, - onBackPressed: () -> Unit = EnsureNeverCalled(), + onBackClick: () -> Unit = EnsureNeverCalled(), ) { setContent { MediaViewerView( state = state, - onBackPressed = onBackPressed, + onBackClick = onBackClick, ) } } 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 38b40e6403..93800c55be 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 @@ -38,7 +38,7 @@ fun PermissionsView( title = stringResource(id = CommonStrings.common_permission), content = state.permission.toDialogContent(), submitText = stringResource(id = CommonStrings.action_open_settings), - onSubmitClicked = { + onSubmitClick = { state.eventSink.invoke(PermissionsEvents.OpenSystemSettingAndCloseDialog) }, onDismiss = { state.eventSink.invoke(PermissionsEvents.CloseDialog) }, diff --git a/libraries/permissions/api/src/main/res/values-ka/translations.xml b/libraries/permissions/api/src/main/res/values-ka/translations.xml new file mode 100644 index 0000000000..ffeb3a4312 --- /dev/null +++ b/libraries/permissions/api/src/main/res/values-ka/translations.xml @@ -0,0 +1,7 @@ + + + "იმისათვის, რომ აპლიკაციამ გამოიყენოს კამერა, გთხოვთ, მიანიჭოთ ნებართვა სისტემის პარამეტრებში." + "გთხოვთ, მიანიჭოთ ნებართვა სისტემის პარამეტრებში." + "იმისათვის, რომ აპლიკაციამ მიკროფონი გამოიყენოს, გთხოვთ, მიანიჭოთ ნებართვა სისტემის პარამეტრებში." + "იმისათვის, რომ აპლიკაციამ გამოაჩინოს შეტყობინებები, გთხოვთ, მიანიჭოთ ნებართვა სისტემის პარამეტრებში." + diff --git a/libraries/permissions/api/src/main/res/values-pt-rBR/translations.xml b/libraries/permissions/api/src/main/res/values-pt-rBR/translations.xml new file mode 100644 index 0000000000..6a029c68d6 --- /dev/null +++ b/libraries/permissions/api/src/main/res/values-pt-rBR/translations.xml @@ -0,0 +1,7 @@ + + + "Para que a aplicação possa utilizar a câmara, concede a permissão nas configurações do sistema." + "Concede a permissão nas configurações do sistema." + "Para que a aplicação possa utilizar o microfone, concede essa permissão nas configurações do sistema." + "Para permitir que a aplicação apresente notificações, concede a permissão nas configurações do sistema." + diff --git a/libraries/permissions/api/src/main/res/values-zh/translations.xml b/libraries/permissions/api/src/main/res/values-zh/translations.xml new file mode 100644 index 0000000000..eb093046a3 --- /dev/null +++ b/libraries/permissions/api/src/main/res/values-zh/translations.xml @@ -0,0 +1,7 @@ + + + "为了让应用程序使用相机,请在系统设置中授予权限。" + "请在系统设置中授予权限。" + "为了让应用程序使用麦克风,请在系统设置中授予权限。" + "为了让应用程序显示通知,请在系统设置中授予权限。" + diff --git a/libraries/permissions/impl/src/main/res/values-pt-rBR/translations.xml b/libraries/permissions/impl/src/main/res/values-pt-rBR/translations.xml new file mode 100644 index 0000000000..128711fa89 --- /dev/null +++ b/libraries/permissions/impl/src/main/res/values-pt-rBR/translations.xml @@ -0,0 +1,5 @@ + + + "Verificar se a aplicação consegue mostrar notificações." + "Verificar permissões" + diff --git a/libraries/permissions/impl/src/main/res/values-ro/translations.xml b/libraries/permissions/impl/src/main/res/values-ro/translations.xml new file mode 100644 index 0000000000..af6b9d23f3 --- /dev/null +++ b/libraries/permissions/impl/src/main/res/values-ro/translations.xml @@ -0,0 +1,5 @@ + + + "Verificați dacă aplicația poate afișa notificări." + "Verificați permisiunile" + diff --git a/libraries/permissions/impl/src/main/res/values-zh/translations.xml b/libraries/permissions/impl/src/main/res/values-zh/translations.xml new file mode 100644 index 0000000000..ac123464ed --- /dev/null +++ b/libraries/permissions/impl/src/main/res/values-zh/translations.xml @@ -0,0 +1,5 @@ + + + "检查应用程序是否可以显示通知。" + "检查权限" + diff --git a/libraries/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/store/AppPreferencesStore.kt b/libraries/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/store/AppPreferencesStore.kt index 4e78978873..8bdea34727 100644 --- a/libraries/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/store/AppPreferencesStore.kt +++ b/libraries/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/store/AppPreferencesStore.kt @@ -19,9 +19,6 @@ package io.element.android.features.preferences.api.store import kotlinx.coroutines.flow.Flow interface AppPreferencesStore { - suspend fun setRichTextEditorEnabled(enabled: Boolean) - fun isRichTextEditorEnabledFlow(): Flow - suspend fun setDeveloperModeEnabled(enabled: Boolean) fun isDeveloperModeEnabledFlow(): Flow diff --git a/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStore.kt b/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStore.kt index fdbd7dde8c..95b455a99f 100644 --- a/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStore.kt +++ b/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStore.kt @@ -25,7 +25,6 @@ import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore import com.squareup.anvil.annotations.ContributesBinding import io.element.android.features.preferences.api.store.AppPreferencesStore -import io.element.android.libraries.core.bool.orTrue import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.core.meta.BuildType import io.element.android.libraries.di.AppScope @@ -36,7 +35,6 @@ import javax.inject.Inject private val Context.dataStore: DataStore by preferencesDataStore(name = "elementx_preferences") -private val richTextEditorKey = booleanPreferencesKey("richTextEditor") private val developerModeKey = booleanPreferencesKey("developerMode") private val customElementCallBaseUrlKey = stringPreferencesKey("elementCallBaseUrl") private val themeKey = stringPreferencesKey("theme") @@ -48,19 +46,6 @@ class DefaultAppPreferencesStore @Inject constructor( ) : AppPreferencesStore { private val store = context.dataStore - override suspend fun setRichTextEditorEnabled(enabled: Boolean) { - store.edit { prefs -> - prefs[richTextEditorKey] = enabled - } - } - - override fun isRichTextEditorEnabledFlow(): Flow { - return store.data.map { prefs -> - // enabled by default - prefs[richTextEditorKey].orTrue() - } - } - override suspend fun setDeveloperModeEnabled(enabled: Boolean) { store.edit { prefs -> prefs[developerModeKey] = enabled diff --git a/libraries/preferences/test/src/main/kotlin/io/element/android/libraries/preferences/test/FakeSessionPreferenceStoreFactory.kt b/libraries/preferences/test/src/main/kotlin/io/element/android/libraries/preferences/test/FakeSessionPreferencesStoreFactory.kt similarity index 97% rename from libraries/preferences/test/src/main/kotlin/io/element/android/libraries/preferences/test/FakeSessionPreferenceStoreFactory.kt rename to libraries/preferences/test/src/main/kotlin/io/element/android/libraries/preferences/test/FakeSessionPreferencesStoreFactory.kt index 264ac4ec3a..96761a9712 100644 --- a/libraries/preferences/test/src/main/kotlin/io/element/android/libraries/preferences/test/FakeSessionPreferenceStoreFactory.kt +++ b/libraries/preferences/test/src/main/kotlin/io/element/android/libraries/preferences/test/FakeSessionPreferencesStoreFactory.kt @@ -24,7 +24,7 @@ import io.element.android.tests.testutils.lambda.LambdaTwoParamsRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder import kotlinx.coroutines.CoroutineScope -class FakeSessionPreferenceStoreFactory( +class FakeSessionPreferencesStoreFactory( var getLambda: LambdaTwoParamsRecorder = lambdaRecorder { _, _ -> throw NotImplementedError() }, var removeLambda: LambdaOneParamRecorder = lambdaRecorder { _ -> }, ) : SessionPreferencesStoreFactory { diff --git a/libraries/preferences/test/src/main/kotlin/io/element/android/libraries/preferences/test/InMemoryAppPreferencesStore.kt b/libraries/preferences/test/src/main/kotlin/io/element/android/libraries/preferences/test/InMemoryAppPreferencesStore.kt index e29c4758ca..25563d59eb 100644 --- a/libraries/preferences/test/src/main/kotlin/io/element/android/libraries/preferences/test/InMemoryAppPreferencesStore.kt +++ b/libraries/preferences/test/src/main/kotlin/io/element/android/libraries/preferences/test/InMemoryAppPreferencesStore.kt @@ -21,24 +21,14 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow class InMemoryAppPreferencesStore( - isRichTextEditorEnabled: Boolean = false, isDeveloperModeEnabled: Boolean = false, customElementCallBaseUrl: String? = null, theme: String? = null, ) : AppPreferencesStore { - private val isRichTextEditorEnabled = MutableStateFlow(isRichTextEditorEnabled) private val isDeveloperModeEnabled = MutableStateFlow(isDeveloperModeEnabled) private val customElementCallBaseUrl = MutableStateFlow(customElementCallBaseUrl) private val theme = MutableStateFlow(theme) - override suspend fun setRichTextEditorEnabled(enabled: Boolean) { - isRichTextEditorEnabled.value = enabled - } - - override fun isRichTextEditorEnabledFlow(): Flow { - return isRichTextEditorEnabled - } - override suspend fun setDeveloperModeEnabled(enabled: Boolean) { isDeveloperModeEnabled.value = enabled } diff --git a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/PushService.kt b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/PushService.kt index abfc328e9f..ce27acb7b3 100644 --- a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/PushService.kt +++ b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/PushService.kt @@ -21,8 +21,10 @@ import io.element.android.libraries.pushproviders.api.Distributor import io.element.android.libraries.pushproviders.api.PushProvider interface PushService { - // TODO Move away - fun notificationStyleChanged() + /** + * Return the current push provider, or null if none. + */ + suspend fun getCurrentPushProvider(): PushProvider? /** * Return the list of push providers, available at compile time, and @@ -35,7 +37,11 @@ interface PushService { * * The method has effect only if the [PushProvider] is different than the current one. */ - suspend fun registerWith(matrixClient: MatrixClient, pushProvider: PushProvider, distributor: Distributor) + suspend fun registerWith( + matrixClient: MatrixClient, + pushProvider: PushProvider, + distributor: Distributor, + ): Result /** * Return false in case of early error. diff --git a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/NotificationDrawerManager.kt b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/NotificationDrawerManager.kt index ecdf32f906..9a778195fa 100644 --- a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/NotificationDrawerManager.kt +++ b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/notifications/NotificationDrawerManager.kt @@ -21,5 +21,5 @@ import io.element.android.libraries.matrix.api.core.SessionId interface NotificationDrawerManager { fun clearMembershipNotificationForSession(sessionId: SessionId) - fun clearMembershipNotificationForRoom(sessionId: SessionId, roomId: RoomId, doRender: Boolean) + fun clearMembershipNotificationForRoom(sessionId: SessionId, roomId: RoomId) } diff --git a/libraries/push/impl/build.gradle.kts b/libraries/push/impl/build.gradle.kts index ee528a4ae7..36dddbcedf 100644 --- a/libraries/push/impl/build.gradle.kts +++ b/libraries/push/impl/build.gradle.kts @@ -52,6 +52,7 @@ dependencies { implementation(projects.libraries.network) implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrixui) + implementation(projects.libraries.preferences.api) implementation(projects.libraries.uiStrings) implementation(projects.libraries.troubleshoot.api) api(projects.libraries.pushproviders.api) @@ -63,8 +64,8 @@ dependencies { implementation(projects.services.toolbox.api) testImplementation(libs.test.junit) - testImplementation(libs.test.robolectric) testImplementation(libs.test.mockk) + testImplementation(libs.test.robolectric) testImplementation(libs.test.truth) testImplementation(libs.test.turbine) testImplementation(libs.coil.test) @@ -72,6 +73,7 @@ dependencies { testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.push.test) testImplementation(projects.libraries.pushproviders.test) + testImplementation(projects.libraries.pushstore.test) testImplementation(projects.tests.testutils) testImplementation(projects.services.appnavstate.test) testImplementation(projects.services.toolbox.impl) diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/DefaultPushService.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/DefaultPushService.kt index cff18cfb3d..47e26fb920 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/DefaultPushService.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/DefaultPushService.kt @@ -21,22 +21,23 @@ import io.element.android.libraries.di.AppScope import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.push.api.GetCurrentPushProvider import io.element.android.libraries.push.api.PushService -import io.element.android.libraries.push.impl.notifications.DefaultNotificationDrawerManager +import io.element.android.libraries.push.impl.test.TestPush import io.element.android.libraries.pushproviders.api.Distributor import io.element.android.libraries.pushproviders.api.PushProvider import io.element.android.libraries.pushstore.api.UserPushStoreFactory +import timber.log.Timber import javax.inject.Inject @ContributesBinding(AppScope::class) class DefaultPushService @Inject constructor( - private val defaultNotificationDrawerManager: DefaultNotificationDrawerManager, - private val pushersManager: PushersManager, + private val testPush: TestPush, private val userPushStoreFactory: UserPushStoreFactory, private val pushProviders: Set<@JvmSuppressWildcards PushProvider>, private val getCurrentPushProvider: GetCurrentPushProvider, ) : PushService { - override fun notificationStyleChanged() { - defaultNotificationDrawerManager.notificationStyleChanged() + override suspend fun getCurrentPushProvider(): PushProvider? { + val currentPushProvider = getCurrentPushProvider.getCurrentPushProvider() + return pushProviders.find { it.name == currentPushProvider } } override fun getAvailablePushProviders(): List { @@ -45,26 +46,36 @@ class DefaultPushService @Inject constructor( .sortedBy { it.index } } - /** - * Get current push provider, compare with provided one, then unregister and register if different, and store change. - */ - override suspend fun registerWith(matrixClient: MatrixClient, pushProvider: PushProvider, distributor: Distributor) { + override suspend fun registerWith( + matrixClient: MatrixClient, + pushProvider: PushProvider, + distributor: Distributor, + ): Result { + Timber.d("Registering with ${pushProvider.name}/${distributor.name}}") val userPushStore = userPushStoreFactory.getOrCreate(matrixClient.sessionId) val currentPushProviderName = userPushStore.getPushProviderName() - if (currentPushProviderName != pushProvider.name) { + val currentPushProvider = pushProviders.find { it.name == currentPushProviderName } + val currentDistributorValue = currentPushProvider?.getCurrentDistributor(matrixClient)?.value + if (currentPushProviderName != pushProvider.name || currentDistributorValue != distributor.value) { // Unregister previous one if any - pushProviders.find { it.name == currentPushProviderName }?.unregister(matrixClient) + currentPushProvider + ?.also { Timber.d("Unregistering previous push provider $currentPushProviderName/$currentDistributorValue") } + ?.unregister(matrixClient) + ?.onFailure { + Timber.w(it, "Failed to unregister previous push provider") + return Result.failure(it) + } } - pushProvider.registerWith(matrixClient, distributor) // Store new value userPushStore.setPushProviderName(pushProvider.name) + // Then try to register + return pushProvider.registerWith(matrixClient, distributor) } override suspend fun testPush(): Boolean { - val currentPushProvider = getCurrentPushProvider.getCurrentPushProvider() - val pushProvider = pushProviders.find { it.name == currentPushProvider } ?: return false + val pushProvider = getCurrentPushProvider() ?: return false val config = pushProvider.getCurrentUserPushConfig() ?: return false - pushersManager.testPush(config) + testPush.execute(config) return true } } diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/PushersManager.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/DefaultPusherSubscriber.kt similarity index 67% rename from libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/PushersManager.kt rename to libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/DefaultPusherSubscriber.kt index 4306072e48..481081de1f 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/PushersManager.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/DefaultPusherSubscriber.kt @@ -22,12 +22,9 @@ import io.element.android.libraries.core.log.logger.LoggerTag import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.di.AppScope import io.element.android.libraries.matrix.api.MatrixClient -import io.element.android.libraries.matrix.api.core.EventId -import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.pusher.SetHttpPusherData -import io.element.android.libraries.push.impl.pushgateway.PushGatewayNotifyRequest -import io.element.android.libraries.pushproviders.api.CurrentUserPushConfig +import io.element.android.libraries.matrix.api.pusher.UnsetHttpPusherData import io.element.android.libraries.pushproviders.api.PusherSubscriber import io.element.android.libraries.pushstore.api.UserPushStoreFactory import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecret @@ -36,48 +33,37 @@ import javax.inject.Inject internal const val DEFAULT_PUSHER_FILE_TAG = "mobile" -private val loggerTag = LoggerTag("PushersManager", LoggerTag.PushLoggerTag) +private val loggerTag = LoggerTag("DefaultPusherSubscriber", LoggerTag.PushLoggerTag) @ContributesBinding(AppScope::class) -class PushersManager @Inject constructor( - // private val localeProvider: LocaleProvider, +class DefaultPusherSubscriber @Inject constructor( private val buildMeta: BuildMeta, - // private val getDeviceInfoUseCase: GetDeviceInfoUseCase, - private val pushGatewayNotifyRequest: PushGatewayNotifyRequest, private val pushClientSecret: PushClientSecret, private val userPushStoreFactory: UserPushStoreFactory, ) : PusherSubscriber { - suspend fun testPush(config: CurrentUserPushConfig) { - pushGatewayNotifyRequest.execute( - PushGatewayNotifyRequest.Params( - url = config.url, - appId = PushConfig.PUSHER_APP_ID, - pushKey = config.pushKey, - eventId = TEST_EVENT_ID, - roomId = TEST_ROOM_ID, - ) - ) - } - /** * Register a pusher to the server if not done yet. */ - override suspend fun registerPusher(matrixClient: MatrixClient, pushKey: String, gateway: String) { + override suspend fun registerPusher( + matrixClient: MatrixClient, + pushKey: String, + gateway: String, + ): Result { val userDataStore = userPushStoreFactory.getOrCreate(matrixClient.sessionId) if (userDataStore.getCurrentRegisteredPushKey() == pushKey) { Timber.tag(loggerTag.value) .d("Unnecessary to register again the same pusher, but do it in case the pusher has been removed from the server") } - matrixClient.pushersService().setHttpPusher( - createHttpPusher(pushKey, gateway, matrixClient.sessionId) - ).fold( - { + return matrixClient.pushersService() + .setHttpPusher( + createHttpPusher(pushKey, gateway, matrixClient.sessionId) + ) + .onSuccess { userDataStore.setCurrentRegisteredPushKey(pushKey) - }, - { throwable -> + } + .onFailure { throwable -> Timber.tag(loggerTag.value).e(throwable, "Unable to register the pusher") } - ) } private suspend fun createHttpPusher( @@ -106,12 +92,24 @@ class PushersManager @Inject constructor( return "{\"cs\":\"$secretForUser\"}" } - override suspend fun unregisterPusher(matrixClient: MatrixClient, pushKey: String, gateway: String) { - matrixClient.pushersService().unsetHttpPusher() - } - - companion object { - val TEST_EVENT_ID = EventId("\$THIS_IS_A_FAKE_EVENT_ID") - val TEST_ROOM_ID = RoomId("!room:domain") + override suspend fun unregisterPusher( + matrixClient: MatrixClient, + pushKey: String, + gateway: String, + ): Result { + val userDataStore = userPushStoreFactory.getOrCreate(matrixClient.sessionId) + return matrixClient.pushersService() + .unsetHttpPusher( + unsetHttpPusherData = UnsetHttpPusherData( + pushKey = pushKey, + appId = PushConfig.PUSHER_APP_ID + ) + ) + .onSuccess { + userDataStore.setCurrentRegisteredPushKey(null) + } + .onFailure { throwable -> + Timber.tag(loggerTag.value).e(throwable, "Unable to unregister the pusher") + } } } diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/di/PushModule.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/di/PushModule.kt new file mode 100644 index 0000000000..4a5c7d7a44 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/di/PushModule.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.push.impl.di + +import android.content.Context +import androidx.core.app.NotificationManagerCompat +import com.squareup.anvil.annotations.ContributesTo +import dagger.Module +import dagger.Provides +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.di.ApplicationContext + +@Module +@ContributesTo(AppScope::class) +object PushModule { + @Provides + fun provideNotificationCompatManager(@ApplicationContext context: Context): NotificationManagerCompat { + return NotificationManagerCompat.from(context) + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/ActiveNotificationsProvider.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/ActiveNotificationsProvider.kt new file mode 100644 index 0000000000..c4814d8134 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/ActiveNotificationsProvider.kt @@ -0,0 +1,73 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.push.impl.notifications + +import android.service.notification.StatusBarNotification +import androidx.core.app.NotificationManagerCompat +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId +import javax.inject.Inject + +interface ActiveNotificationsProvider { + fun getAllNotifications(): List + fun getMessageNotificationsForRoom(sessionId: SessionId, roomId: RoomId): List + fun getNotificationsForSession(sessionId: SessionId): List + fun getMembershipNotificationForSession(sessionId: SessionId): List + fun getMembershipNotificationForRoom(sessionId: SessionId, roomId: RoomId): List + fun getSummaryNotification(sessionId: SessionId): StatusBarNotification? + fun count(sessionId: SessionId): Int +} + +@ContributesBinding(AppScope::class) +class DefaultActiveNotificationsProvider @Inject constructor( + private val notificationManager: NotificationManagerCompat, + private val notificationIdProvider: NotificationIdProvider, +) : ActiveNotificationsProvider { + override fun getAllNotifications(): List { + return notificationManager.activeNotifications + } + + override fun getNotificationsForSession(sessionId: SessionId): List { + return notificationManager.activeNotifications.filter { it.groupKey == sessionId.value } + } + + override fun getMembershipNotificationForSession(sessionId: SessionId): List { + val notificationId = notificationIdProvider.getRoomInvitationNotificationId(sessionId) + return getNotificationsForSession(sessionId).filter { it.id == notificationId } + } + + override fun getMessageNotificationsForRoom(sessionId: SessionId, roomId: RoomId): List { + val notificationId = notificationIdProvider.getRoomMessagesNotificationId(sessionId) + return getNotificationsForSession(sessionId).filter { it.id == notificationId && it.tag == roomId.value } + } + + override fun getMembershipNotificationForRoom(sessionId: SessionId, roomId: RoomId): List { + val notificationId = notificationIdProvider.getRoomInvitationNotificationId(sessionId) + return getNotificationsForSession(sessionId).filter { it.id == notificationId && it.tag == roomId.value } + } + + override fun getSummaryNotification(sessionId: SessionId): StatusBarNotification? { + val summaryId = notificationIdProvider.getSummaryNotificationId(sessionId) + return getNotificationsForSession(sessionId).find { it.id == summaryId } + } + + override fun count(sessionId: SessionId): Int { + return getNotificationsForSession(sessionId).size + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventResolver.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolver.kt similarity index 95% rename from libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventResolver.kt rename to libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolver.kt index edc773b89b..78414fddd1 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventResolver.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolver.kt @@ -19,7 +19,9 @@ package io.element.android.libraries.push.impl.notifications import android.content.Context import android.net.Uri import androidx.core.content.FileProvider +import com.squareup.anvil.annotations.ContributesBinding import io.element.android.libraries.core.log.logger.LoggerTag +import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.ApplicationContext import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClientProvider @@ -55,7 +57,7 @@ import io.element.android.services.toolbox.api.systemclock.SystemClock import timber.log.Timber import javax.inject.Inject -private val loggerTag = LoggerTag("NotifiableEventResolver", LoggerTag.NotificationLoggerTag) +private val loggerTag = LoggerTag("DefaultNotifiableEventResolver", LoggerTag.NotificationLoggerTag) /** * The notifiable event resolver is able to create a NotifiableEvent (view model for notifications) from an sdk Event. @@ -63,15 +65,20 @@ private val loggerTag = LoggerTag("NotifiableEventResolver", LoggerTag.Notificat * The NotifiableEventResolver is the only aware of session/store, the NotificationDrawerManager has no knowledge of that, * this pattern allow decoupling between the object responsible of displaying notifications and the matrix sdk. */ -class NotifiableEventResolver @Inject constructor( +interface NotifiableEventResolver { + suspend fun resolveEvent(sessionId: SessionId, roomId: RoomId, eventId: EventId): NotifiableEvent? +} + +@ContributesBinding(AppScope::class) +class DefaultNotifiableEventResolver @Inject constructor( private val stringProvider: StringProvider, private val clock: SystemClock, private val matrixClientProvider: MatrixClientProvider, private val notificationMediaRepoFactory: NotificationMediaRepo.Factory, @ApplicationContext private val context: Context, private val permalinkParser: PermalinkParser, -) { - suspend fun resolveEvent(sessionId: SessionId, roomId: RoomId, eventId: EventId): NotifiableEvent? { +) : NotifiableEventResolver { + override suspend fun resolveEvent(sessionId: SessionId, roomId: RoomId, eventId: EventId): NotifiableEvent? { // Restore session val client = matrixClientProvider.getOrRestore(sessionId).getOrNull() ?: return null val notificationService = client.notificationService() @@ -143,8 +150,10 @@ class NotifiableEventResolver @Inject constructor( } NotificationContent.MessageLike.CallAnswer, NotificationContent.MessageLike.CallCandidates, - NotificationContent.MessageLike.CallHangup -> null.also { + NotificationContent.MessageLike.CallHangup, + is NotificationContent.MessageLike.CallNotify -> { // TODO CallNotify will be handled separately in the future Timber.tag(loggerTag.value).d("Ignoring notification for call ${content.javaClass.simpleName}") + null } is NotificationContent.MessageLike.CallInvite -> { buildNotifiableMessageEvent( diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManager.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManager.kt index 7c9a60c279..cfbf3c6950 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManager.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManager.kt @@ -16,14 +16,13 @@ package io.element.android.libraries.push.impl.notifications -import io.element.android.libraries.androidutils.throttler.FirstThrottler -import io.element.android.libraries.core.cache.CircularCache -import io.element.android.libraries.core.coroutine.CoroutineDispatchers +import androidx.annotation.VisibleForTesting +import androidx.core.app.NotificationManagerCompat import io.element.android.libraries.core.data.tryOrNull import io.element.android.libraries.core.log.logger.LoggerTag -import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.SingleIn +import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClientProvider import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId @@ -33,45 +32,36 @@ import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder import io.element.android.libraries.push.api.notifications.NotificationDrawerManager import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent +import io.element.android.libraries.push.impl.notifications.model.shouldIgnoreEventInRoom import io.element.android.services.appnavstate.api.AppNavigationStateService import io.element.android.services.appnavstate.api.NavigationState import io.element.android.services.appnavstate.api.currentSessionId import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job -import kotlinx.coroutines.delay import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import timber.log.Timber import javax.inject.Inject private val loggerTag = LoggerTag("DefaultNotificationDrawerManager", LoggerTag.NotificationLoggerTag) /** - * The NotificationDrawerManager receives notification events as they arrived (from event stream or fcm) and + * The NotificationDrawerManager receives notification events as they arrive (from event stream or fcm) and * organise them in order to display them in the notification drawer. * Events can be grouped into the same notification, old (already read) events can be removed to do some cleaning. */ @SingleIn(AppScope::class) class DefaultNotificationDrawerManager @Inject constructor( - private val notifiableEventProcessor: NotifiableEventProcessor, + private val notificationManager: NotificationManagerCompat, private val notificationRenderer: NotificationRenderer, - private val notificationEventPersistence: NotificationEventPersistence, - private val filteredEventDetector: FilteredEventDetector, + private val notificationIdProvider: NotificationIdProvider, private val appNavigationStateService: AppNavigationStateService, - private val coroutineScope: CoroutineScope, - private val dispatchers: CoroutineDispatchers, - private val buildMeta: BuildMeta, + coroutineScope: CoroutineScope, private val matrixClientProvider: MatrixClientProvider, private val imageLoaderHolder: ImageLoaderHolder, + private val activeNotificationsProvider: ActiveNotificationsProvider, ) : NotificationDrawerManager { private var appNavigationStateObserver: Job? = null - /** - * Lazily initializes the NotificationState as we rely on having a current session in order to fetch the persisted queue of events. - */ - private val notificationState by lazy { createInitialNotificationState() } - private val firstThrottler = FirstThrottler(200) - // TODO EAx add a setting per user for this private var useCompleteNotificationFormat = true @@ -84,7 +74,8 @@ class DefaultNotificationDrawerManager @Inject constructor( } // For test only - fun destroy() { + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal fun destroy() { appNavigationStateObserver?.cancel() } @@ -105,7 +96,6 @@ class DefaultNotificationDrawerManager @Inject constructor( clearMessagesForRoom( sessionId = navigationState.parentSpace.parentSession.sessionId, roomId = navigationState.roomId, - doRender = true, ) } is NavigationState.Thread -> { @@ -119,95 +109,71 @@ class DefaultNotificationDrawerManager @Inject constructor( currentAppNavigationState = navigationState } - private fun createInitialNotificationState(): NotificationState { - val queuedEvents = notificationEventPersistence.loadEvents(factory = { rawEvents -> - NotificationEventQueue(rawEvents.toMutableList(), seenEventIds = CircularCache.create(cacheSize = 25)) - }) - val renderedEvents = queuedEvents.rawEvents().map { ProcessedEvent(ProcessedEvent.Type.KEEP, it) }.toMutableList() - return NotificationState(queuedEvents, renderedEvents) - } - - private fun NotificationEventQueue.onNotifiableEventReceived(notifiableEvent: NotifiableEvent) { - if (buildMeta.lowPrivacyLoggingEnabled) { - Timber.tag(loggerTag.value).d("onNotifiableEventReceived(): $notifiableEvent") - } else { - Timber.tag(loggerTag.value).d("onNotifiableEventReceived(): is push: ${notifiableEvent.canBeReplaced}") - } - - if (filteredEventDetector.shouldBeIgnored(notifiableEvent)) { - Timber.tag(loggerTag.value).d("onNotifiableEventReceived(): ignore the event") - return - } - - add(notifiableEvent) - } - /** - * Should be called as soon as a new event is ready to be displayed. - * The notification corresponding to this event will not be displayed until - * #refreshNotificationDrawer() is called. + * Should be called as soon as a new event is ready to be displayed, filtering out notifications that shouldn't be displayed. * Events might be grouped and there might not be one notification per event! */ - fun onNotifiableEventReceived(notifiableEvent: NotifiableEvent) { - updateEvents(doRender = true) { - it.onNotifiableEventReceived(notifiableEvent) + suspend fun onNotifiableEventReceived(notifiableEvent: NotifiableEvent) { + if (notifiableEvent.shouldIgnoreEventInRoom(appNavigationStateService.appNavigationState.value)) { + return } + renderEvents(listOf(notifiableEvent)) } /** - * Clear all known events and refresh the notification drawer. + * Clear all known message events for a [sessionId]. */ - fun clearAllMessagesEvents(sessionId: SessionId, doRender: Boolean) { - updateEvents(doRender = doRender) { - it.clearMessagesForSession(sessionId) - } + fun clearAllMessagesEvents(sessionId: SessionId) { + notificationManager.cancel(null, notificationIdProvider.getRoomMessagesNotificationId(sessionId)) + clearSummaryNotificationIfNeeded(sessionId) } /** - * Clear all notifications related to the session and refresh the notification drawer. + * Clear all notifications related to the session. */ fun clearAllEvents(sessionId: SessionId) { - updateEvents(doRender = true) { - it.clearAllForSession(sessionId) - } + activeNotificationsProvider.getNotificationsForSession(sessionId) + .forEach { notificationManager.cancel(it.tag, it.id) } } /** - * Should be called when the application is currently opened and showing timeline for the given roomId. + * Should be called when the application is currently opened and showing timeline for the given [roomId]. * Used to ignore events related to that room (no need to display notification) and clean any existing notification on this room. * Can also be called when a notification for this room is dismissed by the user. */ - fun clearMessagesForRoom(sessionId: SessionId, roomId: RoomId, doRender: Boolean) { - updateEvents(doRender = doRender) { - it.clearMessagesForRoom(sessionId, roomId) - } + fun clearMessagesForRoom(sessionId: SessionId, roomId: RoomId) { + notificationManager.cancel(roomId.value, notificationIdProvider.getRoomMessagesNotificationId(sessionId)) + clearSummaryNotificationIfNeeded(sessionId) } override fun clearMembershipNotificationForSession(sessionId: SessionId) { - updateEvents(doRender = true) { - it.clearMembershipNotificationForSession(sessionId) - } + activeNotificationsProvider.getMembershipNotificationForSession(sessionId) + .forEach { notificationManager.cancel(it.tag, it.id) } + clearSummaryNotificationIfNeeded(sessionId) } /** * Clear invitation notification for the provided room. */ - override fun clearMembershipNotificationForRoom( - sessionId: SessionId, - roomId: RoomId, - doRender: Boolean, - ) { - updateEvents(doRender = doRender) { - it.clearMembershipNotificationForRoom(sessionId, roomId) - } + override fun clearMembershipNotificationForRoom(sessionId: SessionId, roomId: RoomId) { + activeNotificationsProvider.getMembershipNotificationForRoom(sessionId, roomId) + .forEach { notificationManager.cancel(it.tag, it.id) } + clearSummaryNotificationIfNeeded(sessionId) } /** * Clear the notifications for a single event. */ - fun clearEvent(sessionId: SessionId, eventId: EventId, doRender: Boolean) { - updateEvents(doRender = doRender) { - it.clearEvent(sessionId, eventId) + fun clearEvent(sessionId: SessionId, eventId: EventId) { + val id = notificationIdProvider.getRoomEventNotificationId(sessionId) + notificationManager.cancel(eventId.value, id) + clearSummaryNotificationIfNeeded(sessionId) + } + + private fun clearSummaryNotificationIfNeeded(sessionId: SessionId) { + val summaryNotification = activeNotificationsProvider.getSummaryNotification(sessionId) + if (summaryNotification != null && activeNotificationsProvider.count(sessionId) == 1) { + notificationManager.cancel(null, summaryNotification.id) } } @@ -215,81 +181,19 @@ class DefaultNotificationDrawerManager @Inject constructor( * Should be called when the application is currently opened and showing timeline for the given threadId. * Used to ignore events related to that thread (no need to display notification) and clean any existing notification on this room. */ + @Suppress("UNUSED_PARAMETER") private fun onEnteringThread(sessionId: SessionId, roomId: RoomId, threadId: ThreadId) { - updateEvents(doRender = true) { - it.clearMessagesForThread(sessionId, roomId, threadId) - } + // TODO maybe we'll have to embed more data in the tag to get a threadId + // Do nothing for now } - // TODO EAx Must be per account - fun notificationStyleChanged() { - updateEvents(doRender = true) { - val newSettings = true // pushDataStore.useCompleteNotificationFormat() - if (newSettings != useCompleteNotificationFormat) { - // Settings has changed, remove all current notifications - notificationRenderer.cancelAllNotifications() - useCompleteNotificationFormat = newSettings - } - } - } - - private fun updateEvents( - doRender: Boolean, - action: (NotificationEventQueue) -> Unit, - ) { - notificationState.updateQueuedEvents { queuedEvents, _ -> - action(queuedEvents) - } - coroutineScope.refreshNotificationDrawer(doRender) - } - - private fun CoroutineScope.refreshNotificationDrawer(doRender: Boolean) = launch { - // Implement last throttler - val canHandle = firstThrottler.canHandle() - Timber.tag(loggerTag.value).v("refreshNotificationDrawer($doRender), delay: ${canHandle.waitMillis()} ms") - withContext(dispatchers.io) { - delay(canHandle.waitMillis()) - try { - refreshNotificationDrawerBg(doRender) - } catch (throwable: Throwable) { - // It can happen if for instance session has been destroyed. It's a bit ugly to try catch like this, but it's safer - Timber.tag(loggerTag.value).w(throwable, "refreshNotificationDrawerBg failure") - } - } - } - - private suspend fun refreshNotificationDrawerBg(doRender: Boolean) { - Timber.tag(loggerTag.value).v("refreshNotificationDrawerBg($doRender)") - val eventsToRender = notificationState.updateQueuedEvents { queuedEvents, renderedEvents -> - notifiableEventProcessor.process(queuedEvents.rawEvents(), renderedEvents).also { - queuedEvents.clearAndAdd(it.onlyKeptEvents()) - } - } - - if (notificationState.hasAlreadyRendered(eventsToRender)) { - Timber.tag(loggerTag.value).d("Skipping notification update due to event list not changing") - } else { - notificationState.clearAndAddRenderedEvents(eventsToRender) - if (doRender) { - renderEvents(eventsToRender) - } - persistEvents() - } - } - - private fun persistEvents() { - notificationState.queuedEvents { queuedEvents -> - notificationEventPersistence.persistEvents(queuedEvents) - } - } - - private suspend fun renderEvents(eventsToRender: List>) { + private suspend fun renderEvents(eventsToRender: List) { // Group by sessionId val eventsForSessions = eventsToRender.groupBy { - it.event.sessionId + it.sessionId } - eventsForSessions.forEach { (sessionId, notifiableEvents) -> + for ((sessionId, notifiableEvents) in eventsForSessions) { val client = matrixClientProvider.getOrRestore(sessionId).getOrThrow() val imageLoader = imageLoaderHolder.get(client) val userFromCache = client.userProfile.value @@ -297,27 +201,29 @@ class DefaultNotificationDrawerManager @Inject constructor( // We have an avatar and a display name, use it userFromCache } else { - tryOrNull( - onError = { Timber.tag(loggerTag.value).e(it, "Unable to retrieve info for user ${sessionId.value}") }, - operation = { - client.getUserProfile().getOrNull() - ?.let { - // displayName cannot be empty else NotificationCompat.MessagingStyle() will crash - if (it.displayName.isNullOrEmpty()) { - it.copy(displayName = sessionId.value) - } else { - it - } - } - } - ) ?: MatrixUser( - userId = sessionId, - displayName = sessionId.value, - avatarUrl = null - ) + client.getSafeUserProfile() } notificationRenderer.render(currentUser, useCompleteNotificationFormat, notifiableEvents, imageLoader) } } + + private suspend fun MatrixClient.getSafeUserProfile(): MatrixUser { + return tryOrNull( + onError = { Timber.tag(loggerTag.value).e(it, "Unable to retrieve info for user ${sessionId.value}") }, + operation = { + val profile = getUserProfile().getOrNull() + // displayName cannot be empty else NotificationCompat.MessagingStyle() will crash + if (profile?.displayName.isNullOrEmpty()) { + profile?.copy(displayName = sessionId.value) + } else { + profile + } + } + ) ?: MatrixUser( + userId = sessionId, + displayName = sessionId.value, + avatarUrl = null + ) + } } diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationEventPersistence.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationEventPersistence.kt deleted file mode 100644 index 3646837937..0000000000 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationEventPersistence.kt +++ /dev/null @@ -1,94 +0,0 @@ -/* - * Copyright (c) 2021 New Vector Ltd - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package io.element.android.libraries.push.impl.notifications - -import android.content.Context -import com.squareup.anvil.annotations.ContributesBinding -import io.element.android.libraries.androidutils.file.EncryptedFileFactory -import io.element.android.libraries.androidutils.file.safeDelete -import io.element.android.libraries.core.data.tryOrNull -import io.element.android.libraries.core.log.logger.LoggerTag -import io.element.android.libraries.di.AppScope -import io.element.android.libraries.di.ApplicationContext -import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent -import timber.log.Timber -import java.io.File -import java.io.ObjectInputStream -import java.io.ObjectOutputStream -import javax.inject.Inject - -private const val ROOMS_NOTIFICATIONS_FILE_NAME_LEGACY = "im.vector.notifications.cache" -private const val FILE_NAME = "notifications.bin" - -private val loggerTag = LoggerTag("NotificationEventPersistence", LoggerTag.NotificationLoggerTag) - -@ContributesBinding(AppScope::class) -class DefaultNotificationEventPersistence @Inject constructor( - @ApplicationContext private val context: Context, -) : NotificationEventPersistence { - private val file by lazy { - deleteLegacyFileIfAny() - context.getDatabasePath(FILE_NAME) - } - - private val encryptedFile by lazy { - EncryptedFileFactory(context).create(file) - } - - override fun loadEvents(factory: (List) -> NotificationEventQueue): NotificationEventQueue { - val rawEvents: ArrayList? = file - .takeIf { it.exists() } - ?.let { - try { - encryptedFile.openFileInput().use { fis -> - ObjectInputStream(fis).use { ois -> - @Suppress("UNCHECKED_CAST") - ois.readObject() as? ArrayList - } - }.also { - Timber.tag(loggerTag.value).d("Deserializing ${it?.size} NotifiableEvent(s)") - } - } catch (e: Throwable) { - Timber.tag(loggerTag.value).e(e, "## Failed to load cached notification info") - null - } - } - return factory(rawEvents.orEmpty()) - } - - override fun persistEvents(queuedEvents: NotificationEventQueue) { - Timber.tag(loggerTag.value).d("Serializing ${queuedEvents.rawEvents().size} NotifiableEvent(s)") - // Always delete file before writing, or encryptedFile.openFileOutput() will throw - file.safeDelete() - if (queuedEvents.isEmpty()) return - try { - encryptedFile.openFileOutput().use { fos -> - ObjectOutputStream(fos).use { oos -> - oos.writeObject(queuedEvents.rawEvents()) - } - } - } catch (e: Throwable) { - Timber.tag(loggerTag.value).e(e, "## Failed to save cached notification info") - } - } - - private fun deleteLegacyFileIfAny() { - tryOrNull { - File(context.applicationContext.cacheDir, ROOMS_NOTIFICATIONS_FILE_NAME_LEGACY).delete() - } - } -} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/FilteredEventDetector.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/FilteredEventDetector.kt deleted file mode 100644 index 3219427b8a..0000000000 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/FilteredEventDetector.kt +++ /dev/null @@ -1,57 +0,0 @@ -/* - * Copyright (c) 2023 New Vector Ltd - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package io.element.android.libraries.push.impl.notifications - -import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent -import javax.inject.Inject - -class FilteredEventDetector @Inject constructor( - // private val activeSessionDataSource: ActiveSessionDataSource -) { - /** - * Returns true if the given event should be ignored. - * Used to skip notifications if a non expected message is received. - */ - fun shouldBeIgnored(@Suppress("UNUSED_PARAMETER") notifiableEvent: NotifiableEvent): Boolean { - /* TODO EAx - val session = activeSessionDataSource.currentValue?.orNull() ?: return false - - if (notifiableEvent is NotifiableMessageEvent) { - val room = session.getRoom(notifiableEvent.roomId) ?: return false - val timelineEvent = room.getTimelineEvent(notifiableEvent.eventId) ?: return false - return timelineEvent.shouldBeIgnored() - } - - */ - return false - } - - /* - /** - * Whether the timeline event should be ignored. - */ - private fun TimelineEvent.shouldBeIgnored(): Boolean { - if (root.isVoiceMessage()) { - val audioEvent = root.asMessageAudioEvent() - // if the event is a voice message related to a voice broadcast, only show the event on the first chunk. - return audioEvent.isVoiceBroadcast() && audioEvent?.sequence != 1 - } - - return false - } - */ -} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventProcessor.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventProcessor.kt deleted file mode 100644 index 4da6dbdd59..0000000000 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventProcessor.kt +++ /dev/null @@ -1,77 +0,0 @@ -/* - * Copyright (c) 2021 New Vector Ltd - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package io.element.android.libraries.push.impl.notifications - -import io.element.android.libraries.core.log.logger.LoggerTag -import io.element.android.libraries.matrix.api.timeline.item.event.EventType -import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent -import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent -import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent -import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent -import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent -import io.element.android.libraries.push.impl.notifications.model.shouldIgnoreEventInRoom -import io.element.android.services.appnavstate.api.AppNavigationStateService -import timber.log.Timber -import javax.inject.Inject - -private typealias ProcessedEvents = List> - -private val loggerTag = LoggerTag("NotifiableEventProcessor", LoggerTag.NotificationLoggerTag) - -class NotifiableEventProcessor @Inject constructor( - private val outdatedDetector: OutdatedEventDetector, - private val appNavigationStateService: AppNavigationStateService, -) { - fun process( - queuedEvents: List, - renderedEvents: ProcessedEvents, - ): ProcessedEvents { - val appState = appNavigationStateService.appNavigationState.value - val processedEvents = queuedEvents.map { - val type = when (it) { - is InviteNotifiableEvent -> ProcessedEvent.Type.KEEP - is NotifiableMessageEvent -> when { - it.shouldIgnoreEventInRoom(appState) -> { - ProcessedEvent.Type.REMOVE - .also { Timber.tag(loggerTag.value).d("notification message removed due to currently viewing the same room or thread") } - } - outdatedDetector.isMessageOutdated(it) -> ProcessedEvent.Type.REMOVE - .also { Timber.tag(loggerTag.value).d("notification message removed due to being read") } - else -> ProcessedEvent.Type.KEEP - } - is SimpleNotifiableEvent -> when (it.type) { - EventType.REDACTION -> ProcessedEvent.Type.REMOVE - else -> ProcessedEvent.Type.KEEP - } - is FallbackNotifiableEvent -> when { - it.shouldIgnoreEventInRoom(appState) -> { - ProcessedEvent.Type.REMOVE - .also { Timber.tag(loggerTag.value).d("notification fallback removed due to currently viewing the same room or thread") } - } - else -> ProcessedEvent.Type.KEEP - } - } - ProcessedEvent(type, it) - } - - val removedEventsDiff = renderedEvents.filter { renderedEvent -> - queuedEvents.none { it.eventId == renderedEvent.event.eventId } - }.map { ProcessedEvent(ProcessedEvent.Type.REMOVE, it.event) } - - return removedEventsDiff + processedEvents - } -} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiver.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiver.kt index 360357af54..0f00d552e7 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiver.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationBroadcastReceiver.kt @@ -19,11 +19,17 @@ package io.element.android.libraries.push.impl.notifications import android.content.BroadcastReceiver import android.content.Context import android.content.Intent +import io.element.android.features.preferences.api.store.SessionPreferencesStoreFactory import io.element.android.libraries.architecture.bindings import io.element.android.libraries.core.log.logger.LoggerTag +import io.element.android.libraries.matrix.api.MatrixClientProvider import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.timeline.ReceiptType +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch import timber.log.Timber import javax.inject.Inject @@ -33,87 +39,69 @@ private val loggerTag = LoggerTag("NotificationBroadcastReceiver", LoggerTag.Not * Receives actions broadcast by notification (on click, on dismiss, inline replies, etc.). */ class NotificationBroadcastReceiver : BroadcastReceiver() { + @Inject lateinit var appCoroutineScope: CoroutineScope + @Inject lateinit var matrixClientProvider: MatrixClientProvider + @Inject lateinit var sessionPreferencesStore: SessionPreferencesStoreFactory @Inject lateinit var defaultNotificationDrawerManager: DefaultNotificationDrawerManager @Inject lateinit var actionIds: NotificationActionIds override fun onReceive(context: Context?, intent: Intent?) { if (intent == null || context == null) return - context.bindings().inject(this) val sessionId = intent.extras?.getString(KEY_SESSION_ID)?.let(::SessionId) ?: return val roomId = intent.getStringExtra(KEY_ROOM_ID)?.let(::RoomId) val eventId = intent.getStringExtra(KEY_EVENT_ID)?.let(::EventId) + + context.bindings().inject(this) + Timber.tag(loggerTag.value).d("onReceive: ${intent.action} ${intent.data} for: ${roomId?.value}/${eventId?.value}") when (intent.action) { actionIds.smartReply -> handleSmartReply(intent, context) actionIds.dismissRoom -> if (roomId != null) { - defaultNotificationDrawerManager.clearMessagesForRoom(sessionId, roomId, doRender = false) + defaultNotificationDrawerManager.clearMessagesForRoom(sessionId, roomId) } actionIds.dismissSummary -> - defaultNotificationDrawerManager.clearAllMessagesEvents(sessionId, doRender = false) + defaultNotificationDrawerManager.clearAllMessagesEvents(sessionId) actionIds.dismissInvite -> if (roomId != null) { - defaultNotificationDrawerManager.clearMembershipNotificationForRoom(sessionId, roomId, doRender = false) + defaultNotificationDrawerManager.clearMembershipNotificationForRoom(sessionId, roomId) } actionIds.dismissEvent -> if (eventId != null) { - defaultNotificationDrawerManager.clearEvent(sessionId, eventId, doRender = false) + defaultNotificationDrawerManager.clearEvent(sessionId, eventId) } actionIds.markRoomRead -> if (roomId != null) { - defaultNotificationDrawerManager.clearMessagesForRoom(sessionId, roomId, doRender = true) + defaultNotificationDrawerManager.clearMessagesForRoom(sessionId, roomId) handleMarkAsRead(sessionId, roomId) } actionIds.join -> if (roomId != null) { - defaultNotificationDrawerManager.clearMembershipNotificationForRoom(sessionId, roomId, doRender = true) + defaultNotificationDrawerManager.clearMembershipNotificationForRoom(sessionId, roomId) handleJoinRoom(sessionId, roomId) } actionIds.reject -> if (roomId != null) { - defaultNotificationDrawerManager.clearMembershipNotificationForRoom(sessionId, roomId, doRender = true) + defaultNotificationDrawerManager.clearMembershipNotificationForRoom(sessionId, roomId) handleRejectRoom(sessionId, roomId) } } } - @Suppress("UNUSED_PARAMETER") - private fun handleJoinRoom(sessionId: SessionId, roomId: RoomId) { - /* - activeSessionHolder.getSafeActiveSession()?.let { session -> - val room = session.getRoom(roomId) - if (room != null) { - session.coroutineScope.launch { - tryOrNull { - session.roomService().joinRoom(room.roomId) - analyticsTracker.capture(room.roomSummary().toAnalyticsJoinedRoom(JoinedRoom.Trigger.Notification)) - } - } - } - } - */ + private fun handleJoinRoom(sessionId: SessionId, roomId: RoomId) = appCoroutineScope.launch { + val client = matrixClientProvider.getOrRestore(sessionId).getOrNull() ?: return@launch + client.joinRoom(roomId) } - @Suppress("UNUSED_PARAMETER") - private fun handleRejectRoom(sessionId: SessionId, roomId: RoomId) { - /* - activeSessionHolder.getSafeActiveSession()?.let { session -> - session.coroutineScope.launch { - tryOrNull { session.roomService().leaveRoom(roomId) } - } - } - - */ + private fun handleRejectRoom(sessionId: SessionId, roomId: RoomId) = appCoroutineScope.launch { + val client = matrixClientProvider.getOrRestore(sessionId).getOrNull() ?: return@launch + client.getRoom(roomId)?.leave() } - @Suppress("UNUSED_PARAMETER") - private fun handleMarkAsRead(sessionId: SessionId, roomId: RoomId) { - /* - activeSessionHolder.getActiveSession().let { session -> - val room = session.getRoom(roomId) - if (room != null) { - session.coroutineScope.launch { - tryOrNull { room.readService().markAsRead(ReadService.MarkAsReadParams.READ_RECEIPT, mainTimeLineOnly = false) } - } - } + private fun handleMarkAsRead(sessionId: SessionId, roomId: RoomId) = appCoroutineScope.launch { + val client = matrixClientProvider.getOrRestore(sessionId).getOrNull() ?: return@launch + val isSendPublicReadReceiptsEnabled = sessionPreferencesStore.get(sessionId, this).isSendPublicReadReceiptsEnabled().first() + val receiptType = if (isSendPublicReadReceiptsEnabled) { + ReceiptType.READ + } else { + ReceiptType.READ_PRIVATE } - - */ + client.getRoom(roomId)?.markAsRead(receiptType = receiptType) } @Suppress("UNUSED_PARAMETER") diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDataFactory.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDataFactory.kt new file mode 100644 index 0000000000..45464bed8e --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDataFactory.kt @@ -0,0 +1,239 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.push.impl.notifications + +import android.app.Notification +import android.graphics.Typeface +import android.text.style.StyleSpan +import androidx.core.text.buildSpannedString +import androidx.core.text.inSpans +import coil.ImageLoader +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.push.impl.R +import io.element.android.libraries.push.impl.notifications.factories.NotificationCreator +import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent +import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent +import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent +import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent +import io.element.android.services.toolbox.api.strings.StringProvider +import javax.inject.Inject + +interface NotificationDataFactory { + suspend fun toNotifications( + messages: List, + currentUser: MatrixUser, + imageLoader: ImageLoader, + ): List + + @JvmName("toNotificationInvites") + @Suppress("INAPPLICABLE_JVM_NAME") + fun toNotifications(invites: List): List + @JvmName("toNotificationSimpleEvents") + @Suppress("INAPPLICABLE_JVM_NAME") + fun toNotifications(simpleEvents: List): List + fun toNotifications(fallback: List): List + + fun createSummaryNotification( + currentUser: MatrixUser, + roomNotifications: List, + invitationNotifications: List, + simpleNotifications: List, + fallbackNotifications: List, + useCompleteNotificationFormat: Boolean + ): SummaryNotification +} + +@ContributesBinding(AppScope::class) +class DefaultNotificationDataFactory @Inject constructor( + private val notificationCreator: NotificationCreator, + private val roomGroupMessageCreator: RoomGroupMessageCreator, + private val summaryGroupMessageCreator: SummaryGroupMessageCreator, + private val activeNotificationsProvider: ActiveNotificationsProvider, + private val stringProvider: StringProvider, +) : NotificationDataFactory { + override suspend fun toNotifications( + messages: List, + currentUser: MatrixUser, + imageLoader: ImageLoader, + ): List { + val messagesToDisplay = messages.filterNot { it.canNotBeDisplayed() } + .groupBy { it.roomId } + return messagesToDisplay.map { (roomId, events) -> + val roomName = events.lastOrNull()?.roomName ?: roomId.value + val isDirect = events.lastOrNull()?.roomIsDirect ?: false + val notification = roomGroupMessageCreator.createRoomMessage( + currentUser = currentUser, + events = events, + roomId = roomId, + imageLoader = imageLoader, + existingNotification = getExistingNotificationForMessages(currentUser.userId, roomId), + ) + RoomNotification( + notification = notification, + roomId = roomId, + summaryLine = createRoomMessagesGroupSummaryLine(events, roomName, isDirect), + messageCount = events.size, + latestTimestamp = events.maxOf { it.timestamp }, + shouldBing = events.any { it.noisy } + ) + } + } + + private fun NotifiableMessageEvent.canNotBeDisplayed() = isRedacted + + private fun getExistingNotificationForMessages(sessionId: SessionId, roomId: RoomId): Notification? { + return activeNotificationsProvider.getMessageNotificationsForRoom(sessionId, roomId).firstOrNull()?.notification + } + + @JvmName("toNotificationInvites") + @Suppress("INAPPLICABLE_JVM_NAME") + override fun toNotifications(invites: List): List { + return invites.map { event -> + OneShotNotification( + key = event.roomId.value, + notification = notificationCreator.createRoomInvitationNotification(event), + summaryLine = event.description, + isNoisy = event.noisy, + timestamp = event.timestamp + ) + } + } + + @JvmName("toNotificationSimpleEvents") + @Suppress("INAPPLICABLE_JVM_NAME") + override fun toNotifications(simpleEvents: List): List { + return simpleEvents.map { event -> + OneShotNotification( + key = event.eventId.value, + notification = notificationCreator.createSimpleEventNotification(event), + summaryLine = event.description, + isNoisy = event.noisy, + timestamp = event.timestamp + ) + } + } + + override fun toNotifications(fallback: List): List { + return fallback.map { event -> + OneShotNotification( + key = event.eventId.value, + notification = notificationCreator.createFallbackNotification(event), + summaryLine = event.description.orEmpty(), + isNoisy = false, + timestamp = event.timestamp + ) + } + } + + override fun createSummaryNotification( + currentUser: MatrixUser, + roomNotifications: List, + invitationNotifications: List, + simpleNotifications: List, + fallbackNotifications: List, + useCompleteNotificationFormat: Boolean + ): SummaryNotification { + return when { + roomNotifications.isEmpty() && invitationNotifications.isEmpty() && simpleNotifications.isEmpty() -> SummaryNotification.Removed + else -> SummaryNotification.Update( + summaryGroupMessageCreator.createSummaryNotification( + currentUser = currentUser, + roomNotifications = roomNotifications, + invitationNotifications = invitationNotifications, + simpleNotifications = simpleNotifications, + fallbackNotifications = fallbackNotifications, + useCompleteNotificationFormat = useCompleteNotificationFormat + ) + ) + } + } + + private fun createRoomMessagesGroupSummaryLine(events: List, roomName: String, roomIsDirect: Boolean): CharSequence { + return when (events.size) { + 1 -> createFirstMessageSummaryLine(events.first(), roomName, roomIsDirect) + else -> { + stringProvider.getQuantityString( + R.plurals.notification_compat_summary_line_for_room, + events.size, + roomName, + events.size + ) + } + } + } + + private fun createFirstMessageSummaryLine(event: NotifiableMessageEvent, roomName: String, roomIsDirect: Boolean): CharSequence { + return if (roomIsDirect) { + buildSpannedString { + event.senderDisambiguatedDisplayName?.let { + inSpans(StyleSpan(Typeface.BOLD)) { + append(it) + append(": ") + } + } + append(event.description) + } + } else { + buildSpannedString { + inSpans(StyleSpan(Typeface.BOLD)) { + append(roomName) + append(": ") + event.senderDisambiguatedDisplayName?.let { + append(it) + append(" ") + } + } + append(event.description) + } + } + } +} + +data class RoomNotification( + val notification: Notification, + val roomId: RoomId, + val summaryLine: CharSequence, + val messageCount: Int, + val latestTimestamp: Long, + val shouldBing: Boolean, +) { + fun isDataEqualTo(other: RoomNotification): Boolean { + return notification == other.notification && + roomId == other.roomId && + summaryLine.toString() == other.summaryLine.toString() && + messageCount == other.messageCount && + latestTimestamp == other.latestTimestamp && + shouldBing == other.shouldBing + } +} + +data class OneShotNotification( + val notification: Notification, + val key: String, + val summaryLine: CharSequence, + val isNoisy: Boolean, + val timestamp: Long, +) + +sealed interface SummaryNotification { + data object Removed : SummaryNotification + data class Update(val notification: Notification) : SummaryNotification +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDisplayer.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDisplayer.kt index 04202bbb2f..96b6cac8d3 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDisplayer.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDisplayer.kt @@ -22,16 +22,25 @@ import android.content.Context import android.content.pm.PackageManager import androidx.core.app.ActivityCompat import androidx.core.app.NotificationManagerCompat +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.ApplicationContext import timber.log.Timber import javax.inject.Inject -class NotificationDisplayer @Inject constructor( - @ApplicationContext private val context: Context, -) { - private val notificationManager = NotificationManagerCompat.from(context) +interface NotificationDisplayer { + fun showNotificationMessage(tag: String?, id: Int, notification: Notification): Boolean + fun cancelNotificationMessage(tag: String?, id: Int) + fun displayDiagnosticNotification(notification: Notification): Boolean + fun dismissDiagnosticNotification() +} - fun showNotificationMessage(tag: String?, id: Int, notification: Notification): Boolean { +@ContributesBinding(AppScope::class) +class DefaultNotificationDisplayer @Inject constructor( + @ApplicationContext private val context: Context, + private val notificationManager: NotificationManagerCompat +) : NotificationDisplayer { + override fun showNotificationMessage(tag: String?, id: Int, notification: Notification): Boolean { if (ActivityCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { Timber.w("Not allowed to notify.") return false @@ -40,20 +49,11 @@ class NotificationDisplayer @Inject constructor( return true } - fun cancelNotificationMessage(tag: String?, id: Int) { + override fun cancelNotificationMessage(tag: String?, id: Int) { notificationManager.cancel(tag, id) } - fun cancelAllNotifications() { - // Keep this try catch (reported by GA) - try { - notificationManager.cancelAll() - } catch (e: Exception) { - Timber.e(e, "## cancelAllNotifications() failed") - } - } - - fun displayDiagnosticNotification(notification: Notification): Boolean { + override fun displayDiagnosticNotification(notification: Notification): Boolean { return showNotificationMessage( tag = "DIAGNOSTIC", id = NOTIFICATION_ID_DIAGNOSTIC, @@ -61,33 +61,17 @@ class NotificationDisplayer @Inject constructor( ) } - fun dismissDiagnosticNotification() { + override fun dismissDiagnosticNotification() { cancelNotificationMessage( tag = "DIAGNOSTIC", id = NOTIFICATION_ID_DIAGNOSTIC ) } - /** - * Cancel the foreground notification service. - */ - fun cancelNotificationForegroundService() { - notificationManager.cancel(NOTIFICATION_ID_FOREGROUND_SERVICE) - } - companion object { /* ========================================================================================== * IDs for notifications * ========================================================================================== */ - - /** - * Identifier of the foreground notification used to keep the application alive - * when it runs in background. - * This notification, which is not removable by the end user, displays what - * the application is doing while in background. - */ - private const val NOTIFICATION_ID_FOREGROUND_SERVICE = 61 - private const val NOTIFICATION_ID_DIAGNOSTIC = 888 } } diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationEventQueue.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationEventQueue.kt deleted file mode 100644 index c78244356e..0000000000 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationEventQueue.kt +++ /dev/null @@ -1,186 +0,0 @@ -/* - * Copyright (c) 2021 New Vector Ltd - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package io.element.android.libraries.push.impl.notifications - -import io.element.android.libraries.core.cache.CircularCache -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.push.impl.notifications.model.FallbackNotifiableEvent -import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent -import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent -import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent -import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent -import timber.log.Timber - -data class NotificationEventQueue( - private val queue: MutableList, - /** - * An in memory FIFO cache of the seen events. - * Acts as a notification debouncer to stop already dismissed push notifications from - * displaying again when the /sync response is delayed. - * TODO Should be per session, so the key must be Pair. - */ - private val seenEventIds: CircularCache -) { - fun markRedacted(eventIds: List) { - eventIds.forEach { redactedId -> - queue.replace(redactedId) { - when (it) { - is InviteNotifiableEvent -> it.copy(isRedacted = true) - is NotifiableMessageEvent -> it.copy(isRedacted = true) - is SimpleNotifiableEvent -> it.copy(isRedacted = true) - is FallbackNotifiableEvent -> it.copy(isRedacted = true) - } - } - } - } - - // TODO EAx call this - fun syncRoomEvents(roomsLeft: Collection, roomsJoined: Collection) { - if (roomsLeft.isNotEmpty() || roomsJoined.isNotEmpty()) { - queue.removeAll { - when (it) { - is NotifiableMessageEvent -> roomsLeft.contains(it.roomId) - is InviteNotifiableEvent -> roomsLeft.contains(it.roomId) || roomsJoined.contains(it.roomId) - is SimpleNotifiableEvent -> false - is FallbackNotifiableEvent -> roomsLeft.contains(it.roomId) - } - } - } - } - - fun isEmpty() = queue.isEmpty() - - fun clearAndAdd(events: List) { - queue.clear() - queue.addAll(events) - } - - fun clear() { - queue.clear() - } - - fun add(notifiableEvent: NotifiableEvent) { - val existing = findExistingById(notifiableEvent) - val edited = findEdited(notifiableEvent) - when { - existing != null -> { - if (existing.canBeReplaced) { - // Use the event coming from the event stream as it may contains more info than - // the fcm one (like type/content/clear text) (e.g when an encrypted message from - // FCM should be update with clear text after a sync) - // In this case the message has already been notified, and might have done some noise - // So we want the notification to be updated even if it has already been displayed - // Use setOnlyAlertOnce to ensure update notification does not interfere with sound - // from first notify invocation as outlined in: - // https://developer.android.com/training/notify-user/build-notification#Updating - replace(replace = existing, with = notifiableEvent) - } else { - // keep the existing one, do not replace - } - } - edited != null -> { - // Replace the existing notification with the new content - replace(replace = edited, with = notifiableEvent) - } - seenEventIds.contains(notifiableEvent.eventId) -> { - // we've already seen the event, lets skip - Timber.d("onNotifiableEventReceived(): skipping event, already seen") - } - else -> { - seenEventIds.put(notifiableEvent.eventId) - queue.add(notifiableEvent) - } - } - } - - private fun findExistingById(notifiableEvent: NotifiableEvent): NotifiableEvent? { - return queue.firstOrNull { it.sessionId == notifiableEvent.sessionId && it.eventId == notifiableEvent.eventId } - } - - private fun findEdited(notifiableEvent: NotifiableEvent): NotifiableEvent? { - return notifiableEvent.editedEventId?.let { editedId -> - queue.firstOrNull { - it.eventId == editedId || it.editedEventId == editedId - } - } - } - - private fun replace(replace: NotifiableEvent, with: NotifiableEvent) { - queue.remove(replace) - queue.add( - when (with) { - is InviteNotifiableEvent -> with.copy(isUpdated = true) - is NotifiableMessageEvent -> with.copy(isUpdated = true) - is SimpleNotifiableEvent -> with.copy(isUpdated = true) - is FallbackNotifiableEvent -> with.copy(isUpdated = true) - } - ) - } - - fun clearEvent(sessionId: SessionId, eventId: EventId) { - val isFallback = queue.firstOrNull { it.sessionId == sessionId && it.eventId == eventId } is FallbackNotifiableEvent - if (isFallback) { - Timber.d("Removing all the fallbacks") - queue.removeAll { it.sessionId == sessionId && it is FallbackNotifiableEvent } - } else { - queue.removeAll { it.sessionId == sessionId && it.eventId == eventId } - } - } - - fun clearMembershipNotificationForSession(sessionId: SessionId) { - Timber.d("clearMemberShipOfSession $sessionId") - queue.removeAll { it is InviteNotifiableEvent && it.sessionId == sessionId } - } - - fun clearMembershipNotificationForRoom(sessionId: SessionId, roomId: RoomId) { - Timber.d("clearMemberShipOfRoom $sessionId, $roomId") - queue.removeAll { it is InviteNotifiableEvent && it.sessionId == sessionId && it.roomId == roomId } - } - - fun clearMessagesForSession(sessionId: SessionId) { - Timber.d("clearMessagesForSession $sessionId") - queue.removeAll { it is NotifiableMessageEvent && it.sessionId == sessionId } - } - - fun clearAllForSession(sessionId: SessionId) { - Timber.d("clearAllForSession $sessionId") - queue.removeAll { it.sessionId == sessionId } - } - - fun clearMessagesForRoom(sessionId: SessionId, roomId: RoomId) { - Timber.d("clearMessageEventOfRoom $sessionId, $roomId") - queue.removeAll { it is NotifiableMessageEvent && it.sessionId == sessionId && it.roomId == roomId } - } - - fun clearMessagesForThread(sessionId: SessionId, roomId: RoomId, threadId: ThreadId) { - Timber.d("clearMessageEventOfThread $sessionId, $roomId, $threadId") - queue.removeAll { it is NotifiableMessageEvent && it.sessionId == sessionId && it.roomId == roomId && it.threadId == threadId } - } - - fun rawEvents(): List = queue -} - -private fun MutableList.replace(eventId: EventId, block: (NotifiableEvent) -> NotifiableEvent) { - val indexToReplace = indexOfFirst { it.eventId == eventId } - if (indexToReplace == -1) { - return - } - set(indexToReplace, block(get(indexToReplace))) -} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationFactory.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationFactory.kt deleted file mode 100644 index ef3623f302..0000000000 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationFactory.kt +++ /dev/null @@ -1,172 +0,0 @@ -/* - * Copyright (c) 2021 New Vector Ltd - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package io.element.android.libraries.push.impl.notifications - -import android.app.Notification -import coil.ImageLoader -import io.element.android.libraries.matrix.api.core.RoomId -import io.element.android.libraries.matrix.api.user.MatrixUser -import io.element.android.libraries.push.impl.notifications.factories.NotificationCreator -import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent -import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent -import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent -import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent -import javax.inject.Inject - -private typealias ProcessedMessageEvents = List> - -class NotificationFactory @Inject constructor( - private val notificationCreator: NotificationCreator, - private val roomGroupMessageCreator: RoomGroupMessageCreator, - private val summaryGroupMessageCreator: SummaryGroupMessageCreator -) { - suspend fun Map.toNotifications( - currentUser: MatrixUser, - imageLoader: ImageLoader, - ): List { - return map { (roomId, events) -> - when { - events.hasNoEventsToDisplay() -> RoomNotification.Removed(roomId) - else -> { - val messageEvents = events.onlyKeptEvents().filterNot { it.isRedacted } - roomGroupMessageCreator.createRoomMessage( - currentUser = currentUser, - events = messageEvents, - roomId = roomId, - imageLoader = imageLoader, - ) - } - } - } - } - - private fun ProcessedMessageEvents.hasNoEventsToDisplay() = isEmpty() || all { - it.type == ProcessedEvent.Type.REMOVE || it.event.canNotBeDisplayed() - } - - private fun NotifiableMessageEvent.canNotBeDisplayed() = isRedacted - - @JvmName("toNotificationsInviteNotifiableEvent") - fun List>.toNotifications(): List { - return map { (processed, event) -> - when (processed) { - ProcessedEvent.Type.REMOVE -> OneShotNotification.Removed(key = event.roomId.value) - ProcessedEvent.Type.KEEP -> OneShotNotification.Append( - notificationCreator.createRoomInvitationNotification(event), - OneShotNotification.Append.Meta( - key = event.roomId.value, - summaryLine = event.description, - isNoisy = event.noisy, - timestamp = event.timestamp - ) - ) - } - } - } - - @JvmName("toNotificationsSimpleNotifiableEvent") - fun List>.toNotifications(): List { - return map { (processed, event) -> - when (processed) { - ProcessedEvent.Type.REMOVE -> OneShotNotification.Removed(key = event.eventId.value) - ProcessedEvent.Type.KEEP -> OneShotNotification.Append( - notificationCreator.createSimpleEventNotification(event), - OneShotNotification.Append.Meta( - key = event.eventId.value, - summaryLine = event.description, - isNoisy = event.noisy, - timestamp = event.timestamp - ) - ) - } - } - } - - fun List>.toNotifications(): List { - return map { (processed, event) -> - when (processed) { - ProcessedEvent.Type.REMOVE -> OneShotNotification.Removed(key = event.eventId.value) - ProcessedEvent.Type.KEEP -> OneShotNotification.Append( - notificationCreator.createFallbackNotification(event), - OneShotNotification.Append.Meta( - key = event.eventId.value, - summaryLine = event.description.orEmpty(), - isNoisy = false, - timestamp = event.timestamp - ) - ) - } - } - } - - fun createSummaryNotification( - currentUser: MatrixUser, - roomNotifications: List, - invitationNotifications: List, - simpleNotifications: List, - fallbackNotifications: List, - useCompleteNotificationFormat: Boolean - ): SummaryNotification { - val roomMeta = roomNotifications.filterIsInstance().map { it.meta } - val invitationMeta = invitationNotifications.filterIsInstance().map { it.meta } - val simpleMeta = simpleNotifications.filterIsInstance().map { it.meta } - val fallbackMeta = fallbackNotifications.filterIsInstance().map { it.meta } - return when { - roomMeta.isEmpty() && invitationMeta.isEmpty() && simpleMeta.isEmpty() -> SummaryNotification.Removed - else -> SummaryNotification.Update( - summaryGroupMessageCreator.createSummaryNotification( - currentUser = currentUser, - roomNotifications = roomMeta, - invitationNotifications = invitationMeta, - simpleNotifications = simpleMeta, - fallbackNotifications = fallbackMeta, - useCompleteNotificationFormat = useCompleteNotificationFormat - ) - ) - } - } -} - -sealed interface RoomNotification { - data class Removed(val roomId: RoomId) : RoomNotification - data class Message(val notification: Notification, val meta: Meta) : RoomNotification { - data class Meta( - val roomId: RoomId, - val summaryLine: CharSequence, - val messageCount: Int, - val latestTimestamp: Long, - val shouldBing: Boolean - ) - } -} - -sealed interface OneShotNotification { - data class Removed(val key: String) : OneShotNotification - data class Append(val notification: Notification, val meta: Meta) : OneShotNotification { - data class Meta( - val key: String, - val summaryLine: CharSequence, - val isNoisy: Boolean, - val timestamp: Long, - ) - } -} - -sealed interface SummaryNotification { - data object Removed : SummaryNotification - data class Update(val notification: Notification) : SummaryNotification -} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationRenderer.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationRenderer.kt index 2c826abf10..b9f8edea05 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationRenderer.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationRenderer.kt @@ -18,7 +18,6 @@ package io.element.android.libraries.push.impl.notifications import coil.ImageLoader import io.element.android.libraries.core.log.logger.LoggerTag -import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent @@ -33,20 +32,20 @@ private val loggerTag = LoggerTag("NotificationRenderer", LoggerTag.Notification class NotificationRenderer @Inject constructor( private val notificationIdProvider: NotificationIdProvider, private val notificationDisplayer: NotificationDisplayer, - private val notificationFactory: NotificationFactory, + private val notificationDataFactory: NotificationDataFactory, ) { suspend fun render( currentUser: MatrixUser, useCompleteNotificationFormat: Boolean, - eventsToProcess: List>, + eventsToProcess: List, imageLoader: ImageLoader, ) { val groupedEvents = eventsToProcess.groupByType() - with(notificationFactory) { - val roomNotifications = groupedEvents.roomEvents.toNotifications(currentUser, imageLoader) - val invitationNotifications = groupedEvents.invitationEvents.toNotifications() - val simpleNotifications = groupedEvents.simpleEvents.toNotifications() - val fallbackNotifications = groupedEvents.fallbackEvents.toNotifications() + with(notificationDataFactory) { + val roomNotifications = toNotifications(groupedEvents.roomEvents, currentUser, imageLoader) + val invitationNotifications = toNotifications(groupedEvents.invitationEvents) + val simpleNotifications = toNotifications(groupedEvents.simpleEvents) + val fallbackNotifications = toNotifications(groupedEvents.fallbackEvents) val summaryNotification = createSummaryNotification( currentUser = currentUser, roomNotifications = roomNotifications, @@ -65,101 +64,43 @@ class NotificationRenderer @Inject constructor( ) } - roomNotifications.forEach { wrapper -> - when (wrapper) { - is RoomNotification.Removed -> { - Timber.tag(loggerTag.value).d("Removing room messages notification ${wrapper.roomId}") - notificationDisplayer.cancelNotificationMessage( - tag = wrapper.roomId.value, - id = notificationIdProvider.getRoomMessagesNotificationId(currentUser.userId) - ) - } - is RoomNotification.Message -> if (useCompleteNotificationFormat) { - Timber.tag(loggerTag.value).d("Updating room messages notification ${wrapper.meta.roomId}") - notificationDisplayer.showNotificationMessage( - tag = wrapper.meta.roomId.value, - id = notificationIdProvider.getRoomMessagesNotificationId(currentUser.userId), - notification = wrapper.notification - ) - } - } - } - - invitationNotifications.forEach { wrapper -> - when (wrapper) { - is OneShotNotification.Removed -> { - Timber.tag(loggerTag.value).d("Removing invitation notification ${wrapper.key}") - notificationDisplayer.cancelNotificationMessage( - tag = wrapper.key, - id = notificationIdProvider.getRoomInvitationNotificationId(currentUser.userId) - ) - } - is OneShotNotification.Append -> if (useCompleteNotificationFormat) { - Timber.tag(loggerTag.value).d("Updating invitation notification ${wrapper.meta.key}") - notificationDisplayer.showNotificationMessage( - tag = wrapper.meta.key, - id = notificationIdProvider.getRoomInvitationNotificationId(currentUser.userId), - notification = wrapper.notification - ) - } - } - } - - simpleNotifications.forEach { wrapper -> - when (wrapper) { - is OneShotNotification.Removed -> { - Timber.tag(loggerTag.value).d("Removing simple notification ${wrapper.key}") - notificationDisplayer.cancelNotificationMessage( - tag = wrapper.key, - id = notificationIdProvider.getRoomEventNotificationId(currentUser.userId) - ) - } - is OneShotNotification.Append -> if (useCompleteNotificationFormat) { - Timber.tag(loggerTag.value).d("Updating simple notification ${wrapper.meta.key}") - notificationDisplayer.showNotificationMessage( - tag = wrapper.meta.key, - id = notificationIdProvider.getRoomEventNotificationId(currentUser.userId), - notification = wrapper.notification - ) - } - } - } - - /* - fallbackNotifications.forEach { wrapper -> - when (wrapper) { - is OneShotNotification.Removed -> { - Timber.tag(loggerTag.value).d("Removing fallback notification ${wrapper.key}") - notificationDisplayer.cancelNotificationMessage( - tag = wrapper.key, - id = notificationIdProvider.getFallbackNotificationId(currentUser.userId) - ) - } - is OneShotNotification.Append -> if (useCompleteNotificationFormat) { - Timber.tag(loggerTag.value).d("Updating fallback notification ${wrapper.meta.key}") - notificationDisplayer.showNotificationMessage( - tag = wrapper.meta.key, - id = notificationIdProvider.getFallbackNotificationId(currentUser.userId), - notification = wrapper.notification - ) - } - } - } - */ - val removedFallback = fallbackNotifications.filterIsInstance() - val appendFallback = fallbackNotifications.filterIsInstance() - if (appendFallback.isEmpty() && removedFallback.isNotEmpty()) { - Timber.tag(loggerTag.value).d("Removing global fallback notification") - notificationDisplayer.cancelNotificationMessage( - tag = "FALLBACK", - id = notificationIdProvider.getFallbackNotificationId(currentUser.userId) + roomNotifications.forEach { notificationData -> + notificationDisplayer.showNotificationMessage( + tag = notificationData.roomId.value, + id = notificationIdProvider.getRoomMessagesNotificationId(currentUser.userId), + notification = notificationData.notification ) - } else if (appendFallback.isNotEmpty()) { + } + + invitationNotifications.forEach { notificationData -> + if (useCompleteNotificationFormat) { + Timber.tag(loggerTag.value).d("Updating invitation notification ${notificationData.key}") + notificationDisplayer.showNotificationMessage( + tag = notificationData.key, + id = notificationIdProvider.getRoomInvitationNotificationId(currentUser.userId), + notification = notificationData.notification + ) + } + } + + simpleNotifications.forEach { notificationData -> + if (useCompleteNotificationFormat) { + Timber.tag(loggerTag.value).d("Updating simple notification ${notificationData.key}") + notificationDisplayer.showNotificationMessage( + tag = notificationData.key, + id = notificationIdProvider.getRoomEventNotificationId(currentUser.userId), + notification = notificationData.notification + ) + } + } + + // Show only the first fallback notification + if (fallbackNotifications.isNotEmpty()) { Timber.tag(loggerTag.value).d("Showing fallback notification") notificationDisplayer.showNotificationMessage( tag = "FALLBACK", id = notificationIdProvider.getFallbackNotificationId(currentUser.userId), - notification = appendFallback.first().notification + notification = fallbackNotifications.first().notification ) } @@ -174,39 +115,30 @@ class NotificationRenderer @Inject constructor( } } } - - fun cancelAllNotifications() { - notificationDisplayer.cancelAllNotifications() - } } -private fun List>.groupByType(): GroupedNotificationEvents { - val roomIdToEventMap: MutableMap>> = LinkedHashMap() - val simpleEvents: MutableList> = ArrayList() - val invitationEvents: MutableList> = ArrayList() - val fallbackEvents: MutableList> = ArrayList() - forEach { - when (val event = it.event) { - is InviteNotifiableEvent -> invitationEvents.add(it.castedToEventType()) - is NotifiableMessageEvent -> { - val roomEvents = roomIdToEventMap.getOrPut(event.roomId) { ArrayList() } - roomEvents.add(it.castedToEventType()) - } - is SimpleNotifiableEvent -> simpleEvents.add(it.castedToEventType()) - is FallbackNotifiableEvent -> { - fallbackEvents.add(it.castedToEventType()) - } +private fun List.groupByType(): GroupedNotificationEvents { + val roomEvents: MutableList = mutableListOf() + val simpleEvents: MutableList = mutableListOf() + val invitationEvents: MutableList = mutableListOf() + val fallbackEvents: MutableList = mutableListOf() + forEach { event -> + when (event) { + is InviteNotifiableEvent -> invitationEvents.add(event.castedToEventType()) + is NotifiableMessageEvent -> roomEvents.add(event.castedToEventType()) + is SimpleNotifiableEvent -> simpleEvents.add(event.castedToEventType()) + is FallbackNotifiableEvent -> fallbackEvents.add(event.castedToEventType()) } } - return GroupedNotificationEvents(roomIdToEventMap, simpleEvents, invitationEvents, fallbackEvents) + return GroupedNotificationEvents(roomEvents, simpleEvents, invitationEvents, fallbackEvents) } @Suppress("UNCHECKED_CAST") -private fun ProcessedEvent.castedToEventType(): ProcessedEvent = this as ProcessedEvent +private fun NotifiableEvent.castedToEventType(): T = this as T data class GroupedNotificationEvents( - val roomEvents: Map>>, - val simpleEvents: List>, - val invitationEvents: List>, - val fallbackEvents: List>, + val roomEvents: List, + val simpleEvents: List, + val invitationEvents: List, + val fallbackEvents: List, ) diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationState.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationState.kt deleted file mode 100644 index fb19bd76fd..0000000000 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationState.kt +++ /dev/null @@ -1,59 +0,0 @@ -/* - * Copyright (c) 2021 New Vector Ltd - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package io.element.android.libraries.push.impl.notifications - -import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent - -class NotificationState( - /** - * The notifiable events queued for rendering or currently rendered. - * - * This is our source of truth for notifications, any changes to this list will be rendered as notifications. - * When events are removed the previously rendered notifications will be cancelled. - * When adding or updating, the notifications will be notified. - * - * Events are unique by their properties, we should be careful not to insert multiple events with the same event-id. - */ - private val queuedEvents: NotificationEventQueue, - /** - * The last known rendered notifiable events. - * We keep track of them in order to know which events have been removed from the eventList - * allowing us to cancel any notifications previous displayed by now removed events - */ - private val renderedEvents: MutableList>, -) { - fun updateQueuedEvents( - action: (NotificationEventQueue, List>) -> T - ): T { - return synchronized(queuedEvents) { - action(queuedEvents, renderedEvents) - } - } - - fun clearAndAddRenderedEvents(eventsToRender: List>) { - renderedEvents.clear() - renderedEvents.addAll(eventsToRender) - } - - fun hasAlreadyRendered(eventsToRender: List>) = renderedEvents == eventsToRender - - fun queuedEvents(block: (NotificationEventQueue) -> Unit) { - synchronized(queuedEvents) { - block(queuedEvents) - } - } -} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/OutdatedEventDetector.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/OutdatedEventDetector.kt deleted file mode 100644 index 52e61a7ec6..0000000000 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/OutdatedEventDetector.kt +++ /dev/null @@ -1,44 +0,0 @@ -/* - * Copyright (c) 2023 New Vector Ltd - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package io.element.android.libraries.push.impl.notifications - -import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent -import javax.inject.Inject - -class OutdatedEventDetector @Inject constructor( - // / private val activeSessionDataSource: ActiveSessionDataSource -) { - /** - * Returns true if the given event is outdated. - * Used to clean up notifications if a displayed message has been read on an - * other device. - */ - fun isMessageOutdated(@Suppress("UNUSED_PARAMETER") notifiableEvent: NotifiableEvent): Boolean { - /* TODO EAx - val session = activeSessionDataSource.currentValue?.orNull() ?: return false - - if (notifiableEvent is NotifiableMessageEvent) { - val eventID = notifiableEvent.eventId - val roomID = notifiableEvent.roomId - val room = session.getRoom(roomID) ?: return false - return room.readService().isEventRead(eventID) - } - - */ - return false - } -} 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 65930f7652..3285a7ae90 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 @@ -16,48 +16,46 @@ package io.element.android.libraries.push.impl.notifications +import android.app.Notification import android.graphics.Bitmap -import android.graphics.Typeface -import android.text.style.StyleSpan -import androidx.core.app.NotificationCompat -import androidx.core.app.Person -import androidx.core.text.buildSpannedString -import androidx.core.text.inSpans import coil.ImageLoader +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.di.AppScope import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.push.impl.R -import io.element.android.libraries.push.impl.notifications.debug.annotateForDebug import io.element.android.libraries.push.impl.notifications.factories.NotificationCreator +import io.element.android.libraries.push.impl.notifications.factories.isSmartReplyError import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent import io.element.android.services.toolbox.api.strings.StringProvider import javax.inject.Inject -class RoomGroupMessageCreator @Inject constructor( - private val bitmapLoader: NotificationBitmapLoader, - private val stringProvider: StringProvider, - private val notificationCreator: NotificationCreator -) { +interface RoomGroupMessageCreator { suspend fun createRoomMessage( currentUser: MatrixUser, events: List, roomId: RoomId, imageLoader: ImageLoader, - ): RoomNotification.Message { + existingNotification: Notification?, + ): Notification +} + +@ContributesBinding(AppScope::class) +class DefaultRoomGroupMessageCreator @Inject constructor( + private val bitmapLoader: NotificationBitmapLoader, + private val stringProvider: StringProvider, + private val notificationCreator: NotificationCreator, +) : RoomGroupMessageCreator { + override suspend fun createRoomMessage( + currentUser: MatrixUser, + events: List, + roomId: RoomId, + imageLoader: ImageLoader, + existingNotification: Notification?, + ): Notification { val lastKnownRoomEvent = events.last() val roomName = lastKnownRoomEvent.roomName ?: lastKnownRoomEvent.senderDisambiguatedDisplayName ?: "Room name (${roomId.value.take(8)}…)" val roomIsGroup = !lastKnownRoomEvent.roomIsDirect - val style = NotificationCompat.MessagingStyle( - Person.Builder() - .setName(currentUser.displayName?.annotateForDebug(50)) - .setIcon(bitmapLoader.getUserIcon(currentUser.avatarUrl, imageLoader)) - .setKey(lastKnownRoomEvent.sessionId.value) - .build() - ).also { - it.conversationTitle = roomName.takeIf { roomIsGroup }?.annotateForDebug(51) - it.isGroupConversation = roomIsGroup - it.addMessagesFromEvents(events, imageLoader) - } val tickerText = if (roomIsGroup) { stringProvider.getString(R.string.notification_ticker_text_group, roomName, events.last().senderDisambiguatedDisplayName, events.last().description) @@ -69,112 +67,28 @@ class RoomGroupMessageCreator @Inject constructor( val lastMessageTimestamp = events.last().timestamp val smartReplyErrors = events.filter { it.isSmartReplyError() } - val messageCount = events.size - smartReplyErrors.size - val meta = RoomNotification.Message.Meta( - summaryLine = createRoomMessagesGroupSummaryLine(events, roomName, roomIsDirect = !roomIsGroup), - messageCount = messageCount, - latestTimestamp = lastMessageTimestamp, - roomId = roomId, - shouldBing = events.any { it.noisy } - ) - return RoomNotification.Message( - notificationCreator.createMessagesListNotification( - style, + return notificationCreator.createMessagesListNotification( RoomEventGroupInfo( sessionId = currentUser.userId, roomId = roomId, roomDisplayName = roomName, isDirect = !roomIsGroup, hasSmartReplyError = smartReplyErrors.isNotEmpty(), - shouldBing = meta.shouldBing, + shouldBing = events.any { it.noisy }, customSound = events.last().soundName, isUpdated = events.last().isUpdated, ), threadId = lastKnownRoomEvent.threadId, largeIcon = largeBitmap, - lastMessageTimestamp, - tickerText - ), - meta + lastMessageTimestamp = lastMessageTimestamp, + tickerText = tickerText, + currentUser = currentUser, + existingNotification = existingNotification, + imageLoader = imageLoader, + events = events, ) } - private suspend fun NotificationCompat.MessagingStyle.addMessagesFromEvents( - events: List, - imageLoader: ImageLoader, - ) { - events.forEach { event -> - val senderPerson = if (event.outGoingMessage) { - null - } else { - Person.Builder() - .setName(event.senderDisambiguatedDisplayName?.annotateForDebug(70)) - .setIcon(bitmapLoader.getUserIcon(event.senderAvatarPath, imageLoader)) - .setKey(event.senderId.value) - .build() - } - when { - event.isSmartReplyError() -> addMessage( - stringProvider.getString(R.string.notification_inline_reply_failed), - event.timestamp, - senderPerson - ) - else -> { - val message = NotificationCompat.MessagingStyle.Message( - event.body?.annotateForDebug(71), - event.timestamp, - senderPerson - ).also { message -> - event.imageUri?.let { - message.setData("image/", it) - } - } - addMessage(message) - } - } - } - } - - private fun createRoomMessagesGroupSummaryLine(events: List, roomName: String, roomIsDirect: Boolean): CharSequence { - return when (events.size) { - 1 -> createFirstMessageSummaryLine(events.first(), roomName, roomIsDirect) - else -> { - stringProvider.getQuantityString( - R.plurals.notification_compat_summary_line_for_room, - events.size, - roomName, - events.size - ) - } - } - } - - private fun createFirstMessageSummaryLine(event: NotifiableMessageEvent, roomName: String, roomIsDirect: Boolean): CharSequence { - return if (roomIsDirect) { - buildSpannedString { - event.senderDisambiguatedDisplayName?.let { - inSpans(StyleSpan(Typeface.BOLD)) { - append(it) - append(": ") - } - } - append(event.description) - } - } else { - buildSpannedString { - inSpans(StyleSpan(Typeface.BOLD)) { - append(roomName) - append(": ") - event.senderDisambiguatedDisplayName?.let { - append(it) - append(" ") - } - } - append(event.description) - } - } - } - private suspend fun getRoomBitmap( events: List, imageLoader: ImageLoader, @@ -184,5 +98,3 @@ class RoomGroupMessageCreator @Inject constructor( ?.let { bitmapLoader.getRoomBitmap(it, imageLoader) } } } - -private fun NotifiableMessageEvent.isSmartReplyError() = outGoingMessage && outGoingMessageFailed diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/SummaryGroupMessageCreator.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/SummaryGroupMessageCreator.kt index 18aadb5de9..19cc4593e6 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/SummaryGroupMessageCreator.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/SummaryGroupMessageCreator.kt @@ -18,6 +18,8 @@ package io.element.android.libraries.push.impl.notifications import android.app.Notification import androidx.core.app.NotificationCompat +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.di.AppScope import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.push.impl.R import io.element.android.libraries.push.impl.notifications.debug.annotateForDebug @@ -25,30 +27,37 @@ import io.element.android.libraries.push.impl.notifications.factories.Notificati import io.element.android.services.toolbox.api.strings.StringProvider import javax.inject.Inject +interface SummaryGroupMessageCreator { + fun createSummaryNotification( + currentUser: MatrixUser, + roomNotifications: List, + invitationNotifications: List, + simpleNotifications: List, + fallbackNotifications: List, + useCompleteNotificationFormat: Boolean + ): Notification +} + /** * ======== Build summary notification ========= * On Android 7.0 (API level 24) and higher, the system automatically builds a summary for * your group using snippets of text from each notification. The user can expand this * notification to see each separate notification. - * To support older versions, which cannot show a nested group of notifications, - * you must create an extra notification that acts as the summary. - * This appears as the only notification and the system hides all the others. - * So this summary should include a snippet from all the other notifications, - * which the user can tap to open your app. * The behavior of the group summary may vary on some device types such as wearables. * To ensure the best experience on all devices and versions, always include a group summary when you create a group * https://developer.android.com/training/notify-user/group */ -class SummaryGroupMessageCreator @Inject constructor( +@ContributesBinding(AppScope::class) +class DefaultSummaryGroupMessageCreator @Inject constructor( private val stringProvider: StringProvider, private val notificationCreator: NotificationCreator, -) { - fun createSummaryNotification( +) : SummaryGroupMessageCreator { + override fun createSummaryNotification( currentUser: MatrixUser, - roomNotifications: List, - invitationNotifications: List, - simpleNotifications: List, - fallbackNotifications: List, + roomNotifications: List, + invitationNotifications: List, + simpleNotifications: List, + fallbackNotifications: List, useCompleteNotificationFormat: Boolean ): Notification { val summaryInboxStyle = NotificationCompat.InboxStyle().also { style -> diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/channels/NotificationChannels.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/channels/NotificationChannels.kt index c66e530837..8c74f845b7 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/channels/NotificationChannels.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/channels/NotificationChannels.kt @@ -36,10 +36,9 @@ import javax.inject.Inject @SingleIn(AppScope::class) class NotificationChannels @Inject constructor( @ApplicationContext private val context: Context, + private val notificationManager: NotificationManagerCompat, private val stringProvider: StringProvider, ) { - private val notificationManager = NotificationManagerCompat.from(context) - init { createNotificationChannels() } diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/NotificationCreator.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/NotificationCreator.kt index 434504dd69..d1851d44a2 100755 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/NotificationCreator.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/NotificationCreator.kt @@ -22,26 +22,80 @@ import android.graphics.Bitmap import android.graphics.Canvas import androidx.annotation.DrawableRes import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationCompat.MessagingStyle +import androidx.core.app.Person import androidx.core.content.ContextCompat import androidx.core.content.res.ResourcesCompat +import coil.ImageLoader +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.appconfig.NotificationConfig import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.designsystem.utils.CommonDrawables +import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.ApplicationContext +import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.ThreadId import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.push.impl.R +import io.element.android.libraries.push.impl.notifications.NotificationBitmapLoader import io.element.android.libraries.push.impl.notifications.RoomEventGroupInfo import io.element.android.libraries.push.impl.notifications.channels.NotificationChannels import io.element.android.libraries.push.impl.notifications.debug.annotateForDebug +import io.element.android.libraries.push.impl.notifications.factories.action.AcceptInvitationActionFactory import io.element.android.libraries.push.impl.notifications.factories.action.MarkAsReadActionFactory import io.element.android.libraries.push.impl.notifications.factories.action.QuickReplyActionFactory +import io.element.android.libraries.push.impl.notifications.factories.action.RejectInvitationActionFactory import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent +import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent import io.element.android.services.toolbox.api.strings.StringProvider import javax.inject.Inject -class NotificationCreator @Inject constructor( +interface NotificationCreator { + /** + * Create a notification for a Room. + */ + suspend fun createMessagesListNotification( + roomInfo: RoomEventGroupInfo, + threadId: ThreadId?, + largeIcon: Bitmap?, + lastMessageTimestamp: Long, + tickerText: String, + currentUser: MatrixUser, + existingNotification: Notification?, + imageLoader: ImageLoader, + events: List, + ): Notification + + fun createRoomInvitationNotification( + inviteNotifiableEvent: InviteNotifiableEvent + ): Notification + + fun createSimpleEventNotification( + simpleNotifiableEvent: SimpleNotifiableEvent, + ): Notification + + fun createFallbackNotification( + fallbackNotifiableEvent: FallbackNotifiableEvent, + ): Notification + + /** + * Create the summary notification. + */ + fun createSummaryListNotification( + currentUser: MatrixUser, + style: NotificationCompat.InboxStyle?, + compatSummary: String, + noisy: Boolean, + lastMessageTimestamp: Long + ): Notification + + fun createDiagnosticNotification(): Notification +} + +@ContributesBinding(AppScope::class) +class DefaultNotificationCreator @Inject constructor( @ApplicationContext private val context: Context, private val notificationChannels: NotificationChannels, private val stringProvider: StringProvider, @@ -49,17 +103,23 @@ class NotificationCreator @Inject constructor( private val pendingIntentFactory: PendingIntentFactory, private val markAsReadActionFactory: MarkAsReadActionFactory, private val quickReplyActionFactory: QuickReplyActionFactory, -) { + private val bitmapLoader: NotificationBitmapLoader, + private val acceptInvitationActionFactory: AcceptInvitationActionFactory, + private val rejectInvitationActionFactory: RejectInvitationActionFactory +) : NotificationCreator { /** * Create a notification for a Room. */ - fun createMessagesListNotification( - messageStyle: NotificationCompat.MessagingStyle, + override suspend fun createMessagesListNotification( roomInfo: RoomEventGroupInfo, threadId: ThreadId?, largeIcon: Bitmap?, lastMessageTimestamp: Long, - tickerText: String + tickerText: String, + currentUser: MatrixUser, + existingNotification: Notification?, + imageLoader: ImageLoader, + events: List, ): Notification { val accentColor = ContextCompat.getColor(context, R.color.notification_accent_color) // Build the pending intent for when the notification is clicked @@ -71,17 +131,39 @@ class NotificationCreator @Inject constructor( val smallIcon = CommonDrawables.ic_notification_small val channelId = notificationChannels.getChannelIdForMessage(roomInfo.shouldBing) - return NotificationCompat.Builder(context, channelId) + val builder = if (existingNotification != null) { + NotificationCompat.Builder(context, existingNotification) + } else { + NotificationCompat.Builder(context, channelId) + .setOnlyAlertOnce(roomInfo.isUpdated) + // A category allows groups of notifications to be ranked and filtered – per user or system settings. + // For example, alarm notifications should display before promo notifications, or message from known contact + // that can be displayed in not disturb mode if white listed (the later will need compat28.x) + .setCategory(NotificationCompat.CATEGORY_MESSAGE) + // ID of the corresponding shortcut, for conversation features under API 30+ + .setShortcutId(roomInfo.roomId.value) + // Auto-bundling is enabled for 4 or more notifications on API 24+ (N+) + // devices and all Wear devices. But we want a custom grouping, so we specify the groupID + .setGroup(roomInfo.sessionId.value) + // In order to avoid notification making sound twice (due to the summary notification) + .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_ALL) + // Remove notification after opening it or using an action + .setAutoCancel(true) + } + + val messagingStyle = existingNotification?.let { + MessagingStyle.extractMessagingStyleFromNotification(it) + } ?: messagingStyleFromCurrentUser(roomInfo.sessionId, currentUser, imageLoader, roomInfo.roomDisplayName, !roomInfo.isDirect) + + messagingStyle.addMessagesFromEvents(events, imageLoader) + + return builder + .setNumber(events.size) .setOnlyAlertOnce(roomInfo.isUpdated) .setWhen(lastMessageTimestamp) // MESSAGING_STYLE sets title and content for API 16 and above devices. - .setStyle(messageStyle) - // A category allows groups of notifications to be ranked and filtered – per user or system settings. - // For example, alarm notifications should display before promo notifications, or message from known contact - // that can be displayed in not disturb mode if white listed (the later will need compat28.x) - .setCategory(NotificationCompat.CATEGORY_MESSAGE) - // ID of the corresponding shortcut, for conversation features under API 30+ - .setShortcutId(roomInfo.roomId.value) + .setStyle(messagingStyle) + // Not needed anymore? // Title for API < 16 devices. .setContentTitle(roomInfo.roomDisplayName.annotateForDebug(1)) // Content for API < 16 devices. @@ -90,15 +172,10 @@ class NotificationCreator @Inject constructor( .setSubText( stringProvider.getQuantityString( R.plurals.notification_new_messages_for_room, - messageStyle.messages.size, - messageStyle.messages.size + messagingStyle.messages.size, + messagingStyle.messages.size ).annotateForDebug(3) ) - // Auto-bundling is enabled for 4 or more notifications on API 24+ (N+) - // devices and all Wear devices. But we want a custom grouping, so we specify the groupID - .setGroup(roomInfo.sessionId.value) - // In order to avoid notification making sound twice (due to the summary notification) - .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_ALL) .setSmallIcon(smallIcon) // Set primary color (important for Wear 2.0 Notifications). .setColor(accentColor) @@ -118,7 +195,8 @@ class NotificationCreator @Inject constructor( } else { priority = NotificationCompat.PRIORITY_LOW } - + // Clear existing actions since we might be updating an existing notification + clearActions() // Add actions and notification intents // Mark room as read addAction(markAsReadActionFactory.create(roomInfo)) @@ -134,11 +212,11 @@ class NotificationCreator @Inject constructor( } setDeleteIntent(pendingIntentFactory.createDismissRoomPendingIntent(roomInfo.sessionId, roomInfo.roomId)) } - .setTicker(tickerText.annotateForDebug(4)) + .setTicker(tickerText) .build() } - fun createRoomInvitationNotification( + override fun createRoomInvitationNotification( inviteNotifiableEvent: InviteNotifiableEvent ): Notification { val accentColor = ContextCompat.getColor(context, R.color.notification_accent_color) @@ -152,10 +230,11 @@ class NotificationCreator @Inject constructor( .setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_ALL) .setSmallIcon(smallIcon) .setColor(accentColor) - // TODO removed for now, will be added back later -// .addAction(rejectInvitationActionFactory.create(inviteNotifiableEvent)) -// .addAction(acceptInvitationActionFactory.create(inviteNotifiableEvent)) .apply { + if (NotificationConfig.SUPPORT_JOIN_DECLINE_INVITE) { + addAction(rejectInvitationActionFactory.create(inviteNotifiableEvent)) + addAction(acceptInvitationActionFactory.create(inviteNotifiableEvent)) + } // Build the pending intent for when the notification is clicked setContentIntent(pendingIntentFactory.createOpenRoomPendingIntent(inviteNotifiableEvent.sessionId, inviteNotifiableEvent.roomId)) @@ -182,7 +261,7 @@ class NotificationCreator @Inject constructor( .build() } - fun createSimpleEventNotification( + override fun createSimpleEventNotification( simpleNotifiableEvent: SimpleNotifiableEvent, ): Notification { val accentColor = ContextCompat.getColor(context, R.color.notification_accent_color) @@ -212,12 +291,11 @@ class NotificationCreator @Inject constructor( } else { priority = NotificationCompat.PRIORITY_LOW } - setAutoCancel(true) } .build() } - fun createFallbackNotification( + override fun createFallbackNotification( fallbackNotifiableEvent: FallbackNotifiableEvent, ): Notification { val accentColor = ContextCompat.getColor(context, R.color.notification_accent_color) @@ -244,17 +322,14 @@ class NotificationCreator @Inject constructor( fallbackNotifiableEvent.eventId ) ) - .apply { - priority = NotificationCompat.PRIORITY_LOW - setAutoCancel(true) - } + .setPriority(NotificationCompat.PRIORITY_LOW) .build() } /** * Create the summary notification. */ - fun createSummaryListNotification( + override fun createSummaryListNotification( currentUser: MatrixUser, style: NotificationCompat.InboxStyle?, compatSummary: String, @@ -298,7 +373,7 @@ class NotificationCreator @Inject constructor( .build() } - fun createDiagnosticNotification(): Notification { + override fun createDiagnosticNotification(): Notification { val intent = pendingIntentFactory.createTestPendingIntent() return NotificationCompat.Builder(context, notificationChannels.getChannelIdForTest()) .setContentTitle(buildMeta.applicationName) @@ -314,6 +389,61 @@ class NotificationCreator @Inject constructor( .build() } + private suspend fun MessagingStyle.addMessagesFromEvents( + events: List, + imageLoader: ImageLoader, + ) { + events.forEach { event -> + val senderPerson = if (event.outGoingMessage) { + null + } else { + Person.Builder() + .setName(event.senderDisambiguatedDisplayName?.annotateForDebug(70)) + .setIcon(bitmapLoader.getUserIcon(event.senderAvatarPath, imageLoader)) + .setKey(event.senderId.value) + .build() + } + when { + event.isSmartReplyError() -> addMessage( + stringProvider.getString(R.string.notification_inline_reply_failed), + event.timestamp, + senderPerson + ) + else -> { + val message = MessagingStyle.Message( + event.body?.annotateForDebug(71), + event.timestamp, + senderPerson + ).also { message -> + event.imageUri?.let { + message.setData("image/", it) + } + } + addMessage(message) + } + } + } + } + + private suspend fun messagingStyleFromCurrentUser( + sessionId: SessionId, + user: MatrixUser, + imageLoader: ImageLoader, + roomName: String, + roomIsGroup: Boolean + ): MessagingStyle { + return MessagingStyle( + Person.Builder() + .setName(user.displayName?.annotateForDebug(50)) + .setIcon(bitmapLoader.getUserIcon(user.avatarUrl, imageLoader)) + .setKey(sessionId.value) + .build() + ).also { + it.conversationTitle = roomName.takeIf { roomIsGroup } + it.isGroupConversation = roomIsGroup + } + } + private fun getBitmap(@DrawableRes drawableRes: Int): Bitmap? { val drawable = ResourcesCompat.getDrawable(context.resources, drawableRes, null) ?: return null val canvas = Canvas() @@ -324,3 +454,5 @@ class NotificationCreator @Inject constructor( return bitmap } } + +fun NotifiableMessageEvent.isSmartReplyError() = outGoingMessage && outGoingMessageFailed diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/action/QuickReplyActionFactory.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/action/QuickReplyActionFactory.kt index 53af2c71d5..56fb952c24 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/action/QuickReplyActionFactory.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/factories/action/QuickReplyActionFactory.kt @@ -46,21 +46,20 @@ class QuickReplyActionFactory @Inject constructor( if (!NotificationConfig.SUPPORT_QUICK_REPLY_ACTION) return null val sessionId = roomInfo.sessionId val roomId = roomInfo.roomId - return buildQuickReplyIntent(sessionId, roomId, threadId)?.let { replyPendingIntent -> - val remoteInput = RemoteInput.Builder(NotificationBroadcastReceiver.KEY_TEXT_REPLY) - .setLabel(stringProvider.getString(R.string.notification_room_action_quick_reply)) - .build() + val replyPendingIntent = buildQuickReplyIntent(sessionId, roomId, threadId) + val remoteInput = RemoteInput.Builder(NotificationBroadcastReceiver.KEY_TEXT_REPLY) + .setLabel(stringProvider.getString(R.string.notification_room_action_quick_reply)) + .build() - NotificationCompat.Action.Builder( - R.drawable.vector_notification_quick_reply, - stringProvider.getString(R.string.notification_room_action_quick_reply), - replyPendingIntent - ) - .addRemoteInput(remoteInput) - .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY) - .setShowsUserInterface(false) - .build() - } + return NotificationCompat.Action.Builder( + R.drawable.vector_notification_quick_reply, + stringProvider.getString(R.string.notification_room_action_quick_reply), + replyPendingIntent + ) + .addRemoteInput(remoteInput) + .setSemanticAction(NotificationCompat.Action.SEMANTIC_ACTION_REPLY) + .setShowsUserInterface(false) + .build() } /* @@ -74,30 +73,26 @@ class QuickReplyActionFactory @Inject constructor( sessionId: SessionId, roomId: RoomId, threadId: ThreadId?, - ): PendingIntent? { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { - val intent = Intent(context, NotificationBroadcastReceiver::class.java) - intent.action = actionIds.smartReply - intent.data = createIgnoredUri("quickReply/$sessionId/$roomId" + threadId?.let { "/$it" }.orEmpty()) - intent.putExtra(NotificationBroadcastReceiver.KEY_SESSION_ID, sessionId.value) - intent.putExtra(NotificationBroadcastReceiver.KEY_ROOM_ID, roomId.value) - threadId?.let { - intent.putExtra(NotificationBroadcastReceiver.KEY_THREAD_ID, it.value) - } - - PendingIntent.getBroadcast( - context, - clock.epochMillis().toInt(), - intent, - // PendingIntents attached to actions with remote inputs must be mutable - PendingIntent.FLAG_UPDATE_CURRENT or if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - PendingIntent.FLAG_MUTABLE - } else { - 0 - } - ) - } else { - null + ): PendingIntent { + val intent = Intent(context, NotificationBroadcastReceiver::class.java) + intent.action = actionIds.smartReply + intent.data = createIgnoredUri("quickReply/$sessionId/$roomId" + threadId?.let { "/$it" }.orEmpty()) + intent.putExtra(NotificationBroadcastReceiver.KEY_SESSION_ID, sessionId.value) + intent.putExtra(NotificationBroadcastReceiver.KEY_ROOM_ID, roomId.value) + threadId?.let { + intent.putExtra(NotificationBroadcastReceiver.KEY_THREAD_ID, it.value) } + + return PendingIntent.getBroadcast( + context, + clock.epochMillis().toInt(), + intent, + // PendingIntents attached to actions with remote inputs must be mutable + PendingIntent.FLAG_UPDATE_CURRENT or if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + PendingIntent.FLAG_MUTABLE + } else { + 0 + } + ) } } diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandler.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandler.kt index 2c9abb77b3..1d3f349364 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandler.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandler.kt @@ -16,27 +16,19 @@ package io.element.android.libraries.push.impl.push -import android.os.Handler -import android.os.Looper import com.squareup.anvil.annotations.ContributesBinding import io.element.android.libraries.core.log.logger.LoggerTag import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.di.AppScope import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService -import io.element.android.libraries.push.impl.PushersManager -import io.element.android.libraries.push.impl.notifications.DefaultNotificationDrawerManager import io.element.android.libraries.push.impl.notifications.NotifiableEventResolver -import io.element.android.libraries.push.impl.store.DefaultPushDataStore +import io.element.android.libraries.push.impl.test.DefaultTestPush import io.element.android.libraries.push.impl.troubleshoot.DiagnosticPushHandler import io.element.android.libraries.pushproviders.api.PushData import io.element.android.libraries.pushproviders.api.PushHandler import io.element.android.libraries.pushstore.api.UserPushStoreFactory import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecret -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.first -import kotlinx.coroutines.launch import timber.log.Timber import javax.inject.Inject @@ -44,23 +36,15 @@ private val loggerTag = LoggerTag("PushHandler", LoggerTag.PushLoggerTag) @ContributesBinding(AppScope::class) class DefaultPushHandler @Inject constructor( - private val defaultNotificationDrawerManager: DefaultNotificationDrawerManager, + private val onNotifiableEventReceived: OnNotifiableEventReceived, private val notifiableEventResolver: NotifiableEventResolver, - private val defaultPushDataStore: DefaultPushDataStore, + private val incrementPushDataStore: IncrementPushDataStore, private val userPushStoreFactory: UserPushStoreFactory, private val pushClientSecret: PushClientSecret, - // private val actionIds: NotificationActionIds, private val buildMeta: BuildMeta, private val matrixAuthenticationService: MatrixAuthenticationService, private val diagnosticPushHandler: DiagnosticPushHandler, ) : PushHandler { - private val coroutineScope = CoroutineScope(SupervisorJob()) - - // UI handler - private val uiHandler by lazy { - Handler(Looper.getMainLooper()) - } - /** * Called when message is received. * @@ -68,21 +52,15 @@ class DefaultPushHandler @Inject constructor( */ override suspend fun handle(pushData: PushData) { Timber.tag(loggerTag.value).d("## handling pushData: ${pushData.roomId}/${pushData.eventId}") - if (buildMeta.lowPrivacyLoggingEnabled) { Timber.tag(loggerTag.value).d("## pushData: $pushData") } - - defaultPushDataStore.incrementPushCounter() - + incrementPushDataStore.incrementPushCounter() // Diagnostic Push - if (pushData.eventId == PushersManager.TEST_EVENT_ID) { + if (pushData.eventId == DefaultTestPush.TEST_EVENT_ID) { diagnosticPushHandler.handlePush() - return - } - - uiHandler.post { - coroutineScope.launch(Dispatchers.IO) { handleInternal(pushData) } + } else { + handleInternal(pushData) } } @@ -98,7 +76,6 @@ class DefaultPushHandler @Inject constructor( } else { Timber.tag(loggerTag.value).d("## handleInternal()") } - val clientSecret = pushData.clientSecret // clientSecret should not be null. If this happens, restore default session val userId = clientSecret @@ -109,27 +86,22 @@ class DefaultPushHandler @Inject constructor( ?: run { matrixAuthenticationService.getLatestSessionId() } - if (userId == null) { Timber.w("Unable to get a session") return } - - val notifiableEvent = notifiableEventResolver.resolveEvent(userId, pushData.roomId, pushData.eventId) - - if (notifiableEvent == null) { - Timber.w("Unable to get a notification data") - return - } - val userPushStore = userPushStoreFactory.getOrCreate(userId) - if (!userPushStore.getNotificationEnabledForDevice().first()) { + if (userPushStore.getNotificationEnabledForDevice().first()) { + val notifiableEvent = notifiableEventResolver.resolveEvent(userId, pushData.roomId, pushData.eventId) + if (notifiableEvent == null) { + Timber.w("Unable to get a notification data") + return + } + onNotifiableEventReceived.onNotifiableEventReceived(notifiableEvent) + } else { // TODO We need to check if this is an incoming call Timber.tag(loggerTag.value).i("Notification are disabled for this device, ignore push.") - return } - - defaultNotificationDrawerManager.onNotifiableEventReceived(notifiableEvent) } catch (e: Exception) { Timber.tag(loggerTag.value).e(e, "## handleInternal() failed") } diff --git a/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/di/JoinRoom.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/IncrementPushDataStore.kt similarity index 56% rename from features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/di/JoinRoom.kt rename to libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/IncrementPushDataStore.kt index 477b49e4f6..9a7f99176c 100644 --- a/features/roomdirectory/impl/src/main/kotlin/io/element/android/features/roomdirectory/impl/root/di/JoinRoom.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/IncrementPushDataStore.kt @@ -14,19 +14,22 @@ * limitations under the License. */ -package io.element.android.features.roomdirectory.impl.root.di +package io.element.android.libraries.push.impl.push import com.squareup.anvil.annotations.ContributesBinding -import io.element.android.libraries.di.SessionScope -import io.element.android.libraries.matrix.api.MatrixClient -import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.push.impl.store.DefaultPushDataStore import javax.inject.Inject -interface JoinRoom { - suspend operator fun invoke(roomId: RoomId): Result +interface IncrementPushDataStore { + suspend fun incrementPushCounter() } -@ContributesBinding(SessionScope::class) -class DefaultJoinRoom @Inject constructor(private val client: MatrixClient) : JoinRoom { - override suspend fun invoke(roomId: RoomId) = client.joinRoom(roomId) +@ContributesBinding(AppScope::class) +class DefaultIncrementPushDataStore @Inject constructor( + private val defaultPushDataStore: DefaultPushDataStore +) : IncrementPushDataStore { + override suspend fun incrementPushCounter() { + defaultPushDataStore.incrementPushCounter() + } } diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/OnNotifiableEventReceived.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/OnNotifiableEventReceived.kt new file mode 100644 index 0000000000..cdcc6a1d93 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/OnNotifiableEventReceived.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.push.impl.push + +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.push.impl.notifications.DefaultNotificationDrawerManager +import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch +import javax.inject.Inject + +interface OnNotifiableEventReceived { + fun onNotifiableEventReceived(notifiableEvent: NotifiableEvent) +} + +@ContributesBinding(AppScope::class) +class DefaultOnNotifiableEventReceived @Inject constructor( + private val defaultNotificationDrawerManager: DefaultNotificationDrawerManager, + private val coroutineScope: CoroutineScope, +) : OnNotifiableEventReceived { + override fun onNotifiableEventReceived(notifiableEvent: NotifiableEvent) { + coroutineScope.launch { + defaultNotificationDrawerManager.onNotifiableEventReceived(notifiableEvent) + } + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayAPI.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayAPI.kt index d8de5429ef..4df39587f1 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayAPI.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayAPI.kt @@ -18,7 +18,7 @@ package io.element.android.libraries.push.impl.pushgateway import retrofit2.http.Body import retrofit2.http.POST -internal interface PushGatewayAPI { +interface PushGatewayAPI { /** * Ask the Push Gateway to send a push to the current device. * diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayApiFactory.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayApiFactory.kt new file mode 100644 index 0000000000..1ea4c72fbb --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayApiFactory.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.push.impl.pushgateway + +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.network.RetrofitFactory +import javax.inject.Inject + +interface PushGatewayApiFactory { + fun create(baseUrl: String): PushGatewayAPI +} + +@ContributesBinding(AppScope::class) +class DefaultPushGatewayApiFactory @Inject constructor( + private val retrofitFactory: RetrofitFactory, +) : PushGatewayApiFactory { + override fun create(baseUrl: String): PushGatewayAPI { + return retrofitFactory.create(baseUrl) + .create(PushGatewayAPI::class.java) + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayDevice.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayDevice.kt index 7adedfcfd2..ad5a264168 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayDevice.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayDevice.kt @@ -20,7 +20,7 @@ import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable -internal data class PushGatewayDevice( +data class PushGatewayDevice( /** * Required. The app_id given when the pusher was created. */ diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotification.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotification.kt index 5e341e3286..28ad04a078 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotification.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotification.kt @@ -20,7 +20,7 @@ import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable -internal data class PushGatewayNotification( +data class PushGatewayNotification( @SerialName("event_id") val eventId: String, @SerialName("room_id") diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotifyBody.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotifyBody.kt index ce41d2d83e..14727cab2f 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotifyBody.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotifyBody.kt @@ -20,7 +20,7 @@ import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable -internal data class PushGatewayNotifyBody( +data class PushGatewayNotifyBody( /** * Required. Information about the push notification */ diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotifyRequest.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotifyRequest.kt index e8c01493ab..41c8a05423 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotifyRequest.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotifyRequest.kt @@ -15,15 +15,14 @@ */ package io.element.android.libraries.push.impl.pushgateway +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.di.AppScope import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId -import io.element.android.libraries.network.RetrofitFactory import io.element.android.libraries.push.api.gateway.PushGatewayFailure import javax.inject.Inject -class PushGatewayNotifyRequest @Inject constructor( - private val retrofitFactory: RetrofitFactory, -) { +interface PushGatewayNotifyRequest { data class Params( val url: String, val appId: String, @@ -32,13 +31,18 @@ class PushGatewayNotifyRequest @Inject constructor( val roomId: RoomId, ) - suspend fun execute(params: Params) { - val sygnalApi = retrofitFactory.create( + suspend fun execute(params: Params) +} + +@ContributesBinding(AppScope::class) +class DefaultPushGatewayNotifyRequest @Inject constructor( + private val pushGatewayApiFactory: PushGatewayApiFactory, +) : PushGatewayNotifyRequest { + override suspend fun execute(params: PushGatewayNotifyRequest.Params) { + val pushGatewayApi = pushGatewayApiFactory.create( params.url.substringBefore(PushGatewayConfig.URI_PUSH_GATEWAY_PREFIX_PATH) ) - .create(PushGatewayAPI::class.java) - - val response = sygnalApi.notify( + val response = pushGatewayApi.notify( PushGatewayNotifyBody( PushGatewayNotification( eventId = params.eventId.value, diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotifyResponse.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotifyResponse.kt index 13d9cbad1d..75b5e52111 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotifyResponse.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotifyResponse.kt @@ -20,7 +20,7 @@ import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable -internal data class PushGatewayNotifyResponse( +data class PushGatewayNotifyResponse( @SerialName("rejected") val rejectedPushKeys: List ) diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/test/TestPush.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/test/TestPush.kt new file mode 100644 index 0000000000..667918941e --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/test/TestPush.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.push.impl.test + +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.appconfig.PushConfig +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.push.impl.pushgateway.PushGatewayNotifyRequest +import io.element.android.libraries.pushproviders.api.CurrentUserPushConfig +import javax.inject.Inject + +interface TestPush { + suspend fun execute(config: CurrentUserPushConfig) +} + +@ContributesBinding(AppScope::class) +class DefaultTestPush @Inject constructor( + private val pushGatewayNotifyRequest: PushGatewayNotifyRequest, +) : TestPush { + override suspend fun execute(config: CurrentUserPushConfig) { + pushGatewayNotifyRequest.execute( + PushGatewayNotifyRequest.Params( + url = config.url, + appId = PushConfig.PUSHER_APP_ID, + pushKey = config.pushKey, + eventId = TEST_EVENT_ID, + roomId = TEST_ROOM_ID, + ) + ) + } + + companion object { + val TEST_EVENT_ID = EventId("\$THIS_IS_A_FAKE_EVENT_ID") + val TEST_ROOM_ID = RoomId("!room:domain") + } +} 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 686d10a281..fd85f11ac3 100644 --- a/libraries/push/impl/src/main/res/values-be/translations.xml +++ b/libraries/push/impl/src/main/res/values-be/translations.xml @@ -52,7 +52,6 @@ "%d пакоя" "%d пакояў" - "Выберыце спосаб атрымання апавяшчэнняў" "Фонавая сінхранізацыя" "Сэрвісы Google" "Службы Google Play не знойдзены. Апавяшчэнні могуць не працаваць належным чынам." 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 fac2a69784..c2d93dc94b 100644 --- a/libraries/push/impl/src/main/res/values-cs/translations.xml +++ b/libraries/push/impl/src/main/res/values-cs/translations.xml @@ -52,7 +52,6 @@ "%d místnosti" "%d místností" - "Vyberte, jak chcete přijímat oznámení" "Synchronizace na pozadí" "Služby Google" "Nebyly nalezeny žádné funkční služby Google Play. Oznámení nemusí fungovat správně." 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 2757e89dea..0d1988ecba 100644 --- a/libraries/push/impl/src/main/res/values-de/translations.xml +++ b/libraries/push/impl/src/main/res/values-de/translations.xml @@ -46,7 +46,6 @@ "%d Raum" "%d Räume" - "Wähle aus, wie du Benachrichtigungen erhalten möchtest" "Hintergrundsynchronisation" "Google-Dienste" "Keine gültigen Google Play-Dienste gefunden. Benachrichtigungen funktionieren möglicherweise nicht richtig." 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 9fd13f5768..37f495a163 100644 --- a/libraries/push/impl/src/main/res/values-es/translations.xml +++ b/libraries/push/impl/src/main/res/values-es/translations.xml @@ -28,6 +28,7 @@ "%d mensajes nuevos" "Reaccionó con %1$s" + "Marcar como leído" "Respuesta rápida" "Te invitó a unirte a la sala" "Yo" @@ -45,7 +46,6 @@ "%d sala" "%d salas" - "Elige cómo recibir las notificaciones" "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." 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 8501b8ad76..6173724762 100644 --- a/libraries/push/impl/src/main/res/values-fr/translations.xml +++ b/libraries/push/impl/src/main/res/values-fr/translations.xml @@ -46,7 +46,6 @@ "%d salon" "%d salons" - "Choisissez le mode de réception des notifications" "Synchronisation en arrière-plan" "Services Google" "Aucun service Google Play valide n’a été trouvé. Les notifications peuvent ne pas fonctionner correctement." diff --git a/libraries/push/impl/src/main/res/values-hu/translations.xml b/libraries/push/impl/src/main/res/values-hu/translations.xml index 7b0c66aa65..75804dd0a2 100644 --- a/libraries/push/impl/src/main/res/values-hu/translations.xml +++ b/libraries/push/impl/src/main/res/values-hu/translations.xml @@ -46,7 +46,6 @@ "%d szoba" "%d szoba" - "Válassza ki az értesítések fogadási módját" "Háttérszinkronizálás" "Google szolgáltatások" "A Google Play szolgáltatások nem találhatók. Előfordulhat, hogy az értesítések nem működnek megfelelően." 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 b933330be1..cfc6621d17 100644 --- a/libraries/push/impl/src/main/res/values-in/translations.xml +++ b/libraries/push/impl/src/main/res/values-in/translations.xml @@ -40,7 +40,6 @@ "%d ruangan" - "Pilih cara menerima notifikasi" "Sinkronisasi latar belakang" "Layanan Google" "Tidak ditemukan Layanan Google Play yang valid. Pemberitahuan mungkin tidak berfungsi dengan baik." diff --git a/libraries/push/impl/src/main/res/values-it/translations.xml b/libraries/push/impl/src/main/res/values-it/translations.xml index fe52cd7433..a15e3daa14 100644 --- a/libraries/push/impl/src/main/res/values-it/translations.xml +++ b/libraries/push/impl/src/main/res/values-it/translations.xml @@ -46,7 +46,6 @@ "%d stanza" "%d stanze" - "Scegli come ricevere le notifiche" "Sincronizzazione in background" "Servizi Google" "Google Play Services non trovato. Le notifiche non funzioneranno bene." @@ -67,5 +66,11 @@ "La notifica è stata cliccata!" "Mostra notifica" "Clicca sulla notifica per continuare il test." + "Assicurati che l\'applicazione riceva le notifiche push" + "Errore: il servizio push ha rifiutato la richiesta." "Errore: %1$s." + "Errore, impossibile testare le notifiche push." + "Errore, tempo scaduto in attesa della notifica push." + "Il test push loop back ha impiegato %1$d ms." + "Prova invio notifica push loop back" diff --git a/libraries/push/impl/src/main/res/values-ka/translations.xml b/libraries/push/impl/src/main/res/values-ka/translations.xml new file mode 100644 index 0000000000..4f02db32fa --- /dev/null +++ b/libraries/push/impl/src/main/res/values-ka/translations.xml @@ -0,0 +1,50 @@ + + + "ზარი" + "მოვლენებისთვის მოსმენა" + "ხმაურიანი შეტყობინებები" + "ჩუმი შეტყობინებები" + + "%1$s: %2$d შეტყობინება" + "%1$s: %2$d შეტყობინება" + + + "%d შეტყობინება" + "%d შეტყობინება" + + "შეტყობინება" + "** გაგზავნა ვერ მოხერხდა - გთხოვთ, გახსნათ ოთახი" + "გაწევრიანება" + "უარყოფა" + + "%d მოწვევა" + "%d მოწვევები" + + "მოგიწვიათ ჩატში" + "ახალი შეტყობინებები" + + "%d ახალი მესიჯი" + "%d ახალი მესიჯი" + + "რეაგირება მოხდა: %1$s" + "Სწრაფი პასუხი" + "მოგიწვიათ ოთახში" + "მე" + "თქვენ ხედავთ შეტყობინებას! დამაწკაპუნეთ!" + "%1$s: %2$s" + "%1$s: %2$s %3$s" + + "%d წაუკითხავი შეტყობინება" + "%d წაუკითხავი შეტყობინება" + + "%1$s და %2$s" + "%1$s %2$s-ში" + "%1$s %2$s-ში და %3$s" + + "%d ოთახი" + "%d ოთახი" + + "ფონის სინქრონიზაცია" + "Google სერვისები" + "მოქმედი Google Play სერვისები ვერ მოიძებნა. შეტყობინებები შეიძლება ვერ იმუშაოს სწორად." + 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 new file mode 100644 index 0000000000..c5513675f5 --- /dev/null +++ b/libraries/push/impl/src/main/res/values-pt-rBR/translations.xml @@ -0,0 +1,76 @@ + + + "Chamada" + "À escuta de eventos" + "Notificações barulhentas" + "Notificações silenciosas" + + "%1$s: %2$d mensagem" + "%1$s: %2$d mensagens" + + + "%d notificação" + "%d notificações" + + "Notificação" + "** Falha no envio - por favor abre a sala" + "Entrar" + "Rejeitar" + + "%d convite" + "%d convites" + + "Convidou-te para conversar" + "Mencionou-te: %1$s" + "Mensagens novas" + + "%d mensagem nova" + "%d mensagens novas" + + "Reagiu com %1$s" + "Marcar como lida" + "Resposta rápida" + "Convidou-te a entrar na sala" + "Eu" + "Estás a ver a notificação! Clica em mim!" + "%1$s: %2$s" + "%1$s: %2$s %3$s" + + "%d mensagem notificada não lida" + "%d mensagens notificadas não lidas" + + "%1$s e %2$s" + "%1$s em %2$s" + "%1$s em %2$s e %3$s" + + "%d sala" + "%d salas" + + "Sincronização em segundo plano" + "Serviços do Google Play" + "Nenhuns Serviços do Google Play válidos encontrados. As notificações poderão não funcionar devidamente." + "Obtém o nome do fornecedor atual." + "Nenhum fornecedor de envio selecionado." + "Fornecedor de envio atual: %1$s." + "Fornecedor de envio atual" + "Certifica que a aplicação tem pelo menos um fornecedor de envio." + "Nenhum fornecedor de envio encontrado." + + "%1$d fornecedor de envio encontrado: %2$s" + "%1$d fornecedores de envio encontrados: %2$s" + + "Detetar fornecedores de envio" + "Verificar se a aplicação consegue mostrar notificações." + "Não clicaste na notificação." + "Não foi possível mostrar a notificação." + "Clicaste na notificação!" + "Mostrar notificação" + "Por favor, carrega na notificação para continuar o teste." + "Certifica que a aplicação está a receber notificações instantâneas." + "Erro: fornecedor de envio rejeitou o pedido" + "Erro: %1$s." + "Erro: não foi possível testar envio" + "Erro: envio demorou demasiado" + "O ciclo de envio demorou %1$d ms." + "Testar ciclo de envio" + 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 f242c96ee5..b9862a69a3 100644 --- a/libraries/push/impl/src/main/res/values-ro/translations.xml +++ b/libraries/push/impl/src/main/res/values-ro/translations.xml @@ -46,8 +46,32 @@ "%d cameră" "%d camere" - "Alegeți modul de primire a notificărilor" "Sincronizare în fundal" "Servicii Google" "Nu au fost găsite servicii Google Play valide. Este posibil ca notificările să nu funcționeze corect." + "Obțineți numele furnizorului curent." + "Niciun furnizor push selectat." + "Furnizor de push actual: %1$s." + "Furnizor de push curent" + "Asigurați-vă că aplicația are cel puțin un furnizor push." + "Nu s-au găsit furnizori push." + + "S-a găsit %1$d furnizor push: %2$s" + "S-au găsit %1$d furnizori push: %2$s" + "S-au găsit %1$d furnizori push: %2$s" + + "Detectați furnizorii push" + "Verificați dacă aplicația poate afișa notificări." + "Notificarea nu a fost apăsată." + "Nu s-a putut afișa notificarea." + "Notificarea a fost apăsată!" + "Afișați notificarea" + "Vă rugăm să faceți clic pe notificare pentru a continua testul." + "Asigurați-vă că aplicația primește push-uri." + "Eroare: pusher-ul a respins cererea." + "Eroare: %1$s." + "Eroare, nu se poate testa push-ul." + "Eroare, timeout în așteptare pentru push." + "Push-ul înapoi a durat %1$d ms." + "Testați că push-ul se întoarce" diff --git a/libraries/push/impl/src/main/res/values-ru/translations.xml b/libraries/push/impl/src/main/res/values-ru/translations.xml index 65242651b2..ff78cba4ee 100644 --- a/libraries/push/impl/src/main/res/values-ru/translations.xml +++ b/libraries/push/impl/src/main/res/values-ru/translations.xml @@ -52,7 +52,6 @@ "%d комнаты" "%d комнат" - "Выберите способ получения уведомлений" "Фоновая синхронизация" "Сервисы Google" "Не найдены действующие службы Google Play. Уведомления могут работать некорректно." 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 95aed49bd3..e984d1547c 100644 --- a/libraries/push/impl/src/main/res/values-sk/translations.xml +++ b/libraries/push/impl/src/main/res/values-sk/translations.xml @@ -52,7 +52,6 @@ "%d miestnosti" "%d miestností" - "Vyberte spôsob prijímania oznámení" "Synchronizácia na pozadí" "Služby Google" "Nenašli sa žiadne platné služby Google Play. Oznámenia nemusia fungovať správne." 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 6930a3b13e..8d26670c34 100644 --- a/libraries/push/impl/src/main/res/values-sv/translations.xml +++ b/libraries/push/impl/src/main/res/values-sv/translations.xml @@ -45,7 +45,6 @@ "%d rum" "%d rum" - "Välj hur du vill ta emot aviseringar" "Bakgrundssynkronisering" "Google-tjänster" "Inga giltiga Google Play-tjänster hittades. Aviseringar kanske inte fungerar korrekt." 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 a447f7ea2c..e1a3f8bf47 100644 --- a/libraries/push/impl/src/main/res/values-uk/translations.xml +++ b/libraries/push/impl/src/main/res/values-uk/translations.xml @@ -52,8 +52,8 @@ "%d кімнати" "%d кімнат" - "Виберіть спосіб отримання сповіщень" "Фонова синхронізація" "Сервіси Google" "Не знайдено дійсних сервісів Google Play. Сповіщення можуть не працювати належним чином." + "Не вдається відобразити сповіщення." 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 86b8661c35..d2c91db3b1 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 @@ -22,6 +22,7 @@ "%d 則新訊息" "回應 %1$s" + "標為已讀" "快速回覆" "邀請您加入聊天室" "我" @@ -31,7 +32,6 @@ "%d 個聊天室" - "選擇接收通知的機制" "背景同步" "Google 服務" diff --git a/libraries/push/impl/src/main/res/values-zh/translations.xml b/libraries/push/impl/src/main/res/values-zh/translations.xml new file mode 100644 index 0000000000..9a4e2f47d2 --- /dev/null +++ b/libraries/push/impl/src/main/res/values-zh/translations.xml @@ -0,0 +1,69 @@ + + + "通话" + "监听事件" + "嘈杂通知" + "静默通知" + + "%1$s:%2$d 条消息" + + + "%d 条通知" + + "通知" + "** 无法发送——请打开房间" + "加入" + "拒绝" + + "%d 个邀请" + + "邀请您聊天" + "提到了你:%1$s" + "新消息" + + "%d 条新消息" + + "使用 %1$s 回应" + "标记为已读" + "快速回复" + "邀请你加入房间" + "我" + "您正在查看通知!点击我!" + "%1$s:%2$s" + "%1$s: %2$s %3$s" + + "%d 条未读消息" + + "%1$s 和 %2$s" + "%2$s 中的 %1$s" + "在 %2$s 和 %3$s 中的 %1$s" + + "%d 个房间" + + "后台同步" + "谷歌服务" + "找不到有效的 Google Play 服务。通知可能无法正常工作。" + "获取当前推送提供者的名称。" + "未选择任何推送提供者。" + "当前推送提供者:%1$s。" + "当前推送提供者" + "确保应用程序至少有一个推送提供者。" + "未找到推送提供者。" + + "找到了 %1$d 个推送提供者:%2$s" + + "检测推送提供者" + "检查应用程序是否可以显示通知。" + "通知未被点击。" + "无法显示通知。" + "通知已被点击!" + "显示通知" + "请点击通知继续测试。" + "确保应用程序正在接收推送。" + "错误:推送者拒绝了该请求。" + "错误:%1$s。" + "错误,无法测试推送。" + "错误,等待推送超时。" + "推送回路耗时%1$d 毫秒。" + "测试推送回路" + diff --git a/libraries/push/impl/src/main/res/values/localazy.xml b/libraries/push/impl/src/main/res/values/localazy.xml index c9d7627d07..1064d5c31e 100644 --- a/libraries/push/impl/src/main/res/values/localazy.xml +++ b/libraries/push/impl/src/main/res/values/localazy.xml @@ -46,7 +46,6 @@ "%d room" "%d rooms" - "Choose how to receive notifications" "Background synchronization" "Google Services" "No valid Google Play Services found. Notifications may not work properly." diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/DefaultPushServiceTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/DefaultPushServiceTest.kt new file mode 100644 index 0000000000..546d69b008 --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/DefaultPushServiceTest.kt @@ -0,0 +1,221 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.push.impl + +import com.google.common.truth.Truth.assertThat +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.push.api.GetCurrentPushProvider +import io.element.android.libraries.push.impl.test.FakeTestPush +import io.element.android.libraries.push.impl.test.TestPush +import io.element.android.libraries.push.test.FakeGetCurrentPushProvider +import io.element.android.libraries.pushproviders.api.CurrentUserPushConfig +import io.element.android.libraries.pushproviders.api.Distributor +import io.element.android.libraries.pushproviders.api.PushProvider +import io.element.android.libraries.pushproviders.test.FakePushProvider +import io.element.android.libraries.pushstore.api.UserPushStoreFactory +import io.element.android.libraries.pushstore.test.userpushstore.FakeUserPushStore +import io.element.android.libraries.pushstore.test.userpushstore.FakeUserPushStoreFactory +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class DefaultPushServiceTest { + @Test + fun `test push no push provider`() = runTest { + val defaultPushService = createDefaultPushService() + assertThat(defaultPushService.testPush()).isFalse() + } + + @Test + fun `test push no config`() = runTest { + val aPushProvider = FakePushProvider() + val defaultPushService = createDefaultPushService( + pushProviders = setOf(aPushProvider), + getCurrentPushProvider = FakeGetCurrentPushProvider(currentPushProvider = aPushProvider.name), + ) + assertThat(defaultPushService.testPush()).isFalse() + } + + @Test + fun `test push ok`() = runTest { + val aConfig = CurrentUserPushConfig( + url = "aUrl", + pushKey = "aPushKey", + ) + val testPushResult = lambdaRecorder { } + val aPushProvider = FakePushProvider( + currentUserPushConfig = aConfig + ) + val defaultPushService = createDefaultPushService( + pushProviders = setOf(aPushProvider), + getCurrentPushProvider = FakeGetCurrentPushProvider(currentPushProvider = aPushProvider.name), + testPush = FakeTestPush(executeResult = testPushResult), + ) + assertThat(defaultPushService.testPush()).isTrue() + testPushResult.assertions() + .isCalledOnce() + .with(value(aConfig)) + } + + @Test + fun `getCurrentPushProvider null`() = runTest { + val defaultPushService = createDefaultPushService() + val result = defaultPushService.getCurrentPushProvider() + assertThat(result).isNull() + } + + @Test + fun `getCurrentPushProvider ok`() = runTest { + val aPushProvider = FakePushProvider() + val defaultPushService = createDefaultPushService( + pushProviders = setOf(aPushProvider), + getCurrentPushProvider = FakeGetCurrentPushProvider(currentPushProvider = aPushProvider.name), + ) + val result = defaultPushService.getCurrentPushProvider() + assertThat(result).isEqualTo(aPushProvider) + } + + @Test + fun `getAvailablePushProviders empty`() = runTest { + val defaultPushService = createDefaultPushService() + val result = defaultPushService.getAvailablePushProviders() + assertThat(result).isEmpty() + } + + @Test + fun `registerWith ok`() = runTest { + val client = FakeMatrixClient() + val aPushProvider = FakePushProvider( + registerWithResult = { _, _ -> Result.success(Unit) }, + ) + val aDistributor = Distributor("aValue", "aName") + val defaultPushService = createDefaultPushService() + val result = defaultPushService.registerWith(client, aPushProvider, aDistributor) + assertThat(result).isEqualTo(Result.success(Unit)) + } + + @Test + fun `registerWith fail to register`() = runTest { + val client = FakeMatrixClient() + val aPushProvider = FakePushProvider( + registerWithResult = { _, _ -> Result.failure(AN_EXCEPTION) }, + ) + val aDistributor = Distributor("aValue", "aName") + val defaultPushService = createDefaultPushService() + val result = defaultPushService.registerWith(client, aPushProvider, aDistributor) + assertThat(result.isFailure).isTrue() + } + + @Test + fun `registerWith fail to unregister previous push provider`() = runTest { + val client = FakeMatrixClient() + val aCurrentPushProvider = FakePushProvider( + unregisterWithResult = { Result.failure(AN_EXCEPTION) }, + name = "aCurrentPushProvider", + ) + val aPushProvider = FakePushProvider( + name = "aPushProvider", + ) + val userPushStore = FakeUserPushStore().apply { + setPushProviderName(aCurrentPushProvider.name) + } + val aDistributor = Distributor("aValue", "aName") + val defaultPushService = createDefaultPushService( + pushProviders = setOf(aCurrentPushProvider, aPushProvider), + getCurrentPushProvider = FakeGetCurrentPushProvider(currentPushProvider = aCurrentPushProvider.name), + userPushStoreFactory = FakeUserPushStoreFactory( + userPushStore = { userPushStore }, + ), + ) + val result = defaultPushService.registerWith(client, aPushProvider, aDistributor) + assertThat(result.isFailure).isTrue() + assertThat(userPushStore.getPushProviderName()).isEqualTo(aCurrentPushProvider.name) + } + + @Test + fun `registerWith unregister previous push provider and register new OK`() = runTest { + val client = FakeMatrixClient() + val unregisterLambda = lambdaRecorder> { Result.success(Unit) } + val registerLambda = lambdaRecorder> { _, _ -> Result.success(Unit) } + val aCurrentPushProvider = FakePushProvider( + unregisterWithResult = unregisterLambda, + name = "aCurrentPushProvider", + ) + val aPushProvider = FakePushProvider( + registerWithResult = registerLambda, + name = "aPushProvider", + ) + val userPushStore = FakeUserPushStore().apply { + setPushProviderName(aCurrentPushProvider.name) + } + val aDistributor = Distributor("aValue", "aName") + val defaultPushService = createDefaultPushService( + pushProviders = setOf(aCurrentPushProvider, aPushProvider), + getCurrentPushProvider = FakeGetCurrentPushProvider(currentPushProvider = aCurrentPushProvider.name), + userPushStoreFactory = FakeUserPushStoreFactory( + userPushStore = { userPushStore }, + ), + ) + val result = defaultPushService.registerWith(client, aPushProvider, aDistributor) + assertThat(result.isSuccess).isTrue() + assertThat(userPushStore.getPushProviderName()).isEqualTo(aPushProvider.name) + unregisterLambda.assertions() + .isCalledOnce() + .with(value(client)) + registerLambda.assertions() + .isCalledOnce() + .with(value(client), value(aDistributor)) + } + + @Test + fun `getAvailablePushProviders sorted`() = runTest { + val aPushProvider1 = FakePushProvider( + index = 1, + name = "aPushProvider1", + ) + val aPushProvider2 = FakePushProvider( + index = 2, + name = "aPushProvider2", + ) + val aPushProvider3 = FakePushProvider( + index = 3, + name = "aPushProvider3", + ) + val defaultPushService = createDefaultPushService( + pushProviders = setOf(aPushProvider1, aPushProvider3, aPushProvider2), + ) + val result = defaultPushService.getAvailablePushProviders() + assertThat(result).containsExactly(aPushProvider1, aPushProvider2, aPushProvider3).inOrder() + } + + private fun createDefaultPushService( + testPush: TestPush = FakeTestPush(), + userPushStoreFactory: UserPushStoreFactory = FakeUserPushStoreFactory(), + pushProviders: Set<@JvmSuppressWildcards PushProvider> = emptySet(), + getCurrentPushProvider: GetCurrentPushProvider = FakeGetCurrentPushProvider(currentPushProvider = null), + ): DefaultPushService { + return DefaultPushService( + testPush = testPush, + userPushStoreFactory = userPushStoreFactory, + pushProviders = pushProviders, + getCurrentPushProvider = getCurrentPushProvider, + ) + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/DefaultPusherSubscriberTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/DefaultPusherSubscriberTest.kt new file mode 100644 index 0000000000..dd9363de9e --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/DefaultPusherSubscriberTest.kt @@ -0,0 +1,193 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.push.impl + +import com.google.common.truth.Truth.assertThat +import io.element.android.appconfig.PushConfig +import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.matrix.api.pusher.SetHttpPusherData +import io.element.android.libraries.matrix.api.pusher.UnsetHttpPusherData +import io.element.android.libraries.matrix.test.AN_EXCEPTION +import io.element.android.libraries.matrix.test.A_SECRET +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.matrix.test.core.aBuildMeta +import io.element.android.libraries.matrix.test.pushers.FakePushersService +import io.element.android.libraries.pushstore.api.UserPushStoreFactory +import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecret +import io.element.android.libraries.pushstore.test.userpushstore.FakeUserPushStore +import io.element.android.libraries.pushstore.test.userpushstore.FakeUserPushStoreFactory +import io.element.android.libraries.pushstore.test.userpushstore.clientsecret.FakePushClientSecret +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class DefaultPusherSubscriberTest { + @Test + fun `test register pusher OK`() = runTest { + testRegisterPusher( + currentPushKey = null, + registerResult = Result.success(Unit), + ) + } + + @Test + fun `test re-register pusher OK`() = runTest { + testRegisterPusher( + currentPushKey = "aPushKey", + registerResult = Result.success(Unit), + ) + } + + @Test + fun `test register pusher error`() = runTest { + testRegisterPusher( + currentPushKey = null, + registerResult = Result.failure(AN_EXCEPTION), + ) + } + + @Test + fun `test re-register pusher error`() = runTest { + testRegisterPusher( + currentPushKey = "aPushKey", + registerResult = Result.failure(AN_EXCEPTION), + ) + } + + private suspend fun testRegisterPusher( + currentPushKey: String?, + registerResult: Result, + ) { + val setHttpPusherResult = lambdaRecorder> { registerResult } + val userPushStore = FakeUserPushStore().apply { + setCurrentRegisteredPushKey(currentPushKey) + } + assertThat(userPushStore.getCurrentRegisteredPushKey()).isEqualTo(currentPushKey) + + val matrixClient = FakeMatrixClient( + pushersService = FakePushersService( + setHttpPusherResult = setHttpPusherResult, + ), + ) + val defaultPusherSubscriber = createDefaultPusherSubscriber( + pushClientSecret = FakePushClientSecret( + getSecretForUserResult = { A_SECRET }, + ), + userPushStoreFactory = FakeUserPushStoreFactory( + userPushStore = { userPushStore }, + ), + ) + val result = defaultPusherSubscriber.registerPusher( + matrixClient = matrixClient, + pushKey = "aPushKey", + gateway = "aGateway", + ) + assertThat(result).isEqualTo(registerResult) + setHttpPusherResult.assertions() + .isCalledOnce() + .with( + value( + SetHttpPusherData( + pushKey = "aPushKey", + appId = PushConfig.PUSHER_APP_ID, + url = "aGateway", + appDisplayName = "MyApp", + deviceDisplayName = "MyDevice", + profileTag = DEFAULT_PUSHER_FILE_TAG + "_", + lang = "en", + defaultPayload = "{\"cs\":\"$A_SECRET\"}", + ), + ) + ) + assertThat(userPushStore.getCurrentRegisteredPushKey()).isEqualTo( + if (registerResult.isSuccess) "aPushKey" else currentPushKey + ) + } + + @Test + fun `test unregister pusher OK`() = runTest { + testUnregisterPusher( + currentPushKey = "aPushKey", + unregisterResult = Result.success(Unit), + ) + } + + @Test + fun `test unregister pusher error`() = runTest { + testUnregisterPusher( + currentPushKey = "aPushKey", + unregisterResult = Result.failure(AN_EXCEPTION), + ) + } + + private suspend fun testUnregisterPusher( + currentPushKey: String?, + unregisterResult: Result, + ) { + val unsetHttpPusherResult = lambdaRecorder> { unregisterResult } + val userPushStore = FakeUserPushStore().apply { + setCurrentRegisteredPushKey(currentPushKey) + } + assertThat(userPushStore.getCurrentRegisteredPushKey()).isEqualTo(currentPushKey) + + val matrixClient = FakeMatrixClient( + pushersService = FakePushersService( + unsetHttpPusherResult = unsetHttpPusherResult, + ), + ) + val defaultPusherSubscriber = createDefaultPusherSubscriber( + pushClientSecret = FakePushClientSecret( + getSecretForUserResult = { A_SECRET }, + ), + userPushStoreFactory = FakeUserPushStoreFactory( + userPushStore = { userPushStore }, + ), + ) + val result = defaultPusherSubscriber.unregisterPusher( + matrixClient = matrixClient, + pushKey = "aPushKey", + gateway = "aGateway", + ) + assertThat(result).isEqualTo(unregisterResult) + unsetHttpPusherResult.assertions() + .isCalledOnce() + .with( + value( + UnsetHttpPusherData( + pushKey = "aPushKey", + appId = PushConfig.PUSHER_APP_ID, + ), + ) + ) + assertThat(userPushStore.getCurrentRegisteredPushKey()).isEqualTo( + if (unregisterResult.isSuccess) null else currentPushKey + ) + } + + private fun createDefaultPusherSubscriber( + buildMeta: BuildMeta = aBuildMeta(applicationName = "MyApp"), + userPushStoreFactory: UserPushStoreFactory = FakeUserPushStoreFactory(), + pushClientSecret: PushClientSecret = FakePushClientSecret(), + ): DefaultPusherSubscriber { + return DefaultPusherSubscriber( + buildMeta = buildMeta, + pushClientSecret = pushClientSecret, + userPushStoreFactory = userPushStoreFactory, + ) + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultActiveNotificationsProviderTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultActiveNotificationsProviderTest.kt new file mode 100644 index 0000000000..31daf9e4cc --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultActiveNotificationsProviderTest.kt @@ -0,0 +1,175 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.push.impl.notifications + +import android.service.notification.StatusBarNotification +import androidx.core.app.NotificationManagerCompat +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.matrix.test.A_ROOM_ID_2 +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.matrix.test.A_SESSION_ID_2 +import io.mockk.every +import io.mockk.mockk +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class DefaultActiveNotificationsProviderTest { + @Test + fun `getAllNotifications with no active notifications returns empty list`() { + val activeNotificationsProvider = createActiveNotificationsProvider(activeNotifications = emptyList()) + + val emptyNotifications = activeNotificationsProvider.getAllNotifications() + assertThat(emptyNotifications).isEmpty() + } + + @Test + fun `getAllNotifications with active notifications returns all`() { + val notificationIdProvider = NotificationIdProvider() + val activeNotifications = listOf( + aStatusBarNotification(id = notificationIdProvider.getRoomMessagesNotificationId(A_SESSION_ID), groupId = A_SESSION_ID.value), + aStatusBarNotification(id = notificationIdProvider.getSummaryNotificationId(A_SESSION_ID), groupId = A_SESSION_ID.value), + aStatusBarNotification(id = notificationIdProvider.getRoomInvitationNotificationId(A_SESSION_ID), groupId = A_SESSION_ID.value), + ) + val activeNotificationsProvider = createActiveNotificationsProvider(activeNotifications = activeNotifications) + + val result = activeNotificationsProvider.getAllNotifications() + assertThat(result).hasSize(3) + } + + @Test + fun `getNotificationsForSession returns only notifications for that session id`() { + val notificationIdProvider = NotificationIdProvider() + val activeNotifications = listOf( + aStatusBarNotification(id = notificationIdProvider.getRoomMessagesNotificationId(A_SESSION_ID), groupId = A_SESSION_ID.value), + aStatusBarNotification(id = notificationIdProvider.getSummaryNotificationId(A_SESSION_ID_2), groupId = A_SESSION_ID_2.value), + aStatusBarNotification(id = notificationIdProvider.getRoomInvitationNotificationId(A_SESSION_ID_2), groupId = A_SESSION_ID_2.value), + ) + val activeNotificationsProvider = createActiveNotificationsProvider(activeNotifications = activeNotifications) + + assertThat(activeNotificationsProvider.getNotificationsForSession(A_SESSION_ID)).hasSize(1) + assertThat(activeNotificationsProvider.getNotificationsForSession(A_SESSION_ID_2)).hasSize(2) + } + + @Test + fun `getMembershipNotificationsForSession returns only membership notifications for that session id`() { + val notificationIdProvider = NotificationIdProvider() + val activeNotifications = listOf( + aStatusBarNotification(id = notificationIdProvider.getRoomMessagesNotificationId(A_SESSION_ID), groupId = A_SESSION_ID.value,), + aStatusBarNotification(id = notificationIdProvider.getSummaryNotificationId(A_SESSION_ID_2), groupId = A_SESSION_ID_2.value), + aStatusBarNotification( + id = notificationIdProvider.getRoomInvitationNotificationId(A_SESSION_ID_2), + groupId = A_SESSION_ID_2.value, + tag = A_ROOM_ID.value, + ), + ) + val activeNotificationsProvider = createActiveNotificationsProvider(activeNotifications = activeNotifications) + + assertThat(activeNotificationsProvider.getMembershipNotificationForSession(A_SESSION_ID)).isEmpty() + assertThat(activeNotificationsProvider.getMembershipNotificationForSession(A_SESSION_ID_2)).hasSize(1) + } + + @Test + fun `getMessageNotificationsForRoom returns only message notifications for those session and room ids`() { + val notificationIdProvider = NotificationIdProvider() + val activeNotifications = listOf( + aStatusBarNotification( + id = notificationIdProvider.getRoomMessagesNotificationId(A_SESSION_ID), + groupId = A_SESSION_ID.value, + tag = A_ROOM_ID.value + ), + aStatusBarNotification(id = notificationIdProvider.getSummaryNotificationId(A_SESSION_ID), groupId = A_SESSION_ID.value, tag = A_ROOM_ID.value), + aStatusBarNotification( + id = notificationIdProvider.getRoomMessagesNotificationId(A_SESSION_ID_2), + groupId = A_SESSION_ID_2.value, + tag = A_ROOM_ID.value + ), + aStatusBarNotification(id = notificationIdProvider.getSummaryNotificationId(A_SESSION_ID_2), groupId = A_SESSION_ID_2.value, tag = A_ROOM_ID.value), + aStatusBarNotification( + id = notificationIdProvider.getRoomInvitationNotificationId(A_SESSION_ID_2), + groupId = A_SESSION_ID_2.value, + tag = A_ROOM_ID.value + ), + ) + val activeNotificationsProvider = createActiveNotificationsProvider(activeNotifications = activeNotifications) + + assertThat(activeNotificationsProvider.getMessageNotificationsForRoom(A_SESSION_ID, A_ROOM_ID)).hasSize(1) + assertThat(activeNotificationsProvider.getMessageNotificationsForRoom(A_SESSION_ID_2, A_ROOM_ID_2)).isEmpty() + } + + @Test + fun `getMembershipNotificationsForRoom returns only membership notifications for those session and room ids`() { + val notificationIdProvider = NotificationIdProvider() + val activeNotifications = listOf( + aStatusBarNotification( + id = notificationIdProvider.getRoomMessagesNotificationId(A_SESSION_ID), + groupId = A_SESSION_ID.value, + tag = A_ROOM_ID.value + ), + aStatusBarNotification(id = notificationIdProvider.getSummaryNotificationId(A_SESSION_ID), groupId = A_SESSION_ID.value, tag = A_ROOM_ID.value), + aStatusBarNotification( + id = notificationIdProvider.getRoomInvitationNotificationId(A_SESSION_ID_2), + groupId = A_SESSION_ID_2.value, + tag = A_ROOM_ID_2.value + ), + aStatusBarNotification(id = notificationIdProvider.getSummaryNotificationId(A_SESSION_ID_2), groupId = A_SESSION_ID_2.value, tag = A_ROOM_ID.value), + aStatusBarNotification( + id = notificationIdProvider.getRoomInvitationNotificationId(A_SESSION_ID_2), + groupId = A_SESSION_ID_2.value, + tag = A_ROOM_ID_2.value + ), + ) + val activeNotificationsProvider = createActiveNotificationsProvider(activeNotifications = activeNotifications) + + assertThat(activeNotificationsProvider.getMembershipNotificationForRoom(A_SESSION_ID, A_ROOM_ID)).isEmpty() + assertThat(activeNotificationsProvider.getMembershipNotificationForRoom(A_SESSION_ID_2, A_ROOM_ID_2)).hasSize(2) + } + + @Test + fun `getSummaryNotification returns only the summary notification for that session id if it exists`() { + val notificationIdProvider = NotificationIdProvider() + val activeNotifications = listOf( + aStatusBarNotification(id = notificationIdProvider.getRoomMessagesNotificationId(A_SESSION_ID), groupId = A_SESSION_ID.value), + aStatusBarNotification(id = notificationIdProvider.getSummaryNotificationId(A_SESSION_ID), groupId = A_SESSION_ID.value), + aStatusBarNotification(id = notificationIdProvider.getRoomInvitationNotificationId(A_SESSION_ID_2), groupId = A_SESSION_ID_2.value), + ) + val activeNotificationsProvider = createActiveNotificationsProvider(activeNotifications = activeNotifications) + + assertThat(activeNotificationsProvider.getSummaryNotification(A_SESSION_ID)).isNotNull() + assertThat(activeNotificationsProvider.getSummaryNotification(A_SESSION_ID_2)).isNull() + } + + private fun aStatusBarNotification(id: Int, groupId: String, tag: String? = null) = mockk { + every { this@mockk.id } returns id + every { this@mockk.groupKey } returns groupId + every { this@mockk.tag } returns tag + } + + private fun createActiveNotificationsProvider( + activeNotifications: List = emptyList(), + ): DefaultActiveNotificationsProvider { + val notificationManager = mockk { + every { this@mockk.activeNotifications } returns activeNotifications + } + return DefaultActiveNotificationsProvider( + notificationManager = notificationManager, + notificationIdProvider = NotificationIdProvider(), + ) + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventResolverTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolverTest.kt similarity index 94% rename from libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventResolverTest.kt rename to libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolverTest.kt index d0cd5a30e5..8eebbbbb5c 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventResolverTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolverTest.kt @@ -59,17 +59,17 @@ import org.robolectric.RuntimeEnvironment import org.robolectric.annotation.Config @RunWith(RobolectricTestRunner::class) -class NotifiableEventResolverTest { +class DefaultNotifiableEventResolverTest { @Test fun `resolve event no session`() = runTest { - val sut = createNotifiableEventResolver(notificationService = null) + val sut = createDefaultNotifiableEventResolver(notificationService = null) val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID) assertThat(result).isNull() } @Test fun `resolve event failure`() = runTest { - val sut = createNotifiableEventResolver( + val sut = createDefaultNotifiableEventResolver( notificationResult = Result.failure(AN_EXCEPTION) ) val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID) @@ -78,7 +78,7 @@ class NotifiableEventResolverTest { @Test fun `resolve event null`() = runTest { - val sut = createNotifiableEventResolver( + val sut = createDefaultNotifiableEventResolver( notificationResult = Result.success(null) ) val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID) @@ -87,7 +87,7 @@ class NotifiableEventResolverTest { @Test fun `resolve event message text`() = runTest { - val sut = createNotifiableEventResolver( + val sut = createDefaultNotifiableEventResolver( notificationResult = Result.success( createNotificationData( content = NotificationContent.MessageLike.RoomMessage( @@ -105,7 +105,7 @@ class NotifiableEventResolverTest { @Test @Config(qualifiers = "en") fun `resolve event message with mention`() = runTest { - val sut = createNotifiableEventResolver( + val sut = createDefaultNotifiableEventResolver( notificationResult = Result.success( createNotificationData( content = NotificationContent.MessageLike.RoomMessage( @@ -123,7 +123,7 @@ class NotifiableEventResolverTest { @Test fun `resolve HTML formatted event message text takes plain text version`() = runTest { - val sut = createNotifiableEventResolver( + val sut = createDefaultNotifiableEventResolver( notificationResult = Result.success( createNotificationData( content = NotificationContent.MessageLike.RoomMessage( @@ -146,7 +146,7 @@ class NotifiableEventResolverTest { @Test fun `resolve incorrectly formatted event message text uses fallback`() = runTest { - val sut = createNotifiableEventResolver( + val sut = createDefaultNotifiableEventResolver( notificationResult = Result.success( createNotificationData( content = NotificationContent.MessageLike.RoomMessage( @@ -169,7 +169,7 @@ class NotifiableEventResolverTest { @Test fun `resolve event message audio`() = runTest { - val sut = createNotifiableEventResolver( + val sut = createDefaultNotifiableEventResolver( notificationResult = Result.success( createNotificationData( content = NotificationContent.MessageLike.RoomMessage( @@ -186,7 +186,7 @@ class NotifiableEventResolverTest { @Test fun `resolve event message video`() = runTest { - val sut = createNotifiableEventResolver( + val sut = createDefaultNotifiableEventResolver( notificationResult = Result.success( createNotificationData( content = NotificationContent.MessageLike.RoomMessage( @@ -203,7 +203,7 @@ class NotifiableEventResolverTest { @Test fun `resolve event message voice`() = runTest { - val sut = createNotifiableEventResolver( + val sut = createDefaultNotifiableEventResolver( notificationResult = Result.success( createNotificationData( content = NotificationContent.MessageLike.RoomMessage( @@ -220,7 +220,7 @@ class NotifiableEventResolverTest { @Test fun `resolve event message image`() = runTest { - val sut = createNotifiableEventResolver( + val sut = createDefaultNotifiableEventResolver( notificationResult = Result.success( createNotificationData( content = NotificationContent.MessageLike.RoomMessage( @@ -237,7 +237,7 @@ class NotifiableEventResolverTest { @Test fun `resolve event message sticker`() = runTest { - val sut = createNotifiableEventResolver( + val sut = createDefaultNotifiableEventResolver( notificationResult = Result.success( createNotificationData( content = NotificationContent.MessageLike.RoomMessage( @@ -254,7 +254,7 @@ class NotifiableEventResolverTest { @Test fun `resolve event message file`() = runTest { - val sut = createNotifiableEventResolver( + val sut = createDefaultNotifiableEventResolver( notificationResult = Result.success( createNotificationData( content = NotificationContent.MessageLike.RoomMessage( @@ -271,7 +271,7 @@ class NotifiableEventResolverTest { @Test fun `resolve event message location`() = runTest { - val sut = createNotifiableEventResolver( + val sut = createDefaultNotifiableEventResolver( notificationResult = Result.success( createNotificationData( content = NotificationContent.MessageLike.RoomMessage( @@ -288,7 +288,7 @@ class NotifiableEventResolverTest { @Test fun `resolve event message notice`() = runTest { - val sut = createNotifiableEventResolver( + val sut = createDefaultNotifiableEventResolver( notificationResult = Result.success( createNotificationData( content = NotificationContent.MessageLike.RoomMessage( @@ -305,7 +305,7 @@ class NotifiableEventResolverTest { @Test fun `resolve event message emote`() = runTest { - val sut = createNotifiableEventResolver( + val sut = createDefaultNotifiableEventResolver( notificationResult = Result.success( createNotificationData( content = NotificationContent.MessageLike.RoomMessage( @@ -322,7 +322,7 @@ class NotifiableEventResolverTest { @Test fun `resolve poll`() = runTest { - val sut = createNotifiableEventResolver( + val sut = createDefaultNotifiableEventResolver( notificationResult = Result.success( createNotificationData( content = NotificationContent.MessageLike.Poll( @@ -339,7 +339,7 @@ class NotifiableEventResolverTest { @Test fun `resolve RoomMemberContent invite room`() = runTest { - val sut = createNotifiableEventResolver( + val sut = createDefaultNotifiableEventResolver( notificationResult = Result.success( createNotificationData( content = NotificationContent.StateEvent.RoomMemberContent( @@ -372,7 +372,7 @@ class NotifiableEventResolverTest { @Test fun `resolve RoomMemberContent invite direct`() = runTest { - val sut = createNotifiableEventResolver( + val sut = createDefaultNotifiableEventResolver( notificationResult = Result.success( createNotificationData( content = NotificationContent.StateEvent.RoomMemberContent( @@ -405,7 +405,7 @@ class NotifiableEventResolverTest { @Test fun `resolve RoomMemberContent other`() = runTest { - val sut = createNotifiableEventResolver( + val sut = createDefaultNotifiableEventResolver( notificationResult = Result.success( createNotificationData( content = NotificationContent.StateEvent.RoomMemberContent( @@ -421,7 +421,7 @@ class NotifiableEventResolverTest { @Test fun `resolve RoomEncrypted`() = runTest { - val sut = createNotifiableEventResolver( + val sut = createDefaultNotifiableEventResolver( notificationResult = Result.success( createNotificationData( content = NotificationContent.MessageLike.RoomEncrypted @@ -445,7 +445,7 @@ class NotifiableEventResolverTest { @Test fun `resolve CallInvite`() = runTest { - val sut = createNotifiableEventResolver( + val sut = createDefaultNotifiableEventResolver( notificationResult = Result.success( createNotificationData( content = NotificationContent.MessageLike.CallInvite(A_USER_ID_2) @@ -517,7 +517,7 @@ class NotifiableEventResolverTest { } private fun testNull(content: NotificationContent) = runTest { - val sut = createNotifiableEventResolver( + val sut = createDefaultNotifiableEventResolver( notificationResult = Result.success( createNotificationData( content = content @@ -528,10 +528,10 @@ class NotifiableEventResolverTest { assertThat(result).isNull() } - private fun createNotifiableEventResolver( + private fun createDefaultNotifiableEventResolver( notificationService: FakeNotificationService? = FakeNotificationService(), notificationResult: Result = Result.success(null), - ): NotifiableEventResolver { + ): DefaultNotifiableEventResolver { val context = RuntimeEnvironment.getApplication() as Context notificationService?.givenGetNotificationResult(notificationResult) val matrixClientProvider = FakeMatrixClientProvider(getClient = { @@ -544,7 +544,7 @@ class NotifiableEventResolverTest { val notificationMediaRepoFactory = NotificationMediaRepo.Factory { FakeNotificationMediaRepo() } - return NotifiableEventResolver( + return DefaultNotifiableEventResolver( stringProvider = AndroidStringProvider(context.resources), clock = FakeSystemClock(), matrixClientProvider = matrixClientProvider, diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManagerTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManagerTest.kt index b84e7b4be3..3924171d60 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManagerTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManagerTest.kt @@ -16,25 +16,36 @@ package io.element.android.libraries.push.impl.notifications +import android.app.Notification +import androidx.core.app.NotificationManagerCompat +import com.google.common.truth.Truth.assertThat +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_ID import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_SPACE_ID import io.element.android.libraries.matrix.test.A_THREAD_ID +import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClientProvider -import io.element.android.libraries.matrix.test.core.aBuildMeta +import io.element.android.libraries.matrix.ui.components.aMatrixUser +import io.element.android.libraries.push.impl.notifications.fake.FakeActiveNotificationsProvider import io.element.android.libraries.push.impl.notifications.fake.FakeImageLoaderHolder -import io.element.android.libraries.push.impl.notifications.fake.MockkNotificationCreator -import io.element.android.libraries.push.impl.notifications.fake.MockkRoomGroupMessageCreator -import io.element.android.libraries.push.impl.notifications.fake.MockkSummaryGroupMessageCreator +import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationCreator +import io.element.android.libraries.push.impl.notifications.fake.FakeRoomGroupMessageCreator +import io.element.android.libraries.push.impl.notifications.fake.FakeSummaryGroupMessageCreator import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent -import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent import io.element.android.services.appnavstate.api.AppNavigationState import io.element.android.services.appnavstate.api.AppNavigationStateService import io.element.android.services.appnavstate.api.NavigationState import io.element.android.services.appnavstate.test.FakeAppNavigationStateService import io.element.android.services.appnavstate.test.aNavigationState -import io.element.android.tests.testutils.testCoroutineDispatchers +import io.element.android.services.toolbox.test.strings.FakeStringProvider +import io.element.android.tests.testutils.lambda.any +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import io.mockk.every +import io.mockk.mockk +import io.mockk.verify import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.test.TestScope @@ -58,17 +69,29 @@ class DefaultNotificationDrawerManagerTest { @Test fun `cover all APIs`() = runTest { // For now just call all the API. Later, add more valuable tests. - val defaultNotificationDrawerManager = createDefaultNotificationDrawerManager() - defaultNotificationDrawerManager.notificationStyleChanged() - defaultNotificationDrawerManager.clearAllMessagesEvents(A_SESSION_ID, doRender = true) - defaultNotificationDrawerManager.clearAllMessagesEvents(A_SESSION_ID, doRender = false) - defaultNotificationDrawerManager.clearEvent(A_SESSION_ID, AN_EVENT_ID, doRender = true) - defaultNotificationDrawerManager.clearEvent(A_SESSION_ID, AN_EVENT_ID, doRender = false) - defaultNotificationDrawerManager.clearMessagesForRoom(A_SESSION_ID, A_ROOM_ID, doRender = true) - defaultNotificationDrawerManager.clearMessagesForRoom(A_SESSION_ID, A_ROOM_ID, doRender = false) + val matrixUser = aMatrixUser(id = A_SESSION_ID.value, displayName = "alice", avatarUrl = "mxc://data") + val mockRoomGroupMessageCreator = FakeRoomGroupMessageCreator( + createRoomMessageResult = lambdaRecorder { user, _, roomId, _, existingNotification, -> + assertThat(user).isEqualTo(matrixUser) + assertThat(roomId).isEqualTo(A_ROOM_ID) + assertThat(existingNotification).isNull() + Notification() + } + ) + val summaryGroupMessageCreator = FakeSummaryGroupMessageCreator() + val defaultNotificationDrawerManager = createDefaultNotificationDrawerManager( + roomGroupMessageCreator = mockRoomGroupMessageCreator, + summaryGroupMessageCreator = summaryGroupMessageCreator, + ) + defaultNotificationDrawerManager.clearAllMessagesEvents(A_SESSION_ID) + defaultNotificationDrawerManager.clearAllMessagesEvents(A_SESSION_ID) + defaultNotificationDrawerManager.clearEvent(A_SESSION_ID, AN_EVENT_ID) + defaultNotificationDrawerManager.clearEvent(A_SESSION_ID, AN_EVENT_ID) + defaultNotificationDrawerManager.clearMessagesForRoom(A_SESSION_ID, A_ROOM_ID) + defaultNotificationDrawerManager.clearMessagesForRoom(A_SESSION_ID, A_ROOM_ID) defaultNotificationDrawerManager.clearMembershipNotificationForSession(A_SESSION_ID) - defaultNotificationDrawerManager.clearMembershipNotificationForRoom(A_SESSION_ID, A_ROOM_ID, doRender = true) - defaultNotificationDrawerManager.clearMembershipNotificationForRoom(A_SESSION_ID, A_ROOM_ID, doRender = false) + defaultNotificationDrawerManager.clearMembershipNotificationForRoom(A_SESSION_ID, A_ROOM_ID) + defaultNotificationDrawerManager.clearMembershipNotificationForRoom(A_SESSION_ID, A_ROOM_ID) defaultNotificationDrawerManager.onNotifiableEventReceived(aNotifiableMessageEvent()) // Add the same Event again (will be ignored) defaultNotificationDrawerManager.onNotifiableEventReceived(aNotifiableMessageEvent()) @@ -104,33 +127,93 @@ class DefaultNotificationDrawerManagerTest { defaultNotificationDrawerManager.destroy() } + @Test + fun `when MatrixClient has no cached user name a fallback one is used to render the notification`() = runTest { + val matrixClient = FakeMatrixClient(userDisplayName = null) + val matrixClientProvider = FakeMatrixClientProvider(getClient = { Result.success(matrixClient) }) + val messageCreator = FakeRoomGroupMessageCreator() + val defaultNotificationDrawerManager = createDefaultNotificationDrawerManager( + matrixClientProvider = matrixClientProvider, + roomGroupMessageCreator = messageCreator, + ) + // Gets a display name from MatrixClient.getUserProfile + matrixClient.givenGetProfileResult(A_SESSION_ID, Result.success(aMatrixUser(id = A_SESSION_ID.value, displayName = "alice"))) + defaultNotificationDrawerManager.onNotifiableEventReceived(aNotifiableMessageEvent()) + + // Uses the user id as a fallback value since display name is blank + matrixClient.givenGetProfileResult(A_SESSION_ID, Result.success(aMatrixUser(id = A_SESSION_ID.value, displayName = ""))) + defaultNotificationDrawerManager.onNotifiableEventReceived(aNotifiableMessageEvent()) + + // Uses the user id as a fallback value since the result fails + matrixClient.givenGetProfileResult(A_SESSION_ID, Result.failure(IllegalStateException("Failed to get profile"))) + defaultNotificationDrawerManager.onNotifiableEventReceived(aNotifiableMessageEvent()) + + messageCreator.createRoomMessageResult.assertions() + .isCalledExactly(3) + .withSequence( + listOf(value(aMatrixUser(id = A_SESSION_ID.value, displayName = "alice")), any(), any(), any(), any()), + listOf(value(aMatrixUser(id = A_SESSION_ID.value, displayName = A_SESSION_ID.value)), any(), any(), any(), any()), + listOf(value(aMatrixUser(id = A_SESSION_ID.value, displayName = A_SESSION_ID.value, avatarUrl = AN_AVATAR_URL)), any(), any(), any(), any()), + ) + + defaultNotificationDrawerManager.destroy() + } + + @Test + fun `clearSummaryNotificationIfNeeded will run after clearing all other notifications`() = runTest { + val notificationManager = mockk { + every { cancel(any(), any()) } returns Unit + } + val summaryId = NotificationIdProvider().getSummaryNotificationId(A_SESSION_ID) + val activeNotificationsProvider = FakeActiveNotificationsProvider( + mutableListOf( + mockk { + every { id } returns summaryId + } + ) + ) + val defaultNotificationDrawerManager = createDefaultNotificationDrawerManager( + notificationManager = notificationManager, + activeNotificationsProvider = activeNotificationsProvider, + ) + + // Ask to clear all existing message notifications. Since only the summary notification is left, it should be cleared + defaultNotificationDrawerManager.clearAllMessagesEvents(A_SESSION_ID) + + // Verify we asked to cancel the notification with summaryId + verify { notificationManager.cancel(null, summaryId) } + + defaultNotificationDrawerManager.destroy() + } + private fun TestScope.createDefaultNotificationDrawerManager( + notificationManager: NotificationManagerCompat = NotificationManagerCompat.from(RuntimeEnvironment.getApplication()), appNavigationStateService: AppNavigationStateService = FakeAppNavigationStateService(), - initialData: List = emptyList() + roomGroupMessageCreator: RoomGroupMessageCreator = FakeRoomGroupMessageCreator(), + summaryGroupMessageCreator: SummaryGroupMessageCreator = FakeSummaryGroupMessageCreator(), + activeNotificationsProvider: FakeActiveNotificationsProvider = FakeActiveNotificationsProvider(), + matrixClientProvider: FakeMatrixClientProvider = FakeMatrixClientProvider(), ): DefaultNotificationDrawerManager { val context = RuntimeEnvironment.getApplication() return DefaultNotificationDrawerManager( - notifiableEventProcessor = NotifiableEventProcessor( - outdatedDetector = OutdatedEventDetector(), - appNavigationStateService = appNavigationStateService - ), + notificationManager = notificationManager, notificationRenderer = NotificationRenderer( notificationIdProvider = NotificationIdProvider(), - notificationDisplayer = NotificationDisplayer(context), - notificationFactory = NotificationFactory( - notificationCreator = MockkNotificationCreator().instance, - roomGroupMessageCreator = MockkRoomGroupMessageCreator().instance, - summaryGroupMessageCreator = MockkSummaryGroupMessageCreator().instance, - ) + notificationDisplayer = DefaultNotificationDisplayer(context, NotificationManagerCompat.from(context)), + notificationDataFactory = DefaultNotificationDataFactory( + notificationCreator = FakeNotificationCreator(), + roomGroupMessageCreator = roomGroupMessageCreator, + summaryGroupMessageCreator = summaryGroupMessageCreator, + activeNotificationsProvider = activeNotificationsProvider, + stringProvider = FakeStringProvider(), + ), ), - notificationEventPersistence = InMemoryNotificationEventPersistence(initialData = initialData), - filteredEventDetector = FilteredEventDetector(), + notificationIdProvider = NotificationIdProvider(), appNavigationStateService = appNavigationStateService, coroutineScope = this, - dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true), - buildMeta = aBuildMeta(), - matrixClientProvider = FakeMatrixClientProvider(), + matrixClientProvider = matrixClientProvider, imageLoaderHolder = FakeImageLoaderHolder(), + activeNotificationsProvider = activeNotificationsProvider, ) } } diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationEventPersistenceTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationEventPersistenceTest.kt deleted file mode 100644 index ce984c712f..0000000000 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationEventPersistenceTest.kt +++ /dev/null @@ -1,64 +0,0 @@ -/* - * Copyright (c) 2023 New Vector Ltd - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package io.element.android.libraries.push.impl.notifications - -import com.google.common.truth.Truth.assertThat -import io.element.android.libraries.core.cache.CircularCache -import io.element.android.libraries.push.impl.notifications.fixtures.aSimpleNotifiableEvent -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -import org.robolectric.RuntimeEnvironment - -@RunWith(RobolectricTestRunner::class) -class DefaultNotificationEventPersistenceTest { - @Test - fun `loadEvents should return empty NotificationEventQueue`() { - val sut = createDefaultNotificationEventPersistence() - val result = sut.loadEvents( - factory = { rawEvents -> - NotificationEventQueue(rawEvents.toMutableList(), seenEventIds = CircularCache.create(cacheSize = 25)) - } - ) - assertThat(result.isEmpty()).isTrue() - } - - @Test - fun `after persisting NotificationEventQueue, loadEvents should return non-empty NotificationEventQueue`() { - val sut = createDefaultNotificationEventPersistence() - val notificationEventQueue = NotificationEventQueue(mutableListOf(), seenEventIds = CircularCache.create(cacheSize = 25)) - // First persist an empty queue - sut.persistEvents(notificationEventQueue) - // Add an event - notificationEventQueue.add(aSimpleNotifiableEvent()) - // Persist - // Note: is cannot work because AndroidKeyStore is not available. But we check that the code does - // not crash. - sut.persistEvents(notificationEventQueue) - sut.loadEvents( - factory = { rawEvents -> - NotificationEventQueue(rawEvents.toMutableList(), seenEventIds = CircularCache.create(cacheSize = 25)) - } - ) - // assertThat(result.isEmpty()).isFalse() - } - - private fun createDefaultNotificationEventPersistence(): DefaultNotificationEventPersistence { - val context = RuntimeEnvironment.getApplication() - return DefaultNotificationEventPersistence(context) - } -} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/RoomGroupMessageCreatorTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultRoomGroupMessageCreatorTest.kt similarity index 69% rename from libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/RoomGroupMessageCreatorTest.kt rename to libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultRoomGroupMessageCreatorTest.kt index 399b0fc4b3..8a8b5efd43 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/RoomGroupMessageCreatorTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultRoomGroupMessageCreatorTest.kt @@ -18,7 +18,7 @@ package io.element.android.libraries.push.impl.notifications import android.content.Context import android.os.Build -import coil.annotation.ExperimentalCoilApi +import androidx.core.app.NotificationCompat import com.google.common.truth.Truth.assertThat import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.test.A_ROOM_ID @@ -42,7 +42,7 @@ private const val A_USER_AVATAR_1 = "mxc://userAvatar1" private const val A_USER_AVATAR_2 = "mxc://userAvatar2" @RunWith(RobolectricTestRunner::class) -class RoomGroupMessageCreatorTest { +class DefaultRoomGroupMessageCreatorTest { @Test fun `test createRoomMessage with one Event`() = runTest { val sut = createRoomGroupMessageCreator() @@ -56,19 +56,12 @@ class RoomGroupMessageCreatorTest { ), roomId = A_ROOM_ID, imageLoader = fakeImageLoader.getImageLoader(), + existingNotification = null, ) - val resultMetaWithoutFormatting = result.meta.copy( - summaryLine = result.meta.summaryLine.toString() - ) - assertThat(resultMetaWithoutFormatting).isEqualTo( - RoomNotification.Message.Meta( - roomId = A_ROOM_ID, - summaryLine = "room-name: sender-name message-body", - messageCount = 1, - latestTimestamp = A_TIMESTAMP, - shouldBing = false, - ) - ) + assertThat(result.number).isEqualTo(1) + @Suppress("DEPRECATION") + assertThat(result.priority).isEqualTo(NotificationCompat.PRIORITY_LOW) + assertThat(result.`when`).isEqualTo(A_TIMESTAMP) assertThat(fakeImageLoader.getCoilRequests().size).isEqualTo(0) } @@ -85,19 +78,10 @@ class RoomGroupMessageCreatorTest { ), roomId = A_ROOM_ID, imageLoader = fakeImageLoader.getImageLoader(), + existingNotification = null, ) - val resultMetaWithoutFormatting = result.meta.copy( - summaryLine = result.meta.summaryLine.toString() - ) - assertThat(resultMetaWithoutFormatting).isEqualTo( - RoomNotification.Message.Meta( - roomId = A_ROOM_ID, - summaryLine = "room-name: sender-name message-body", - messageCount = 1, - latestTimestamp = A_TIMESTAMP, - shouldBing = true, - ) - ) + @Suppress("DEPRECATION") + assertThat(result.priority).isEqualTo(NotificationCompat.PRIORITY_DEFAULT) assertThat(fakeImageLoader.getCoilRequests().size).isEqualTo(0) } @@ -115,7 +99,6 @@ class RoomGroupMessageCreatorTest { ) } - @OptIn(ExperimentalCoilApi::class) @Test fun `test createRoomMessage with room avatar and sender avatar android P`() = runTest { `test createRoomMessage with room avatar and sender avatar`( @@ -138,7 +121,6 @@ class RoomGroupMessageCreatorTest { ) } - @OptIn(ExperimentalCoilApi::class) private fun `test createRoomMessage with room avatar and sender avatar`( api: Int, expectedCoilRequests: List, @@ -160,20 +142,10 @@ class RoomGroupMessageCreatorTest { ), roomId = A_ROOM_ID, imageLoader = fakeImageLoader.getImageLoader(), + existingNotification = null, ) - val resultMetaWithoutFormatting = result.meta.copy( - summaryLine = result.meta.summaryLine.toString() - ) - assertThat(resultMetaWithoutFormatting).isEqualTo( - RoomNotification.Message.Meta( - roomId = A_ROOM_ID, - summaryLine = "room-name: sender-name message-body", - messageCount = 1, - latestTimestamp = A_TIMESTAMP, - shouldBing = false, - ) - ) - assertThat(fakeImageLoader.getCoilRequests()).isEqualTo(expectedCoilRequests) + assertThat(result.number).isEqualTo(1) + assertThat(fakeImageLoader.getCoilRequests()).containsExactlyElementsIn(expectedCoilRequests) } @Test @@ -188,19 +160,10 @@ class RoomGroupMessageCreatorTest { ), roomId = A_ROOM_ID, imageLoader = fakeImageLoader.getImageLoader(), + existingNotification = null, ) - val resultMetaWithoutFormatting = result.meta.copy( - summaryLine = result.meta.summaryLine.toString() - ) - assertThat(resultMetaWithoutFormatting).isEqualTo( - RoomNotification.Message.Meta( - roomId = A_ROOM_ID, - summaryLine = "room-name: 2 messages", - messageCount = 2, - latestTimestamp = A_TIMESTAMP + 10, - shouldBing = false, - ) - ) + assertThat(result.number).isEqualTo(2) + assertThat(result.`when`).isEqualTo(A_TIMESTAMP + 10) assertThat(fakeImageLoader.getCoilRequests().size).isEqualTo(0) } @@ -218,19 +181,9 @@ class RoomGroupMessageCreatorTest { ), roomId = A_ROOM_ID, imageLoader = fakeImageLoader.getImageLoader(), + existingNotification = null, ) - val resultMetaWithoutFormatting = result.meta.copy( - summaryLine = result.meta.summaryLine.toString() - ) - assertThat(resultMetaWithoutFormatting).isEqualTo( - RoomNotification.Message.Meta( - roomId = A_ROOM_ID, - summaryLine = "room-name: sender-name message-body", - messageCount = 0, - latestTimestamp = A_TIMESTAMP, - shouldBing = false, - ) - ) + assertThat(result.actions).isNull() assertThat(fakeImageLoader.getCoilRequests().size).isEqualTo(0) } @@ -247,19 +200,10 @@ class RoomGroupMessageCreatorTest { ), roomId = A_ROOM_ID, imageLoader = fakeImageLoader.getImageLoader(), + existingNotification = null, ) - val resultMetaWithoutFormatting = result.meta.copy( - summaryLine = result.meta.summaryLine.toString() - ) - assertThat(resultMetaWithoutFormatting).isEqualTo( - RoomNotification.Message.Meta( - roomId = A_ROOM_ID, - summaryLine = "sender-name: message-body", - messageCount = 1, - latestTimestamp = A_TIMESTAMP, - shouldBing = false, - ) - ) + assertThat(result.number).isEqualTo(1) + assertThat(result.`when`).isEqualTo(A_TIMESTAMP) assertThat(fakeImageLoader.getCoilRequests().size).isEqualTo(0) } } @@ -268,12 +212,13 @@ fun createRoomGroupMessageCreator( sdkIntProvider: BuildVersionSdkIntProvider = FakeBuildVersionSdkIntProvider(Build.VERSION_CODES.O), ): RoomGroupMessageCreator { val context = RuntimeEnvironment.getApplication() as Context - return RoomGroupMessageCreator( - notificationCreator = createNotificationCreator(), - bitmapLoader = NotificationBitmapLoader( - context = RuntimeEnvironment.getApplication(), - sdkIntProvider = sdkIntProvider, - ), + val bitmapLoader = NotificationBitmapLoader( + context = RuntimeEnvironment.getApplication(), + sdkIntProvider = sdkIntProvider, + ) + return DefaultRoomGroupMessageCreator( + notificationCreator = createNotificationCreator(bitmapLoader = bitmapLoader), + bitmapLoader = bitmapLoader, stringProvider = AndroidStringProvider(context.resources) ) } diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultSummaryGroupMessageCreatorTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultSummaryGroupMessageCreatorTest.kt new file mode 100644 index 0000000000..190ae9330a --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultSummaryGroupMessageCreatorTest.kt @@ -0,0 +1,106 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.push.impl.notifications + +import android.app.Notification +import androidx.core.app.NotificationCompat +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.matrix.ui.components.aMatrixUser +import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationCreator +import io.element.android.services.toolbox.test.strings.FakeStringProvider +import io.element.android.services.toolbox.test.systemclock.A_FAKE_TIMESTAMP +import io.element.android.tests.testutils.lambda.any +import io.element.android.tests.testutils.lambda.nonNull +import io.element.android.tests.testutils.lambda.value +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class DefaultSummaryGroupMessageCreatorTest { + @Test + fun `process notifications with complete format`() = runTest { + val notificationCreator = FakeNotificationCreator() + val summaryCreator = DefaultSummaryGroupMessageCreator( + stringProvider = FakeStringProvider(), + notificationCreator = notificationCreator, + ) + + val result = summaryCreator.createSummaryNotification( + currentUser = aMatrixUser(), + roomNotifications = listOf( + RoomNotification( + notification = Notification(), + roomId = A_ROOM_ID, + summaryLine = "", + messageCount = 1, + latestTimestamp = A_FAKE_TIMESTAMP + 10, + shouldBing = true, + ) + ), + invitationNotifications = emptyList(), + simpleNotifications = emptyList(), + fallbackNotifications = emptyList(), + useCompleteNotificationFormat = true, + ) + + notificationCreator.createSummaryListNotificationResult.assertions() + .isCalledOnce() + .with(any(), nonNull(), any(), any(), any()) + + // Set from the events included + @Suppress("DEPRECATION") + assertThat(result.priority).isEqualTo(NotificationCompat.PRIORITY_DEFAULT) + } + + @Test + fun `process notifications without complete format`() = runTest { + val notificationCreator = FakeNotificationCreator() + val summaryCreator = DefaultSummaryGroupMessageCreator( + stringProvider = FakeStringProvider(), + notificationCreator = notificationCreator, + ) + + val result = summaryCreator.createSummaryNotification( + currentUser = aMatrixUser(), + roomNotifications = listOf( + RoomNotification( + notification = Notification(), + roomId = A_ROOM_ID, + summaryLine = "", + messageCount = 1, + latestTimestamp = A_FAKE_TIMESTAMP + 10, + shouldBing = true, + ) + ), + invitationNotifications = emptyList(), + simpleNotifications = emptyList(), + fallbackNotifications = emptyList(), + useCompleteNotificationFormat = false, + ) + + notificationCreator.createSummaryListNotificationResult.assertions() + .isCalledOnce() + .with(any(), value(null), any(), any(), any()) + + // Set from the events included + @Suppress("DEPRECATION") + assertThat(result.priority).isEqualTo(NotificationCompat.PRIORITY_DEFAULT) + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/InMemoryNotificationEventPersistence.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/FakeNotifiableEventResolver.kt similarity index 52% rename from libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/InMemoryNotificationEventPersistence.kt rename to libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/FakeNotifiableEventResolver.kt index 09f907f50b..a9ccabaa6d 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/InMemoryNotificationEventPersistence.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/FakeNotifiableEventResolver.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 New Vector Ltd + * Copyright (c) 2024 New Vector Ltd * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,18 +16,16 @@ package io.element.android.libraries.push.impl.notifications +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.push.impl.notifications.model.NotifiableEvent +import io.element.android.tests.testutils.lambda.lambdaError -class InMemoryNotificationEventPersistence( - initialData: List = emptyList() -) : NotificationEventPersistence { - private var data: List = initialData - - override fun loadEvents(factory: (List) -> NotificationEventQueue): NotificationEventQueue { - return factory(data) - } - - override fun persistEvents(queuedEvents: NotificationEventQueue) { - data = queuedEvents.rawEvents() +class FakeNotifiableEventResolver( + private val notifiableEventResult: (SessionId, RoomId, EventId) -> NotifiableEvent? = { _, _, _ -> lambdaError() } +) : NotifiableEventResolver { + override suspend fun resolveEvent(sessionId: SessionId, roomId: RoomId, eventId: EventId): NotifiableEvent? { + return notifiableEventResult(sessionId, roomId, eventId) } } diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventProcessorTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventProcessorTest.kt deleted file mode 100644 index a8626766e5..0000000000 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventProcessorTest.kt +++ /dev/null @@ -1,220 +0,0 @@ -/* - * Copyright (c) 2021 New Vector Ltd - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package io.element.android.libraries.push.impl.notifications - -import com.google.common.truth.Truth.assertThat -import io.element.android.libraries.matrix.api.timeline.item.event.EventType -import io.element.android.libraries.matrix.test.AN_EVENT_ID -import io.element.android.libraries.matrix.test.AN_EVENT_ID_2 -import io.element.android.libraries.matrix.test.A_ROOM_ID -import io.element.android.libraries.matrix.test.A_ROOM_ID_2 -import io.element.android.libraries.matrix.test.A_SESSION_ID -import io.element.android.libraries.matrix.test.A_SPACE_ID -import io.element.android.libraries.matrix.test.A_THREAD_ID -import io.element.android.libraries.push.impl.notifications.fake.MockkOutdatedEventDetector -import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent -import io.element.android.libraries.push.impl.notifications.fixtures.aSimpleNotifiableEvent -import io.element.android.libraries.push.impl.notifications.fixtures.anInviteNotifiableEvent -import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent -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.element.android.services.appnavstate.test.aNavigationState -import kotlinx.coroutines.flow.MutableStateFlow -import org.junit.Test - -private val NOT_VIEWING_A_ROOM = aNavigationState() -private val VIEWING_A_ROOM = aNavigationState(A_SESSION_ID, A_SPACE_ID, A_ROOM_ID) -private val VIEWING_A_THREAD = aNavigationState(A_SESSION_ID, A_SPACE_ID, A_ROOM_ID, A_THREAD_ID) - -class NotifiableEventProcessorTest { - private val mockkOutdatedDetector = MockkOutdatedEventDetector() - - @Test - fun `given simple events when processing then keep simple events`() { - val events = listOf( - aSimpleNotifiableEvent(eventId = AN_EVENT_ID), - aSimpleNotifiableEvent(eventId = AN_EVENT_ID_2) - ) - val eventProcessor = createProcessor(navigationState = NOT_VIEWING_A_ROOM) - - val result = eventProcessor.process(events, renderedEvents = emptyList()) - - assertThat(result).isEqualTo( - listOfProcessedEvents( - ProcessedEvent.Type.KEEP to events[0], - ProcessedEvent.Type.KEEP to events[1] - ) - ) - } - - @Test - fun `given redacted simple event when processing then remove redaction event`() { - val events = listOf(aSimpleNotifiableEvent(eventId = AN_EVENT_ID, type = EventType.REDACTION)) - val eventProcessor = createProcessor(navigationState = NOT_VIEWING_A_ROOM) - - val result = eventProcessor.process(events, renderedEvents = emptyList()) - - assertThat(result).isEqualTo( - listOfProcessedEvents( - ProcessedEvent.Type.REMOVE to events[0] - ) - ) - } - - @Test - fun `given invites are not auto accepted when processing then keep invitation events`() { - val events = listOf( - anInviteNotifiableEvent(roomId = A_ROOM_ID), - anInviteNotifiableEvent(roomId = A_ROOM_ID_2) - ) - val eventProcessor = createProcessor(navigationState = NOT_VIEWING_A_ROOM) - - val result = eventProcessor.process(events, renderedEvents = emptyList()) - - assertThat(result).isEqualTo( - listOfProcessedEvents( - ProcessedEvent.Type.KEEP to events[0], - ProcessedEvent.Type.KEEP to events[1] - ) - ) - } - - @Test - fun `given out of date message event when processing then removes message event`() { - val events = listOf(aNotifiableMessageEvent(eventId = AN_EVENT_ID, roomId = A_ROOM_ID)) - mockkOutdatedDetector.givenEventIsOutOfDate(events[0]) - - val eventProcessor = createProcessor(navigationState = NOT_VIEWING_A_ROOM) - - val result = eventProcessor.process(events, renderedEvents = emptyList()) - - assertThat(result).isEqualTo( - listOfProcessedEvents( - ProcessedEvent.Type.REMOVE to events[0], - ) - ) - } - - @Test - fun `given in date message event when processing then keep message event`() { - val events = listOf(aNotifiableMessageEvent(eventId = AN_EVENT_ID, roomId = A_ROOM_ID)) - mockkOutdatedDetector.givenEventIsInDate(events[0]) - val eventProcessor = createProcessor(navigationState = NOT_VIEWING_A_ROOM) - - val result = eventProcessor.process(events, renderedEvents = emptyList()) - - assertThat(result).isEqualTo( - listOfProcessedEvents( - ProcessedEvent.Type.KEEP to events[0], - ) - ) - } - - @Test - fun `given viewing the same room main timeline when processing main timeline message event then removes message`() { - val events = listOf(aNotifiableMessageEvent(eventId = AN_EVENT_ID, roomId = A_ROOM_ID, threadId = null)) - events.forEach { mockkOutdatedDetector.givenEventIsOutOfDate(it) } - val eventProcessor = createProcessor(isInForeground = true, navigationState = VIEWING_A_ROOM) - - val result = eventProcessor.process(events, renderedEvents = emptyList()) - - assertThat(result).isEqualTo( - listOfProcessedEvents( - ProcessedEvent.Type.REMOVE to events[0], - ) - ) - } - - @Test - fun `given viewing the same thread timeline when processing thread message event then removes message`() { - val events = listOf(aNotifiableMessageEvent(eventId = AN_EVENT_ID, roomId = A_ROOM_ID, threadId = A_THREAD_ID)) - events.forEach { mockkOutdatedDetector.givenEventIsOutOfDate(it) } - val eventProcessor = createProcessor(isInForeground = true, navigationState = VIEWING_A_THREAD) - - val result = eventProcessor.process(events, renderedEvents = emptyList()) - - assertThat(result).isEqualTo( - listOfProcessedEvents( - ProcessedEvent.Type.REMOVE to events[0], - ) - ) - } - - @Test - fun `given viewing main timeline of the same room when processing thread timeline message event then keep message`() { - val events = listOf(aNotifiableMessageEvent(eventId = AN_EVENT_ID, roomId = A_ROOM_ID, threadId = A_THREAD_ID)) - mockkOutdatedDetector.givenEventIsInDate(events[0]) - val eventProcessor = createProcessor(isInForeground = true, navigationState = VIEWING_A_ROOM) - - val result = eventProcessor.process(events, renderedEvents = emptyList()) - - assertThat(result).isEqualTo( - listOfProcessedEvents( - ProcessedEvent.Type.KEEP to events[0], - ) - ) - } - - @Test - fun `given viewing thread timeline of the same room when processing main timeline message event then keep message`() { - val events = listOf(aNotifiableMessageEvent(eventId = AN_EVENT_ID, roomId = A_ROOM_ID)) - mockkOutdatedDetector.givenEventIsInDate(events[0]) - val eventProcessor = createProcessor(isInForeground = true, navigationState = VIEWING_A_THREAD) - - val result = eventProcessor.process(events, renderedEvents = emptyList()) - - assertThat(result).isEqualTo( - listOfProcessedEvents( - ProcessedEvent.Type.KEEP to events[0], - ) - ) - } - - @Test - fun `given events are different to rendered events when processing then removes difference`() { - val events = listOf(aSimpleNotifiableEvent(eventId = AN_EVENT_ID)) - val renderedEvents = listOf>( - ProcessedEvent(ProcessedEvent.Type.KEEP, events[0]), - ProcessedEvent(ProcessedEvent.Type.KEEP, anInviteNotifiableEvent(eventId = AN_EVENT_ID_2)) - ) - val eventProcessor = createProcessor(navigationState = NOT_VIEWING_A_ROOM) - - val result = eventProcessor.process(events, renderedEvents = renderedEvents) - - assertThat(result).isEqualTo( - listOfProcessedEvents( - ProcessedEvent.Type.REMOVE to renderedEvents[1].event, - ProcessedEvent.Type.KEEP to renderedEvents[0].event - ) - ) - } - - private fun listOfProcessedEvents(vararg event: Pair) = event.map { - ProcessedEvent(it.first, it.second) - } - - private fun createProcessor( - isInForeground: Boolean = false, - navigationState: NavigationState - ): NotifiableEventProcessor { - return NotifiableEventProcessor( - outdatedDetector = mockkOutdatedDetector.instance, - appNavigationStateService = FakeAppNavigationStateService(MutableStateFlow(AppNavigationState(navigationState, isInForeground))), - ) - } -} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDataFactoryTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDataFactoryTest.kt new file mode 100644 index 0000000000..aff5de7d4b --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationDataFactoryTest.kt @@ -0,0 +1,186 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.push.impl.notifications + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.test.AN_EVENT_ID +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.push.impl.notifications.fake.FakeActiveNotificationsProvider +import io.element.android.libraries.push.impl.notifications.fake.FakeImageLoader +import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationCreator +import io.element.android.libraries.push.impl.notifications.fake.FakeRoomGroupMessageCreator +import io.element.android.libraries.push.impl.notifications.fake.FakeSummaryGroupMessageCreator +import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent +import io.element.android.libraries.push.impl.notifications.fixtures.aSimpleNotifiableEvent +import io.element.android.libraries.push.impl.notifications.fixtures.anInviteNotifiableEvent +import io.element.android.services.toolbox.test.strings.FakeStringProvider +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +private val MY_AVATAR_URL: String? = null +private val AN_INVITATION_EVENT = anInviteNotifiableEvent(roomId = A_ROOM_ID) +private val A_SIMPLE_EVENT = aSimpleNotifiableEvent(eventId = AN_EVENT_ID) +private val A_MESSAGE_EVENT = aNotifiableMessageEvent(eventId = AN_EVENT_ID, roomId = A_ROOM_ID) + +@RunWith(RobolectricTestRunner::class) +class NotificationDataFactoryTest { + private val notificationCreator = FakeNotificationCreator() + private val fakeRoomGroupMessageCreator = FakeRoomGroupMessageCreator() + private val fakeSummaryGroupMessageCreator = FakeSummaryGroupMessageCreator() + private val activeNotificationsProvider = FakeActiveNotificationsProvider() + + private val notificationDataFactory = DefaultNotificationDataFactory( + notificationCreator = notificationCreator, + roomGroupMessageCreator = fakeRoomGroupMessageCreator, + summaryGroupMessageCreator = fakeSummaryGroupMessageCreator, + activeNotificationsProvider = activeNotificationsProvider, + stringProvider = FakeStringProvider(), + ) + + @Test + fun `given a room invitation when mapping to notification then it's added`() = testWith(notificationDataFactory) { + val expectedNotification = notificationCreator.createRoomInvitationNotificationResult(AN_INVITATION_EVENT) + val roomInvitation = listOf(AN_INVITATION_EVENT) + + val result = toNotifications(roomInvitation) + + assertThat(result).isEqualTo( + listOf( + OneShotNotification( + notification = expectedNotification, + key = A_ROOM_ID.value, + summaryLine = AN_INVITATION_EVENT.description, + isNoisy = AN_INVITATION_EVENT.noisy, + timestamp = AN_INVITATION_EVENT.timestamp + ) + ) + ) + } + + @Test + fun `given a simple event when mapping to notification then it's added`() = testWith(notificationDataFactory) { + val expectedNotification = notificationCreator.createRoomInvitationNotificationResult(AN_INVITATION_EVENT) + val roomInvitation = listOf(A_SIMPLE_EVENT) + + val result = toNotifications(roomInvitation) + + assertThat(result).isEqualTo( + listOf( + OneShotNotification( + notification = expectedNotification, + key = AN_EVENT_ID.value, + summaryLine = A_SIMPLE_EVENT.description, + isNoisy = A_SIMPLE_EVENT.noisy, + timestamp = AN_INVITATION_EVENT.timestamp + ) + ) + ) + } + + @Test + fun `given room with message when mapping to notification then delegates to room group message creator`() = testWith(notificationDataFactory) { + val events = listOf(A_MESSAGE_EVENT) + val expectedNotification = RoomNotification( + notification = fakeRoomGroupMessageCreator.createRoomMessage( + MatrixUser(A_SESSION_ID, A_SESSION_ID.value, MY_AVATAR_URL), + events, + A_ROOM_ID, + FakeImageLoader().getImageLoader(), + null, + ), + roomId = A_ROOM_ID, + summaryLine = "room-name: sender-name message-body", + messageCount = events.size, + latestTimestamp = events.maxOf { it.timestamp }, + shouldBing = events.any { it.noisy } + ) + val roomWithMessage = listOf(A_MESSAGE_EVENT) + + val fakeImageLoader = FakeImageLoader() + val result = toNotifications( + messages = roomWithMessage, + currentUser = MatrixUser(A_SESSION_ID, A_SESSION_ID.value, MY_AVATAR_URL), + imageLoader = fakeImageLoader.getImageLoader(), + ) + + assertThat(result.size).isEqualTo(1) + assertThat(result.first().isDataEqualTo(expectedNotification)).isTrue() + assertThat(fakeImageLoader.getCoilRequests().size).isEqualTo(0) + } + + @Test + fun `given a room with only redacted events when mapping to notification then is Empty`() = testWith(notificationDataFactory) { + val redactedRoom = listOf(A_MESSAGE_EVENT.copy(isRedacted = true)) + + val fakeImageLoader = FakeImageLoader() + val result = toNotifications( + messages = redactedRoom, + currentUser = MatrixUser(A_SESSION_ID, A_SESSION_ID.value, MY_AVATAR_URL), + imageLoader = fakeImageLoader.getImageLoader(), + ) + + assertThat(result).isEmpty() + assertThat(fakeImageLoader.getCoilRequests().size).isEqualTo(0) + } + + @Test + fun `given a room with redacted and non redacted message events when mapping to notification then redacted events are removed`() = testWith( + notificationDataFactory + ) { + val roomWithRedactedMessage = listOf( + A_MESSAGE_EVENT.copy(isRedacted = true), + A_MESSAGE_EVENT.copy(eventId = EventId("\$not-redacted")), + ) + val withRedactedRemoved = listOf(A_MESSAGE_EVENT.copy(eventId = EventId("\$not-redacted"))) + val expectedNotification = RoomNotification( + notification = fakeRoomGroupMessageCreator.createRoomMessage( + MatrixUser(A_SESSION_ID, A_SESSION_ID.value, MY_AVATAR_URL), + withRedactedRemoved, + A_ROOM_ID, + FakeImageLoader().getImageLoader(), + null, + ), + roomId = A_ROOM_ID, + summaryLine = "room-name: sender-name message-body", + messageCount = withRedactedRemoved.size, + latestTimestamp = withRedactedRemoved.maxOf { it.timestamp }, + shouldBing = withRedactedRemoved.any { it.noisy } + ) + + val fakeImageLoader = FakeImageLoader() + val result = toNotifications( + messages = roomWithRedactedMessage, + currentUser = MatrixUser(A_SESSION_ID, A_SESSION_ID.value, MY_AVATAR_URL), + imageLoader = fakeImageLoader.getImageLoader(), + ) + + assertThat(result.size).isEqualTo(1) + assertThat(result.first().isDataEqualTo(expectedNotification)).isTrue() + assertThat(fakeImageLoader.getCoilRequests().size).isEqualTo(0) + } +} + +fun testWith(receiver: T, block: suspend T.() -> Unit) { + runTest { + receiver.block() + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationEventQueueTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationEventQueueTest.kt deleted file mode 100644 index 7cc2687207..0000000000 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationEventQueueTest.kt +++ /dev/null @@ -1,230 +0,0 @@ -/* - * Copyright (c) 2021 New Vector Ltd - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package io.element.android.libraries.push.impl.notifications - -import com.google.common.truth.Truth.assertThat -import io.element.android.libraries.core.cache.CircularCache -import io.element.android.libraries.matrix.api.core.EventId -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.push.impl.notifications.fixtures.aNotifiableMessageEvent -import io.element.android.libraries.push.impl.notifications.fixtures.aSimpleNotifiableEvent -import io.element.android.libraries.push.impl.notifications.fixtures.anInviteNotifiableEvent -import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent -import org.junit.Test - -class NotificationEventQueueTest { - private val seenIdsCache = CircularCache.create(5) - - @Test - fun `given events when redacting some then marks matching event ids as redacted`() { - val queue = givenQueue( - listOf( - aSimpleNotifiableEvent(eventId = EventId("\$redacted-id-1")), - aNotifiableMessageEvent(eventId = EventId("\$redacted-id-2")), - anInviteNotifiableEvent(eventId = EventId("\$redacted-id-3")), - aSimpleNotifiableEvent(eventId = EventId("\$kept-id")), - ) - ) - - queue.markRedacted(listOf(EventId("\$redacted-id-1"), EventId("\$redacted-id-2"), EventId("\$redacted-id-3"))) - - assertThat(queue.rawEvents()).isEqualTo( - listOf( - aSimpleNotifiableEvent(eventId = EventId("\$redacted-id-1"), isRedacted = true), - aNotifiableMessageEvent(eventId = EventId("\$redacted-id-2"), isRedacted = true), - anInviteNotifiableEvent(eventId = EventId("\$redacted-id-3"), isRedacted = true), - aSimpleNotifiableEvent(eventId = EventId("\$kept-id"), isRedacted = false), - ) - ) - } - - @Test - fun `given invite event when leaving invited room and syncing then removes event`() { - val queue = givenQueue(listOf(anInviteNotifiableEvent(roomId = A_ROOM_ID))) - val roomsLeft = listOf(A_ROOM_ID) - - queue.syncRoomEvents(roomsLeft = roomsLeft, roomsJoined = emptyList()) - - assertThat(queue.rawEvents()).isEmpty() - } - - @Test - fun `given invite event when joining invited room and syncing then removes event`() { - val queue = givenQueue(listOf(anInviteNotifiableEvent(roomId = A_ROOM_ID))) - val joinedRooms = listOf(A_ROOM_ID) - - queue.syncRoomEvents(roomsLeft = emptyList(), roomsJoined = joinedRooms) - - assertThat(queue.rawEvents()).isEmpty() - } - - @Test - fun `given message event when leaving message room and syncing then removes event`() { - val queue = givenQueue(listOf(aNotifiableMessageEvent(roomId = A_ROOM_ID))) - val roomsLeft = listOf(A_ROOM_ID) - - queue.syncRoomEvents(roomsLeft = roomsLeft, roomsJoined = emptyList()) - - assertThat(queue.rawEvents()).isEmpty() - } - - @Test - fun `given events when syncing without rooms left or joined ids then does not change the events`() { - val queue = givenQueue( - listOf( - aNotifiableMessageEvent(roomId = A_ROOM_ID), - anInviteNotifiableEvent(roomId = A_ROOM_ID) - ) - ) - - queue.syncRoomEvents(roomsLeft = emptyList(), roomsJoined = emptyList()) - - assertThat(queue.rawEvents()).isEqualTo( - listOf( - aNotifiableMessageEvent(roomId = A_ROOM_ID), - anInviteNotifiableEvent(roomId = A_ROOM_ID) - ) - ) - } - - @Test - fun `given events then is not empty`() { - val queue = givenQueue(listOf(aSimpleNotifiableEvent())) - - assertThat(queue.isEmpty()).isFalse() - } - - @Test - fun `given no events then is empty`() { - val queue = givenQueue(emptyList()) - - assertThat(queue.isEmpty()).isTrue() - } - - @Test - fun `given events when clearing and adding then removes previous events and adds only new events`() { - val queue = givenQueue(listOf(aSimpleNotifiableEvent())) - - queue.clearAndAdd(listOf(anInviteNotifiableEvent())) - - assertThat(queue.rawEvents()).isEqualTo(listOf(anInviteNotifiableEvent())) - } - - @Test - fun `when clearing then is empty`() { - val queue = givenQueue(listOf(aSimpleNotifiableEvent())) - - queue.clear() - - assertThat(queue.rawEvents()).isEmpty() - } - - @Test - fun `given no events when adding then adds event`() { - val queue = givenQueue(listOf()) - - queue.add(aSimpleNotifiableEvent()) - - assertThat(queue.rawEvents()).isEqualTo(listOf(aSimpleNotifiableEvent())) - } - - @Test - fun `given no events when adding already seen event then ignores event`() { - val queue = givenQueue(listOf()) - val notifiableEvent = aSimpleNotifiableEvent() - seenIdsCache.put(notifiableEvent.eventId) - - queue.add(notifiableEvent) - - assertThat(queue.rawEvents()).isEmpty() - } - - @Test - fun `given replaceable event when adding event with same id then updates existing event`() { - val replaceableEvent = aSimpleNotifiableEvent(canBeReplaced = true) - val updatedEvent = replaceableEvent.copy(title = "updated title", isUpdated = true) - val queue = givenQueue(listOf(replaceableEvent)) - - queue.add(updatedEvent) - - assertThat(queue.rawEvents()).isEqualTo(listOf(updatedEvent)) - } - - @Test - fun `given non replaceable event when adding event with same id then ignores event`() { - val nonReplaceableEvent = aSimpleNotifiableEvent(canBeReplaced = false) - val updatedEvent = nonReplaceableEvent.copy(title = "updated title") - val queue = givenQueue(listOf(nonReplaceableEvent)) - - queue.add(updatedEvent) - - assertThat(queue.rawEvents()).isEqualTo(listOf(nonReplaceableEvent)) - } - - @Test - fun `given event when adding new event with edited event id matching the existing event id then updates existing event`() { - val editedEvent = aSimpleNotifiableEvent(eventId = EventId("\$id-to-edit")) - val updatedEvent = editedEvent.copy(eventId = EventId("\$1"), editedEventId = EventId("\$id-to-edit"), title = "updated title", isUpdated = true) - val queue = givenQueue(listOf(editedEvent)) - - queue.add(updatedEvent) - - assertThat(queue.rawEvents()).isEqualTo(listOf(updatedEvent)) - } - - @Test - fun `given event when adding new event with edited event id matching the existing event edited id then updates existing event`() { - val editedEvent = aSimpleNotifiableEvent(eventId = EventId("\$0"), editedEventId = EventId("\$id-to-edit")) - val updatedEvent = editedEvent.copy(eventId = EventId("\$1"), editedEventId = EventId("\$id-to-edit"), title = "updated title", isUpdated = true) - val queue = givenQueue(listOf(editedEvent)) - - queue.add(updatedEvent) - - assertThat(queue.rawEvents()).isEqualTo(listOf(updatedEvent)) - } - - @Test - fun `when clearing membership notification then removes invite events with matching room id`() { - val queue = givenQueue( - listOf( - anInviteNotifiableEvent(roomId = A_ROOM_ID), - aNotifiableMessageEvent(roomId = A_ROOM_ID) - ) - ) - - queue.clearMembershipNotificationForRoom(A_SESSION_ID, A_ROOM_ID) - - assertThat(queue.rawEvents()).isEqualTo(listOf(aNotifiableMessageEvent(roomId = A_ROOM_ID))) - } - - @Test - fun `when clearing messages for room then removes message events with matching room id`() { - val queue = givenQueue( - listOf( - anInviteNotifiableEvent(roomId = A_ROOM_ID), - aNotifiableMessageEvent(roomId = A_ROOM_ID) - ) - ) - - queue.clearMessagesForRoom(A_SESSION_ID, A_ROOM_ID) - - assertThat(queue.rawEvents()).isEqualTo(listOf(anInviteNotifiableEvent(roomId = A_ROOM_ID))) - } - - private fun givenQueue(events: List) = NotificationEventQueue(events.toMutableList(), seenEventIds = seenIdsCache) -} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationFactoryTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationFactoryTest.kt deleted file mode 100644 index 6a211fc446..0000000000 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationFactoryTest.kt +++ /dev/null @@ -1,221 +0,0 @@ -/* - * Copyright (c) 2021 New Vector Ltd - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package io.element.android.libraries.push.impl.notifications - -import com.google.common.truth.Truth.assertThat -import io.element.android.libraries.matrix.api.core.EventId -import io.element.android.libraries.matrix.api.user.MatrixUser -import io.element.android.libraries.matrix.test.AN_EVENT_ID -import io.element.android.libraries.matrix.test.A_ROOM_ID -import io.element.android.libraries.matrix.test.A_SESSION_ID -import io.element.android.libraries.push.impl.notifications.fake.FakeImageLoader -import io.element.android.libraries.push.impl.notifications.fake.MockkNotificationCreator -import io.element.android.libraries.push.impl.notifications.fake.MockkRoomGroupMessageCreator -import io.element.android.libraries.push.impl.notifications.fake.MockkSummaryGroupMessageCreator -import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent -import io.element.android.libraries.push.impl.notifications.fixtures.aSimpleNotifiableEvent -import io.element.android.libraries.push.impl.notifications.fixtures.anInviteNotifiableEvent -import kotlinx.coroutines.test.runTest -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner - -private val MY_AVATAR_URL: String? = null -private val AN_INVITATION_EVENT = anInviteNotifiableEvent(roomId = A_ROOM_ID) -private val A_SIMPLE_EVENT = aSimpleNotifiableEvent(eventId = AN_EVENT_ID) -private val A_MESSAGE_EVENT = aNotifiableMessageEvent(eventId = AN_EVENT_ID, roomId = A_ROOM_ID) - -@RunWith(RobolectricTestRunner::class) -class NotificationFactoryTest { - private val mockkNotificationCreator = MockkNotificationCreator() - private val mockkRoomGroupMessageCreator = MockkRoomGroupMessageCreator() - private val mockkSummaryGroupMessageCreator = MockkSummaryGroupMessageCreator() - - private val notificationFactory = NotificationFactory( - notificationCreator = mockkNotificationCreator.instance, - roomGroupMessageCreator = mockkRoomGroupMessageCreator.instance, - summaryGroupMessageCreator = mockkSummaryGroupMessageCreator.instance - ) - - @Test - fun `given a room invitation when mapping to notification then is Append`() = testWith(notificationFactory) { - val expectedNotification = mockkNotificationCreator.givenCreateRoomInvitationNotificationFor(AN_INVITATION_EVENT) - val roomInvitation = listOf(ProcessedEvent(ProcessedEvent.Type.KEEP, AN_INVITATION_EVENT)) - - val result = roomInvitation.toNotifications() - - assertThat(result).isEqualTo( - listOf( - OneShotNotification.Append( - notification = expectedNotification, - meta = OneShotNotification.Append.Meta( - key = A_ROOM_ID.value, - summaryLine = AN_INVITATION_EVENT.description, - isNoisy = AN_INVITATION_EVENT.noisy, - timestamp = AN_INVITATION_EVENT.timestamp - ) - ) - ) - ) - } - - @Test - fun `given a missing event in room invitation when mapping to notification then is Removed`() = testWith(notificationFactory) { - val missingEventRoomInvitation = listOf(ProcessedEvent(ProcessedEvent.Type.REMOVE, AN_INVITATION_EVENT)) - - val result = missingEventRoomInvitation.toNotifications() - - assertThat(result).isEqualTo( - listOf( - OneShotNotification.Removed( - key = A_ROOM_ID.value - ) - ) - ) - } - - @Test - fun `given a simple event when mapping to notification then is Append`() = testWith(notificationFactory) { - val expectedNotification = mockkNotificationCreator.givenCreateSimpleInvitationNotificationFor(A_SIMPLE_EVENT) - val roomInvitation = listOf(ProcessedEvent(ProcessedEvent.Type.KEEP, A_SIMPLE_EVENT)) - - val result = roomInvitation.toNotifications() - - assertThat(result).isEqualTo( - listOf( - OneShotNotification.Append( - notification = expectedNotification, - meta = OneShotNotification.Append.Meta( - key = AN_EVENT_ID.value, - summaryLine = A_SIMPLE_EVENT.description, - isNoisy = A_SIMPLE_EVENT.noisy, - timestamp = AN_INVITATION_EVENT.timestamp - ) - ) - ) - ) - } - - @Test - fun `given a missing simple event when mapping to notification then is Removed`() = testWith(notificationFactory) { - val missingEventRoomInvitation = listOf(ProcessedEvent(ProcessedEvent.Type.REMOVE, A_SIMPLE_EVENT)) - - val result = missingEventRoomInvitation.toNotifications() - - assertThat(result).isEqualTo( - listOf( - OneShotNotification.Removed( - key = AN_EVENT_ID.value - ) - ) - ) - } - - @Test - fun `given room with message when mapping to notification then delegates to room group message creator`() = testWith(notificationFactory) { - val events = listOf(A_MESSAGE_EVENT) - val expectedNotification = mockkRoomGroupMessageCreator.givenCreatesRoomMessageFor( - MatrixUser(A_SESSION_ID, A_SESSION_ID.value, MY_AVATAR_URL), - events, - A_ROOM_ID - ) - val roomWithMessage = mapOf(A_ROOM_ID to listOf(ProcessedEvent(ProcessedEvent.Type.KEEP, A_MESSAGE_EVENT))) - - val fakeImageLoader = FakeImageLoader() - val result = roomWithMessage.toNotifications( - currentUser = MatrixUser(A_SESSION_ID, A_SESSION_ID.value, MY_AVATAR_URL), - imageLoader = fakeImageLoader.getImageLoader(), - ) - - assertThat(result).isEqualTo(listOf(expectedNotification)) - assertThat(fakeImageLoader.getCoilRequests().size).isEqualTo(0) - } - - @Test - fun `given a room with no events to display when mapping to notification then is Empty`() = testWith(notificationFactory) { - val events = listOf(ProcessedEvent(ProcessedEvent.Type.REMOVE, A_MESSAGE_EVENT)) - val emptyRoom = mapOf(A_ROOM_ID to events) - - val fakeImageLoader = FakeImageLoader() - val result = emptyRoom.toNotifications( - currentUser = MatrixUser(A_SESSION_ID, A_SESSION_ID.value, MY_AVATAR_URL), - imageLoader = fakeImageLoader.getImageLoader(), - ) - - assertThat(result).isEqualTo( - listOf( - RoomNotification.Removed( - roomId = A_ROOM_ID - ) - ) - ) - assertThat(fakeImageLoader.getCoilRequests().size).isEqualTo(0) - } - - @Test - fun `given a room with only redacted events when mapping to notification then is Empty`() = testWith(notificationFactory) { - val redactedRoom = mapOf(A_ROOM_ID to listOf(ProcessedEvent(ProcessedEvent.Type.KEEP, A_MESSAGE_EVENT.copy(isRedacted = true)))) - - val fakeImageLoader = FakeImageLoader() - val result = redactedRoom.toNotifications( - currentUser = MatrixUser(A_SESSION_ID, A_SESSION_ID.value, MY_AVATAR_URL), - imageLoader = fakeImageLoader.getImageLoader(), - ) - - assertThat(result).isEqualTo( - listOf( - RoomNotification.Removed( - roomId = A_ROOM_ID - ) - ) - ) - assertThat(fakeImageLoader.getCoilRequests().size).isEqualTo(0) - } - - @Test - fun `given a room with redacted and non redacted message events when mapping to notification then redacted events are removed`() = testWith( - notificationFactory - ) { - val roomWithRedactedMessage = mapOf( - A_ROOM_ID to listOf( - ProcessedEvent(ProcessedEvent.Type.KEEP, A_MESSAGE_EVENT.copy(isRedacted = true)), - ProcessedEvent(ProcessedEvent.Type.KEEP, A_MESSAGE_EVENT.copy(eventId = EventId("\$not-redacted"))) - ) - ) - val withRedactedRemoved = listOf(A_MESSAGE_EVENT.copy(eventId = EventId("\$not-redacted"))) - val expectedNotification = mockkRoomGroupMessageCreator.givenCreatesRoomMessageFor( - MatrixUser(A_SESSION_ID, A_SESSION_ID.value, MY_AVATAR_URL), - withRedactedRemoved, - A_ROOM_ID, - ) - - val fakeImageLoader = FakeImageLoader() - val result = roomWithRedactedMessage.toNotifications( - currentUser = MatrixUser(A_SESSION_ID, A_SESSION_ID.value, MY_AVATAR_URL), - imageLoader = fakeImageLoader.getImageLoader(), - ) - - assertThat(result).isEqualTo(listOf(expectedNotification)) - assertThat(fakeImageLoader.getCoilRequests().size).isEqualTo(0) - } -} - -fun testWith(receiver: T, block: suspend T.() -> Unit) { - runTest { - receiver.block() - } -} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationRendererTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationRendererTest.kt index 21fc1b4fca..0a5b216ea0 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationRendererTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotificationRendererTest.kt @@ -16,16 +16,24 @@ package io.element.android.libraries.push.impl.notifications -import android.app.Notification import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.test.AN_EVENT_ID import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.push.impl.notifications.fake.FakeActiveNotificationsProvider import io.element.android.libraries.push.impl.notifications.fake.FakeImageLoader -import io.element.android.libraries.push.impl.notifications.fake.MockkNotificationDisplayer -import io.element.android.libraries.push.impl.notifications.fake.MockkNotificationFactory +import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationCreator +import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationDisplayer +import io.element.android.libraries.push.impl.notifications.fake.FakeRoomGroupMessageCreator +import io.element.android.libraries.push.impl.notifications.fake.FakeSummaryGroupMessageCreator +import io.element.android.libraries.push.impl.notifications.fixtures.A_NOTIFICATION +import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent +import io.element.android.libraries.push.impl.notifications.fixtures.aSimpleNotifiableEvent +import io.element.android.libraries.push.impl.notifications.fixtures.anInviteNotifiableEvent import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent -import io.mockk.mockk +import io.element.android.services.toolbox.test.strings.FakeStringProvider +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value import kotlinx.coroutines.test.runTest import org.junit.Test import org.junit.runner.RunWith @@ -35,201 +43,81 @@ private const val MY_USER_DISPLAY_NAME = "display-name" private const val MY_USER_AVATAR_URL = "avatar-url" private const val USE_COMPLETE_NOTIFICATION_FORMAT = true -private val AN_EVENT_LIST = listOf>() -private val A_PROCESSED_EVENTS = GroupedNotificationEvents(emptyMap(), emptyList(), emptyList(), emptyList()) -private val A_SUMMARY_NOTIFICATION = SummaryNotification.Update(mockk()) -private val A_REMOVE_SUMMARY_NOTIFICATION = SummaryNotification.Removed -private val A_NOTIFICATION = mockk() -private val MESSAGE_META = RoomNotification.Message.Meta( - summaryLine = "ignored", - messageCount = 1, - latestTimestamp = -1, - roomId = A_ROOM_ID, - shouldBing = false -) -private val ONE_SHOT_META = OneShotNotification.Append.Meta(key = "ignored", summaryLine = "ignored", isNoisy = false, timestamp = -1) +private val A_SUMMARY_NOTIFICATION = SummaryNotification.Update(A_NOTIFICATION) +private val ONE_SHOT_NOTIFICATION = + OneShotNotification(notification = A_NOTIFICATION, key = "ignored", summaryLine = "ignored", isNoisy = false, timestamp = -1) @RunWith(RobolectricTestRunner::class) class NotificationRendererTest { - private val mockkNotificationDisplayer = MockkNotificationDisplayer() - private val mockkNotificationFactory = MockkNotificationFactory() + private val notificationDisplayer = FakeNotificationDisplayer() + + private val notificationCreator = FakeNotificationCreator() + private val roomGroupMessageCreator = FakeRoomGroupMessageCreator() + private val summaryGroupMessageCreator = FakeSummaryGroupMessageCreator() + private val notificationDataFactory = DefaultNotificationDataFactory( + notificationCreator = notificationCreator, + roomGroupMessageCreator = roomGroupMessageCreator, + summaryGroupMessageCreator = summaryGroupMessageCreator, + activeNotificationsProvider = FakeActiveNotificationsProvider(), + stringProvider = FakeStringProvider(), + ) private val notificationIdProvider = NotificationIdProvider() private val notificationRenderer = NotificationRenderer( notificationIdProvider = notificationIdProvider, - notificationDisplayer = mockkNotificationDisplayer.instance, - notificationFactory = mockkNotificationFactory.instance, + notificationDisplayer = notificationDisplayer, + notificationDataFactory = notificationDataFactory, ) @Test fun `given no notifications when rendering then cancels summary notification`() = runTest { - givenNoNotifications() + renderEventsAsNotifications(emptyList()) - renderEventsAsNotifications() - - mockkNotificationDisplayer.verifySummaryCancelled() - mockkNotificationDisplayer.verifyNoOtherInteractions() - } - - @Test - fun `given last room message group notification is removed when rendering then remove the summary and then remove message notification`() = runTest { - givenNotifications(roomNotifications = listOf(RoomNotification.Removed(A_ROOM_ID)), summaryNotification = A_REMOVE_SUMMARY_NOTIFICATION) - - renderEventsAsNotifications() - - mockkNotificationDisplayer.verifyInOrder { - cancelNotificationMessage(tag = null, notificationIdProvider.getSummaryNotificationId(A_SESSION_ID)) - cancelNotificationMessage(tag = A_ROOM_ID.value, notificationIdProvider.getRoomMessagesNotificationId(A_SESSION_ID)) - } - } - - @Test - fun `given a room message group notification is removed when rendering then remove the message notification and update summary`() = runTest { - givenNotifications(roomNotifications = listOf(RoomNotification.Removed(A_ROOM_ID))) - - renderEventsAsNotifications() - - mockkNotificationDisplayer.verifyInOrder { - cancelNotificationMessage(tag = A_ROOM_ID.value, notificationIdProvider.getRoomMessagesNotificationId(A_SESSION_ID)) - showNotificationMessage(tag = null, notificationIdProvider.getSummaryNotificationId(A_SESSION_ID), A_SUMMARY_NOTIFICATION.notification) - } + notificationDisplayer.verifySummaryCancelled() } @Test fun `given a room message group notification is added when rendering then show the message notification and update summary`() = runTest { - givenNotifications( - roomNotifications = listOf( - RoomNotification.Message( - A_NOTIFICATION, - MESSAGE_META - ) - ) + roomGroupMessageCreator.createRoomMessageResult = lambdaRecorder { _, _, _, _, _ -> A_NOTIFICATION } + + renderEventsAsNotifications(listOf(aNotifiableMessageEvent())) + + notificationDisplayer.showNotificationMessageResult.assertions().isCalledExactly(2).withSequence( + listOf(value(A_ROOM_ID.value), value(notificationIdProvider.getRoomMessagesNotificationId(A_SESSION_ID)), value(A_NOTIFICATION)), + listOf(value(null), value(notificationIdProvider.getSummaryNotificationId(A_SESSION_ID)), value(A_SUMMARY_NOTIFICATION.notification)) ) - - renderEventsAsNotifications() - - mockkNotificationDisplayer.verifyInOrder { - showNotificationMessage(tag = A_ROOM_ID.value, notificationIdProvider.getRoomMessagesNotificationId(A_SESSION_ID), A_NOTIFICATION) - showNotificationMessage(tag = null, notificationIdProvider.getSummaryNotificationId(A_SESSION_ID), A_SUMMARY_NOTIFICATION.notification) - } - } - - @Test - fun `given last simple notification is removed when rendering then remove the summary and then remove simple notification`() = runTest { - givenNotifications(simpleNotifications = listOf(OneShotNotification.Removed(AN_EVENT_ID.value)), summaryNotification = A_REMOVE_SUMMARY_NOTIFICATION) - - renderEventsAsNotifications() - - mockkNotificationDisplayer.verifyInOrder { - cancelNotificationMessage(tag = null, notificationIdProvider.getSummaryNotificationId(A_SESSION_ID)) - cancelNotificationMessage(tag = AN_EVENT_ID.value, notificationIdProvider.getRoomEventNotificationId(A_SESSION_ID)) - } - } - - @Test - fun `given a simple notification is removed when rendering then remove the simple notification and update summary`() = runTest { - givenNotifications(simpleNotifications = listOf(OneShotNotification.Removed(AN_EVENT_ID.value))) - - renderEventsAsNotifications() - - mockkNotificationDisplayer.verifyInOrder { - cancelNotificationMessage(tag = AN_EVENT_ID.value, notificationIdProvider.getRoomEventNotificationId(A_SESSION_ID)) - showNotificationMessage(tag = null, notificationIdProvider.getSummaryNotificationId(A_SESSION_ID), A_SUMMARY_NOTIFICATION.notification) - } } @Test fun `given a simple notification is added when rendering then show the simple notification and update summary`() = runTest { - givenNotifications( - simpleNotifications = listOf( - OneShotNotification.Append( - A_NOTIFICATION, - ONE_SHOT_META.copy(key = AN_EVENT_ID.value) - ) - ) + notificationCreator.createSimpleNotificationResult = lambdaRecorder { _ -> ONE_SHOT_NOTIFICATION.copy(key = AN_EVENT_ID.value).notification } + + renderEventsAsNotifications(listOf(aSimpleNotifiableEvent(eventId = AN_EVENT_ID))) + + notificationDisplayer.showNotificationMessageResult.assertions().isCalledExactly(2).withSequence( + listOf(value(AN_EVENT_ID.value), value(notificationIdProvider.getRoomEventNotificationId(A_SESSION_ID)), value(A_NOTIFICATION)), + listOf(value(null), value(notificationIdProvider.getSummaryNotificationId(A_SESSION_ID)), value(A_SUMMARY_NOTIFICATION.notification)) ) - - renderEventsAsNotifications() - - mockkNotificationDisplayer.verifyInOrder { - showNotificationMessage(tag = AN_EVENT_ID.value, notificationIdProvider.getRoomEventNotificationId(A_SESSION_ID), A_NOTIFICATION) - showNotificationMessage(tag = null, notificationIdProvider.getSummaryNotificationId(A_SESSION_ID), A_SUMMARY_NOTIFICATION.notification) - } - } - - @Test - fun `given last invitation notification is removed when rendering then remove the summary and then remove invitation notification`() = runTest { - givenNotifications(invitationNotifications = listOf(OneShotNotification.Removed(A_ROOM_ID.value)), summaryNotification = A_REMOVE_SUMMARY_NOTIFICATION) - - renderEventsAsNotifications() - - mockkNotificationDisplayer.verifyInOrder { - cancelNotificationMessage(tag = null, notificationIdProvider.getSummaryNotificationId(A_SESSION_ID)) - cancelNotificationMessage(tag = A_ROOM_ID.value, notificationIdProvider.getRoomInvitationNotificationId(A_SESSION_ID)) - } - } - - @Test - fun `given an invitation notification is removed when rendering then remove the invitation notification and update summary`() = runTest { - givenNotifications(invitationNotifications = listOf(OneShotNotification.Removed(A_ROOM_ID.value))) - - renderEventsAsNotifications() - - mockkNotificationDisplayer.verifyInOrder { - cancelNotificationMessage(tag = A_ROOM_ID.value, notificationIdProvider.getRoomInvitationNotificationId(A_SESSION_ID)) - showNotificationMessage(tag = null, notificationIdProvider.getSummaryNotificationId(A_SESSION_ID), A_SUMMARY_NOTIFICATION.notification) - } } @Test fun `given an invitation notification is added when rendering then show the invitation notification and update summary`() = runTest { - givenNotifications( - simpleNotifications = listOf( - OneShotNotification.Append( - A_NOTIFICATION, - ONE_SHOT_META.copy(key = A_ROOM_ID.value) - ) - ) + notificationCreator.createRoomInvitationNotificationResult = lambdaRecorder { _ -> ONE_SHOT_NOTIFICATION.copy(key = AN_EVENT_ID.value).notification } + + renderEventsAsNotifications(listOf(anInviteNotifiableEvent())) + + notificationDisplayer.showNotificationMessageResult.assertions().isCalledExactly(2).withSequence( + listOf(value(A_ROOM_ID.value), value(notificationIdProvider.getRoomInvitationNotificationId(A_SESSION_ID)), value(A_NOTIFICATION)), + listOf(value(null), value(notificationIdProvider.getSummaryNotificationId(A_SESSION_ID)), value(A_SUMMARY_NOTIFICATION.notification)) ) - - renderEventsAsNotifications() - - mockkNotificationDisplayer.verifyInOrder { - showNotificationMessage(tag = A_ROOM_ID.value, notificationIdProvider.getRoomEventNotificationId(A_SESSION_ID), A_NOTIFICATION) - showNotificationMessage(tag = null, notificationIdProvider.getSummaryNotificationId(A_SESSION_ID), A_SUMMARY_NOTIFICATION.notification) - } } - private suspend fun renderEventsAsNotifications() { + private suspend fun renderEventsAsNotifications(events: List) { notificationRenderer.render( MatrixUser(A_SESSION_ID, MY_USER_DISPLAY_NAME, MY_USER_AVATAR_URL), useCompleteNotificationFormat = USE_COMPLETE_NOTIFICATION_FORMAT, - eventsToProcess = AN_EVENT_LIST, + eventsToProcess = events, imageLoader = FakeImageLoader().getImageLoader(), ) } - - private fun givenNoNotifications() { - givenNotifications(emptyList(), emptyList(), emptyList(), emptyList(), USE_COMPLETE_NOTIFICATION_FORMAT, A_REMOVE_SUMMARY_NOTIFICATION) - } - - private fun givenNotifications( - roomNotifications: List = emptyList(), - invitationNotifications: List = emptyList(), - simpleNotifications: List = emptyList(), - fallbackNotifications: List = emptyList(), - useCompleteNotificationFormat: Boolean = USE_COMPLETE_NOTIFICATION_FORMAT, - summaryNotification: SummaryNotification = A_SUMMARY_NOTIFICATION - ) { - mockkNotificationFactory.givenNotificationsFor( - groupedEvents = A_PROCESSED_EVENTS, - matrixUser = MatrixUser(A_SESSION_ID, MY_USER_DISPLAY_NAME, MY_USER_AVATAR_URL), - useCompleteNotificationFormat = useCompleteNotificationFormat, - roomNotifications = roomNotifications, - invitationNotifications = invitationNotifications, - simpleNotifications = simpleNotifications, - fallbackNotifications = fallbackNotifications, - summaryNotification = summaryNotification - ) - } } diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/factories/NotificationCreatorTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/factories/DefaultNotificationCreatorTest.kt similarity index 84% rename from libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/factories/NotificationCreatorTest.kt rename to libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/factories/DefaultNotificationCreatorTest.kt index edf87fcd1a..7af19d66a7 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/factories/NotificationCreatorTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/factories/DefaultNotificationCreatorTest.kt @@ -18,8 +18,9 @@ package io.element.android.libraries.push.impl.notifications.factories import android.app.Notification import android.content.Context +import android.os.Build import androidx.core.app.NotificationCompat -import androidx.core.app.Person +import androidx.core.app.NotificationManagerCompat import com.google.common.truth.Truth.assertThat import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.matrix.test.AN_EVENT_ID @@ -29,23 +30,29 @@ import io.element.android.libraries.matrix.test.A_THREAD_ID import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.libraries.matrix.ui.components.aMatrixUser import io.element.android.libraries.push.impl.notifications.NotificationActionIds +import io.element.android.libraries.push.impl.notifications.NotificationBitmapLoader import io.element.android.libraries.push.impl.notifications.RoomEventGroupInfo import io.element.android.libraries.push.impl.notifications.channels.NotificationChannels +import io.element.android.libraries.push.impl.notifications.factories.action.AcceptInvitationActionFactory import io.element.android.libraries.push.impl.notifications.factories.action.MarkAsReadActionFactory import io.element.android.libraries.push.impl.notifications.factories.action.QuickReplyActionFactory +import io.element.android.libraries.push.impl.notifications.factories.action.RejectInvitationActionFactory +import io.element.android.libraries.push.impl.notifications.fake.FakeImageLoader import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent +import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvider import io.element.android.services.toolbox.test.strings.FakeStringProvider import io.element.android.services.toolbox.test.systemclock.A_FAKE_TIMESTAMP import io.element.android.services.toolbox.test.systemclock.FakeSystemClock +import kotlinx.coroutines.test.runTest import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner import org.robolectric.RuntimeEnvironment @RunWith(RobolectricTestRunner::class) -class NotificationCreatorTest { +class DefaultNotificationCreatorTest { @Test fun `test createDiagnosticNotification`() { val sut = createNotificationCreator() @@ -212,15 +219,10 @@ class NotificationCreatorTest { } @Test - fun `test createMessagesListNotification`() { + fun `test createMessagesListNotification`() = runTest { val sut = createNotificationCreator() aMatrixUser() val result = sut.createMessagesListNotification( - messageStyle = NotificationCompat.MessagingStyle( - Person.Builder() - .setName("name") - .build() - ), roomInfo = RoomEventGroupInfo( sessionId = A_SESSION_ID, roomId = A_ROOM_ID, @@ -235,20 +237,19 @@ class NotificationCreatorTest { largeIcon = null, lastMessageTimestamp = 123_456L, tickerText = "tickerText", + currentUser = aMatrixUser(), + existingNotification = null, + imageLoader = FakeImageLoader().getImageLoader(), + events = emptyList(), ) result.commonAssertions() } @Test - fun `test createMessagesListNotification should bing and thread`() { + fun `test createMessagesListNotification should bing and thread`() = runTest { val sut = createNotificationCreator() aMatrixUser() val result = sut.createMessagesListNotification( - messageStyle = NotificationCompat.MessagingStyle( - Person.Builder() - .setName("name") - .build() - ), roomInfo = RoomEventGroupInfo( sessionId = A_SESSION_ID, roomId = A_ROOM_ID, @@ -263,6 +264,10 @@ class NotificationCreatorTest { largeIcon = null, lastMessageTimestamp = 123_456L, tickerText = "tickerText", + currentUser = aMatrixUser(), + existingNotification = null, + imageLoader = FakeImageLoader().getImageLoader(), + events = emptyList(), ) result.commonAssertions() } @@ -280,9 +285,10 @@ class NotificationCreatorTest { fun createNotificationCreator( context: Context = RuntimeEnvironment.getApplication(), buildMeta: BuildMeta = aBuildMeta(), - notificationChannels: NotificationChannels = createNotificationChannels() + notificationChannels: NotificationChannels = createNotificationChannels(), + bitmapLoader: NotificationBitmapLoader = NotificationBitmapLoader(context, FakeBuildVersionSdkIntProvider(Build.VERSION_CODES.R)), ): NotificationCreator { - return NotificationCreator( + return DefaultNotificationCreator( context = context, notificationChannels = notificationChannels, stringProvider = FakeStringProvider("test"), @@ -305,10 +311,23 @@ fun createNotificationCreator( stringProvider = FakeStringProvider("QuickReplyActionFactory"), clock = FakeSystemClock(), ), + bitmapLoader = bitmapLoader, + acceptInvitationActionFactory = AcceptInvitationActionFactory( + context = context, + actionIds = NotificationActionIds(buildMeta), + stringProvider = FakeStringProvider("AcceptInvitationActionFactory"), + clock = FakeSystemClock(), + ), + rejectInvitationActionFactory = RejectInvitationActionFactory( + context = context, + actionIds = NotificationActionIds(buildMeta), + stringProvider = FakeStringProvider("RejectInvitationActionFactory"), + clock = FakeSystemClock(), + ), ) } fun createNotificationChannels(): NotificationChannels { val context = RuntimeEnvironment.getApplication() - return NotificationChannels(context, FakeStringProvider("")) + return NotificationChannels(context, NotificationManagerCompat.from(context), FakeStringProvider("")) } diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeActiveNotificationsProvider.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeActiveNotificationsProvider.kt new file mode 100644 index 0000000000..680688d3dc --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeActiveNotificationsProvider.kt @@ -0,0 +1,54 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.push.impl.notifications.fake + +import android.service.notification.StatusBarNotification +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.push.impl.notifications.ActiveNotificationsProvider + +class FakeActiveNotificationsProvider( + var activeNotifications: MutableList = mutableListOf(), +) : ActiveNotificationsProvider { + override fun getAllNotifications(): List { + return activeNotifications + } + + override fun getMessageNotificationsForRoom(sessionId: SessionId, roomId: RoomId): List { + return activeNotifications + } + + override fun getNotificationsForSession(sessionId: SessionId): List { + return activeNotifications + } + + override fun getMembershipNotificationForSession(sessionId: SessionId): List { + return activeNotifications + } + + override fun getMembershipNotificationForRoom(sessionId: SessionId, roomId: RoomId): List { + return activeNotifications + } + + override fun getSummaryNotification(sessionId: SessionId): StatusBarNotification? { + return activeNotifications.firstOrNull() + } + + override fun count(sessionId: SessionId): Int { + return activeNotifications.size + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeNotificationCreator.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeNotificationCreator.kt new file mode 100644 index 0000000000..bf20fef14e --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeNotificationCreator.kt @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.push.impl.notifications.fake + +import android.app.Notification +import android.graphics.Bitmap +import androidx.core.app.NotificationCompat +import coil.ImageLoader +import io.element.android.libraries.matrix.api.core.ThreadId +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.push.impl.notifications.RoomEventGroupInfo +import io.element.android.libraries.push.impl.notifications.factories.NotificationCreator +import io.element.android.libraries.push.impl.notifications.fixtures.A_NOTIFICATION +import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent +import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent +import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent +import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent +import io.element.android.tests.testutils.lambda.LambdaFiveParamsRecorder +import io.element.android.tests.testutils.lambda.LambdaListAnyParamsRecorder +import io.element.android.tests.testutils.lambda.LambdaNoParamRecorder +import io.element.android.tests.testutils.lambda.LambdaOneParamRecorder +import io.element.android.tests.testutils.lambda.lambdaAnyRecorder +import io.element.android.tests.testutils.lambda.lambdaRecorder + +class FakeNotificationCreator( + var createMessagesListNotificationResult: LambdaListAnyParamsRecorder = lambdaAnyRecorder { A_NOTIFICATION }, + var createRoomInvitationNotificationResult: LambdaOneParamRecorder = lambdaRecorder { _ -> A_NOTIFICATION }, + var createSimpleNotificationResult: LambdaOneParamRecorder = lambdaRecorder { _ -> A_NOTIFICATION }, + var createFallbackNotificationResult: LambdaOneParamRecorder = lambdaRecorder { _ -> A_NOTIFICATION }, + var createSummaryListNotificationResult: LambdaFiveParamsRecorder = + lambdaRecorder { _, _, _, _, _ -> A_NOTIFICATION }, + var createDiagnosticNotificationResult: LambdaNoParamRecorder = lambdaRecorder { -> A_NOTIFICATION }, +) : NotificationCreator { + override suspend fun createMessagesListNotification( + roomInfo: RoomEventGroupInfo, + threadId: ThreadId?, + largeIcon: Bitmap?, + lastMessageTimestamp: Long, + tickerText: String, + currentUser: MatrixUser, + existingNotification: Notification?, + imageLoader: ImageLoader, + events: List + ): Notification { + return createMessagesListNotificationResult( + listOf(roomInfo, threadId, largeIcon, lastMessageTimestamp, tickerText, currentUser, existingNotification, imageLoader, events) + ) + } + + override fun createRoomInvitationNotification(inviteNotifiableEvent: InviteNotifiableEvent): Notification { + return createRoomInvitationNotificationResult(inviteNotifiableEvent) + } + + override fun createSimpleEventNotification(simpleNotifiableEvent: SimpleNotifiableEvent): Notification { + return createSimpleNotificationResult(simpleNotifiableEvent) + } + + override fun createFallbackNotification(fallbackNotifiableEvent: FallbackNotifiableEvent): Notification { + return createFallbackNotificationResult(fallbackNotifiableEvent) + } + + override fun createSummaryListNotification( + currentUser: MatrixUser, + style: NotificationCompat.InboxStyle?, + compatSummary: String, + noisy: Boolean, + lastMessageTimestamp: Long + ): Notification { + return createSummaryListNotificationResult(currentUser, style, compatSummary, noisy, lastMessageTimestamp) + } + + override fun createDiagnosticNotification(): Notification { + return createDiagnosticNotificationResult() + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeNotificationDataFactory.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeNotificationDataFactory.kt new file mode 100644 index 0000000000..6cf4196bb5 --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeNotificationDataFactory.kt @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.push.impl.notifications.fake + +import coil.ImageLoader +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.push.impl.notifications.NotificationDataFactory +import io.element.android.libraries.push.impl.notifications.OneShotNotification +import io.element.android.libraries.push.impl.notifications.RoomNotification +import io.element.android.libraries.push.impl.notifications.SummaryNotification +import io.element.android.libraries.push.impl.notifications.fixtures.A_NOTIFICATION +import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent +import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent +import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent +import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent +import io.element.android.tests.testutils.lambda.LambdaOneParamRecorder +import io.element.android.tests.testutils.lambda.LambdaSixParamsRecorder +import io.element.android.tests.testutils.lambda.LambdaThreeParamsRecorder +import io.element.android.tests.testutils.lambda.lambdaRecorder + +class FakeNotificationDataFactory( + var messageEventToNotificationsResult: LambdaThreeParamsRecorder, MatrixUser, ImageLoader, List> = + lambdaRecorder { _, _, _ -> emptyList() }, + var summaryToNotificationsResult: LambdaSixParamsRecorder< + MatrixUser, + List, + List, + List, + List, + Boolean, + SummaryNotification + > = lambdaRecorder { _, _, _, _, _, _ -> SummaryNotification.Update(A_NOTIFICATION) }, + var inviteToNotificationsResult: LambdaOneParamRecorder, List> = lambdaRecorder { _ -> emptyList() }, + var simpleEventToNotificationsResult: LambdaOneParamRecorder, List> = lambdaRecorder { _ -> emptyList() }, + var fallbackEventToNotificationsResult: LambdaOneParamRecorder, List> = + lambdaRecorder { _ -> emptyList() }, +) : NotificationDataFactory { + override suspend fun toNotifications(messages: List, currentUser: MatrixUser, imageLoader: ImageLoader): List { + return messageEventToNotificationsResult(messages, currentUser, imageLoader) + } + + @JvmName("toNotificationInvites") + @Suppress("INAPPLICABLE_JVM_NAME") + override fun toNotifications(invites: List): List { + return inviteToNotificationsResult(invites) + } + + @JvmName("toNotificationSimpleEvents") + @Suppress("INAPPLICABLE_JVM_NAME") + override fun toNotifications(simpleEvents: List): List { + return simpleEventToNotificationsResult(simpleEvents) + } + + override fun toNotifications(fallback: List): List { + return fallbackEventToNotificationsResult(fallback) + } + + override fun createSummaryNotification( + currentUser: MatrixUser, + roomNotifications: List, + invitationNotifications: List, + simpleNotifications: List, + fallbackNotifications: List, + useCompleteNotificationFormat: Boolean + ): SummaryNotification { + return summaryToNotificationsResult( + currentUser, + roomNotifications, + invitationNotifications, + simpleNotifications, + fallbackNotifications, + useCompleteNotificationFormat + ) + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeNotificationDisplayer.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeNotificationDisplayer.kt new file mode 100644 index 0000000000..c8c041720c --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeNotificationDisplayer.kt @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.push.impl.notifications.fake + +import android.app.Notification +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.push.impl.notifications.NotificationDisplayer +import io.element.android.libraries.push.impl.notifications.NotificationIdProvider +import io.element.android.tests.testutils.lambda.LambdaNoParamRecorder +import io.element.android.tests.testutils.lambda.LambdaOneParamRecorder +import io.element.android.tests.testutils.lambda.LambdaThreeParamsRecorder +import io.element.android.tests.testutils.lambda.LambdaTwoParamsRecorder +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value + +class FakeNotificationDisplayer( + var showNotificationMessageResult: LambdaThreeParamsRecorder = lambdaRecorder { _, _, _ -> true }, + var cancelNotificationMessageResult: LambdaTwoParamsRecorder = lambdaRecorder { _, _ -> }, + var displayDiagnosticNotificationResult: LambdaOneParamRecorder = lambdaRecorder { _ -> true }, + var dismissDiagnosticNotificationResult: LambdaNoParamRecorder = lambdaRecorder { -> }, +) : NotificationDisplayer { + override fun showNotificationMessage(tag: String?, id: Int, notification: Notification): Boolean { + return showNotificationMessageResult(tag, id, notification) + } + + override fun cancelNotificationMessage(tag: String?, id: Int) { + return cancelNotificationMessageResult(tag, id) + } + + override fun displayDiagnosticNotification(notification: Notification): Boolean { + return displayDiagnosticNotificationResult(notification) + } + + override fun dismissDiagnosticNotification() { + return dismissDiagnosticNotificationResult() + } + + fun verifySummaryCancelled(times: Int = 1) { + cancelNotificationMessageResult.assertions().isCalledExactly(times).withSequence( + listOf(value(null), value(NotificationIdProvider().getSummaryNotificationId(A_SESSION_ID))) + ) + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/MockkRoomGroupMessageCreator.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeRoomGroupMessageCreator.kt similarity index 55% rename from libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/MockkRoomGroupMessageCreator.kt rename to libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeRoomGroupMessageCreator.kt index 389a4f441d..c0e1692775 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/MockkRoomGroupMessageCreator.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeRoomGroupMessageCreator.kt @@ -16,31 +16,27 @@ package io.element.android.libraries.push.impl.notifications.fake +import android.app.Notification +import coil.ImageLoader import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.push.impl.notifications.RoomGroupMessageCreator -import io.element.android.libraries.push.impl.notifications.RoomNotification +import io.element.android.libraries.push.impl.notifications.fixtures.A_NOTIFICATION import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent -import io.mockk.coEvery -import io.mockk.mockk +import io.element.android.tests.testutils.lambda.LambdaFiveParamsRecorder +import io.element.android.tests.testutils.lambda.lambdaRecorder -class MockkRoomGroupMessageCreator { - val instance = mockk() - - fun givenCreatesRoomMessageFor( - matrixUser: MatrixUser, +class FakeRoomGroupMessageCreator( + var createRoomMessageResult: LambdaFiveParamsRecorder, RoomId, ImageLoader, Notification?, Notification> = + lambdaRecorder { _, _, _, _, _, -> A_NOTIFICATION } +) : RoomGroupMessageCreator { + override suspend fun createRoomMessage( + currentUser: MatrixUser, events: List, roomId: RoomId, - ): RoomNotification.Message { - val mockMessage = mockk() - coEvery { - instance.createRoomMessage( - currentUser = matrixUser, - events = events, - roomId = roomId, - imageLoader = any(), - ) - } returns mockMessage - return mockMessage + imageLoader: ImageLoader, + existingNotification: Notification? + ): Notification { + return createRoomMessageResult(currentUser, events, roomId, imageLoader, existingNotification) } } diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeSummaryGroupMessageCreator.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeSummaryGroupMessageCreator.kt new file mode 100644 index 0000000000..08083ceb18 --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/FakeSummaryGroupMessageCreator.kt @@ -0,0 +1,51 @@ +/* + * Copyright (c) 2021 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.push.impl.notifications.fake + +import android.app.Notification +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.push.impl.notifications.OneShotNotification +import io.element.android.libraries.push.impl.notifications.RoomNotification +import io.element.android.libraries.push.impl.notifications.SummaryGroupMessageCreator +import io.element.android.libraries.push.impl.notifications.fixtures.A_NOTIFICATION +import io.element.android.tests.testutils.lambda.LambdaSixParamsRecorder +import io.element.android.tests.testutils.lambda.lambdaRecorder + +class FakeSummaryGroupMessageCreator( + var createSummaryNotificationResult: LambdaSixParamsRecorder< + MatrixUser, List, List, List, List, Boolean, Notification + > = + lambdaRecorder { _, _, _, _, _, _ -> A_NOTIFICATION } +) : SummaryGroupMessageCreator { + override fun createSummaryNotification( + currentUser: MatrixUser, + roomNotifications: List, + invitationNotifications: List, + simpleNotifications: List, + fallbackNotifications: List, + useCompleteNotificationFormat: Boolean + ): Notification { + return createSummaryNotificationResult( + currentUser, + roomNotifications, + invitationNotifications, + simpleNotifications, + fallbackNotifications, + useCompleteNotificationFormat + ) + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/MockkNotificationCreator.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/MockkNotificationCreator.kt deleted file mode 100644 index 205ba058e6..0000000000 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/MockkNotificationCreator.kt +++ /dev/null @@ -1,46 +0,0 @@ -/* - * Copyright (c) 2021 New Vector Ltd - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package io.element.android.libraries.push.impl.notifications.fake - -import android.app.Notification -import io.element.android.libraries.push.impl.notifications.factories.NotificationCreator -import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent -import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent -import io.mockk.every -import io.mockk.mockk - -class MockkNotificationCreator { - val instance = mockk() - - fun givenCreateRoomInvitationNotificationFor(event: InviteNotifiableEvent): Notification { - val mockNotification = mockk() - every { instance.createRoomInvitationNotification(event) } returns mockNotification - return mockNotification - } - - fun givenCreateSimpleInvitationNotificationFor(event: SimpleNotifiableEvent): Notification { - val mockNotification = mockk() - every { instance.createSimpleEventNotification(event) } returns mockNotification - return mockNotification - } - - fun givenCreateDiagnosticNotification(): Notification { - val mockNotification = mockk() - every { instance.createDiagnosticNotification() } returns mockNotification - return mockNotification - } -} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/MockkNotificationDisplayer.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/MockkNotificationDisplayer.kt deleted file mode 100644 index dc55cecfac..0000000000 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/MockkNotificationDisplayer.kt +++ /dev/null @@ -1,47 +0,0 @@ -/* - * Copyright (c) 2021 New Vector Ltd - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package io.element.android.libraries.push.impl.notifications.fake - -import io.element.android.libraries.matrix.test.A_SESSION_ID -import io.element.android.libraries.push.impl.notifications.NotificationDisplayer -import io.element.android.libraries.push.impl.notifications.NotificationIdProvider -import io.mockk.confirmVerified -import io.mockk.every -import io.mockk.mockk -import io.mockk.verify -import io.mockk.verifyOrder - -class MockkNotificationDisplayer { - val instance = mockk(relaxed = true) - - fun givenDisplayDiagnosticNotificationResult(result: Boolean) { - every { instance.displayDiagnosticNotification(any()) } returns result - } - - fun verifySummaryCancelled() { - verify { instance.cancelNotificationMessage(tag = null, NotificationIdProvider().getSummaryNotificationId(A_SESSION_ID)) } - } - - fun verifyNoOtherInteractions() { - confirmVerified(instance) - } - - fun verifyInOrder(verifyBlock: NotificationDisplayer.() -> Unit) { - verifyOrder { verifyBlock(instance) } - verifyNoOtherInteractions() - } -} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/MockkNotificationFactory.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/MockkNotificationFactory.kt deleted file mode 100644 index 6a8410d2cb..0000000000 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/MockkNotificationFactory.kt +++ /dev/null @@ -1,60 +0,0 @@ -/* - * Copyright (c) 2021 New Vector Ltd - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package io.element.android.libraries.push.impl.notifications.fake - -import io.element.android.libraries.matrix.api.user.MatrixUser -import io.element.android.libraries.push.impl.notifications.GroupedNotificationEvents -import io.element.android.libraries.push.impl.notifications.NotificationFactory -import io.element.android.libraries.push.impl.notifications.OneShotNotification -import io.element.android.libraries.push.impl.notifications.RoomNotification -import io.element.android.libraries.push.impl.notifications.SummaryNotification -import io.mockk.coEvery -import io.mockk.every -import io.mockk.mockk - -class MockkNotificationFactory { - val instance = mockk() - - fun givenNotificationsFor( - groupedEvents: GroupedNotificationEvents, - matrixUser: MatrixUser, - useCompleteNotificationFormat: Boolean, - roomNotifications: List, - invitationNotifications: List, - simpleNotifications: List, - fallbackNotifications: List, - summaryNotification: SummaryNotification - ) { - with(instance) { - coEvery { groupedEvents.roomEvents.toNotifications(matrixUser, any()) } returns roomNotifications - every { groupedEvents.invitationEvents.toNotifications() } returns invitationNotifications - every { groupedEvents.simpleEvents.toNotifications() } returns simpleNotifications - every { groupedEvents.fallbackEvents.toNotifications() } returns fallbackNotifications - - every { - createSummaryNotification( - matrixUser, - roomNotifications, - invitationNotifications, - simpleNotifications, - fallbackNotifications, - useCompleteNotificationFormat - ) - } returns summaryNotification - } - } -} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/MockkOutdatedEventDetector.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/MockkOutdatedEventDetector.kt deleted file mode 100644 index 414f7ae652..0000000000 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fake/MockkOutdatedEventDetector.kt +++ /dev/null @@ -1,34 +0,0 @@ -/* - * Copyright (c) 2021 New Vector Ltd - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package io.element.android.libraries.push.impl.notifications.fake - -import io.element.android.libraries.push.impl.notifications.OutdatedEventDetector -import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent -import io.mockk.every -import io.mockk.mockk - -class MockkOutdatedEventDetector { - val instance = mockk() - - fun givenEventIsOutOfDate(notifiableEvent: NotifiableEvent) { - every { instance.isMessageOutdated(notifiableEvent) } returns true - } - - fun givenEventIsInDate(notifiableEvent: NotifiableEvent) { - every { instance.isMessageOutdated(notifiableEvent) } returns false - } -} diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/Config.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fixtures/NotificationFixture.kt similarity index 66% rename from libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/Config.kt rename to libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fixtures/NotificationFixture.kt index be8c86448e..797665ea78 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/Config.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/fixtures/NotificationFixture.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 New Vector Ltd + * Copyright (c) 2024 New Vector Ltd * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,10 +14,8 @@ * limitations under the License. */ -package io.element.android.libraries.designsystem.components.preferences +package io.element.android.libraries.push.impl.notifications.fixtures -import androidx.compose.ui.unit.dp +import android.app.Notification -internal val preferenceMinHeightOnlyTitle = 56.dp -internal val preferenceMinHeight = 56.dp -internal val preferencePaddingHorizontal = 16.dp +val A_NOTIFICATION = Notification() 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 new file mode 100644 index 0000000000..7739efbd1d --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandlerTest.kt @@ -0,0 +1,268 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package io.element.android.libraries.push.impl.push + +import app.cash.turbine.test +import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService +import io.element.android.libraries.matrix.api.core.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.test.AN_EVENT_ID +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.matrix.test.A_SECRET +import io.element.android.libraries.matrix.test.A_USER_ID +import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService +import io.element.android.libraries.matrix.test.core.aBuildMeta +import io.element.android.libraries.push.impl.notifications.FakeNotifiableEventResolver +import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent +import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent +import io.element.android.libraries.push.impl.test.DefaultTestPush +import io.element.android.libraries.push.impl.troubleshoot.DiagnosticPushHandler +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 +import io.element.android.libraries.pushstore.test.userpushstore.FakeUserPushStore +import io.element.android.libraries.pushstore.test.userpushstore.FakeUserPushStoreFactory +import io.element.android.libraries.pushstore.test.userpushstore.clientsecret.FakePushClientSecret +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 kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class DefaultPushHandlerTest { + @Test + fun `when classical PushData is received, the notification drawer is informed`() = runTest { + val aNotifiableMessageEvent = aNotifiableMessageEvent() + val notifiableEventResult = + lambdaRecorder { _, _, _ -> aNotifiableMessageEvent } + val onNotifiableEventReceived = lambdaRecorder {} + val incrementPushCounterResult = lambdaRecorder {} + val aPushData = PushData( + eventId = AN_EVENT_ID, + roomId = A_ROOM_ID, + unread = 0, + clientSecret = A_SECRET, + ) + val defaultPushHandler = createDefaultPushHandler( + onNotifiableEventReceived = onNotifiableEventReceived, + notifiableEventResult = notifiableEventResult, + pushClientSecret = FakePushClientSecret( + getUserIdFromSecretResult = { A_USER_ID } + ), + incrementPushCounterResult = incrementPushCounterResult + ) + defaultPushHandler.handle(aPushData) + incrementPushCounterResult.assertions() + .isCalledOnce() + notifiableEventResult.assertions() + .isCalledOnce() + .with(value(A_USER_ID), value(A_ROOM_ID), value(AN_EVENT_ID)) + onNotifiableEventReceived.assertions() + .isCalledOnce() + .with(value(aNotifiableMessageEvent)) + } + + @Test + fun `when classical PushData is received, but notifications are disabled, nothing happen`() = + runTest { + val aNotifiableMessageEvent = aNotifiableMessageEvent() + val notifiableEventResult = + lambdaRecorder { _, _, _ -> aNotifiableMessageEvent } + val onNotifiableEventReceived = lambdaRecorder {} + val incrementPushCounterResult = lambdaRecorder {} + val aPushData = PushData( + eventId = AN_EVENT_ID, + roomId = A_ROOM_ID, + unread = 0, + clientSecret = A_SECRET, + ) + val defaultPushHandler = createDefaultPushHandler( + onNotifiableEventReceived = onNotifiableEventReceived, + notifiableEventResult = notifiableEventResult, + pushClientSecret = FakePushClientSecret( + getUserIdFromSecretResult = { A_USER_ID } + ), + userPushStore = FakeUserPushStore().apply { + setNotificationEnabledForDevice(false) + }, + incrementPushCounterResult = incrementPushCounterResult + ) + defaultPushHandler.handle(aPushData) + incrementPushCounterResult.assertions() + .isCalledOnce() + notifiableEventResult.assertions() + .isNeverCalled() + onNotifiableEventReceived.assertions() + .isNeverCalled() + } + + @Test + fun `when PushData is received, but client secret is not known, fallback the latest session`() = + runTest { + val aNotifiableMessageEvent = aNotifiableMessageEvent() + val notifiableEventResult = + lambdaRecorder { _, _, _ -> aNotifiableMessageEvent } + val onNotifiableEventReceived = lambdaRecorder {} + val incrementPushCounterResult = lambdaRecorder {} + val aPushData = PushData( + eventId = AN_EVENT_ID, + roomId = A_ROOM_ID, + unread = 0, + clientSecret = A_SECRET, + ) + val defaultPushHandler = createDefaultPushHandler( + onNotifiableEventReceived = onNotifiableEventReceived, + notifiableEventResult = notifiableEventResult, + pushClientSecret = FakePushClientSecret( + getUserIdFromSecretResult = { null } + ), + matrixAuthenticationService = FakeMatrixAuthenticationService().apply { + getLatestSessionIdLambda = { A_USER_ID } + }, + incrementPushCounterResult = incrementPushCounterResult + ) + defaultPushHandler.handle(aPushData) + incrementPushCounterResult.assertions() + .isCalledOnce() + notifiableEventResult.assertions() + .isCalledOnce() + .with(value(A_USER_ID), value(A_ROOM_ID), value(AN_EVENT_ID)) + onNotifiableEventReceived.assertions() + .isCalledOnce() + .with(value(aNotifiableMessageEvent)) + } + + @Test + fun `when PushData is received, but client secret is not known, and there is no latest session, nothing happen`() = + runTest { + val aNotifiableMessageEvent = aNotifiableMessageEvent() + val notifiableEventResult = + lambdaRecorder { _, _, _ -> aNotifiableMessageEvent } + val onNotifiableEventReceived = lambdaRecorder {} + val incrementPushCounterResult = lambdaRecorder {} + val aPushData = PushData( + eventId = AN_EVENT_ID, + roomId = A_ROOM_ID, + unread = 0, + clientSecret = A_SECRET, + ) + val defaultPushHandler = createDefaultPushHandler( + onNotifiableEventReceived = onNotifiableEventReceived, + notifiableEventResult = notifiableEventResult, + pushClientSecret = FakePushClientSecret( + getUserIdFromSecretResult = { null } + ), + matrixAuthenticationService = FakeMatrixAuthenticationService().apply { + getLatestSessionIdLambda = { null } + }, + incrementPushCounterResult = incrementPushCounterResult + ) + defaultPushHandler.handle(aPushData) + incrementPushCounterResult.assertions() + .isCalledOnce() + notifiableEventResult.assertions() + .isNeverCalled() + onNotifiableEventReceived.assertions() + .isNeverCalled() + } + + @Test + fun `when classical PushData is received, but not able to resolve the event, nothing happen`() = + runTest { + val notifiableEventResult = + lambdaRecorder { _, _, _ -> null } + val onNotifiableEventReceived = lambdaRecorder {} + val incrementPushCounterResult = lambdaRecorder {} + val aPushData = PushData( + eventId = AN_EVENT_ID, + roomId = A_ROOM_ID, + unread = 0, + clientSecret = A_SECRET, + ) + val defaultPushHandler = createDefaultPushHandler( + onNotifiableEventReceived = onNotifiableEventReceived, + notifiableEventResult = notifiableEventResult, + buildMeta = aBuildMeta( + // Also test `lowPrivacyLoggingEnabled = false` here + lowPrivacyLoggingEnabled = false + ), + pushClientSecret = FakePushClientSecret( + getUserIdFromSecretResult = { A_USER_ID } + ), + incrementPushCounterResult = incrementPushCounterResult + ) + defaultPushHandler.handle(aPushData) + incrementPushCounterResult.assertions() + .isCalledOnce() + notifiableEventResult.assertions() + .isCalledOnce() + .with(value(A_USER_ID), value(A_ROOM_ID), value(AN_EVENT_ID)) + onNotifiableEventReceived.assertions() + .isNeverCalled() + } + + @Test + fun `when diagnostic PushData is received, the diagnostic push handler is informed `() = + runTest { + val aPushData = PushData( + eventId = DefaultTestPush.TEST_EVENT_ID, + roomId = A_ROOM_ID, + unread = 0, + clientSecret = A_SECRET, + ) + val diagnosticPushHandler = DiagnosticPushHandler() + val defaultPushHandler = createDefaultPushHandler( + diagnosticPushHandler = diagnosticPushHandler, + incrementPushCounterResult = { } + ) + diagnosticPushHandler.state.test { + defaultPushHandler.handle(aPushData) + awaitItem() + } + } + + private fun createDefaultPushHandler( + onNotifiableEventReceived: (NotifiableEvent) -> Unit = { lambdaError() }, + notifiableEventResult: (SessionId, RoomId, EventId) -> NotifiableEvent? = { _, _, _ -> lambdaError() }, + incrementPushCounterResult: () -> Unit = { lambdaError() }, + userPushStore: UserPushStore = FakeUserPushStore(), + pushClientSecret: PushClientSecret = FakePushClientSecret(), + buildMeta: BuildMeta = aBuildMeta(), + matrixAuthenticationService: MatrixAuthenticationService = FakeMatrixAuthenticationService(), + diagnosticPushHandler: DiagnosticPushHandler = DiagnosticPushHandler(), + ): DefaultPushHandler { + return DefaultPushHandler( + onNotifiableEventReceived = FakeOnNotifiableEventReceived(onNotifiableEventReceived), + notifiableEventResolver = FakeNotifiableEventResolver(notifiableEventResult), + incrementPushDataStore = object : IncrementPushDataStore { + override suspend fun incrementPushCounter() { + incrementPushCounterResult() + } + }, + userPushStoreFactory = FakeUserPushStoreFactory { userPushStore }, + pushClientSecret = pushClientSecret, + buildMeta = buildMeta, + matrixAuthenticationService = matrixAuthenticationService, + diagnosticPushHandler = diagnosticPushHandler, + ) + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationEventPersistence.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/FakeOnNotifiableEventReceived.kt similarity index 63% rename from libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationEventPersistence.kt rename to libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/FakeOnNotifiableEventReceived.kt index e593f49824..3c9e025830 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotificationEventPersistence.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/FakeOnNotifiableEventReceived.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 New Vector Ltd + * Copyright (c) 2024 New Vector Ltd * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,11 +14,14 @@ * limitations under the License. */ -package io.element.android.libraries.push.impl.notifications +package io.element.android.libraries.push.impl.push import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent -interface NotificationEventPersistence { - fun loadEvents(factory: (List) -> NotificationEventQueue): NotificationEventQueue - fun persistEvents(queuedEvents: NotificationEventQueue) +class FakeOnNotifiableEventReceived( + private val onNotifiableEventReceivedResult: (NotifiableEvent) -> Unit, +) : OnNotifiableEventReceived { + override fun onNotifiableEventReceived(notifiableEvent: NotifiableEvent) { + onNotifiableEventReceivedResult(notifiableEvent) + } } diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/pushgateway/DefaultPushGatewayNotifyRequestTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/pushgateway/DefaultPushGatewayNotifyRequestTest.kt new file mode 100644 index 0000000000..ba432b4c7a --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/pushgateway/DefaultPushGatewayNotifyRequestTest.kt @@ -0,0 +1,102 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.push.impl.pushgateway + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.push.api.gateway.PushGatewayFailure +import io.element.android.libraries.push.impl.test.DefaultTestPush +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertThrows +import org.junit.Test + +class DefaultPushGatewayNotifyRequestTest { + @Test + fun `notify success`() = runTest { + val factory = FakePushGatewayApiFactory( + notifyResponse = { + PushGatewayNotifyResponse( + rejectedPushKeys = emptyList() + ) + } + ) + val pushGatewayNotifyRequest = DefaultPushGatewayNotifyRequest( + pushGatewayApiFactory = factory, + ) + pushGatewayNotifyRequest.execute( + PushGatewayNotifyRequest.Params( + url = "aUrl", + appId = "anAppId", + pushKey = "aPushKey", + eventId = DefaultTestPush.TEST_EVENT_ID, + roomId = DefaultTestPush.TEST_ROOM_ID, + ) + ) + assertThat(factory.baseUrlParameter).isEqualTo("aUrl") + } + + @Test + fun `notify success, url is stripped`() = runTest { + val factory = FakePushGatewayApiFactory( + notifyResponse = { + PushGatewayNotifyResponse( + rejectedPushKeys = emptyList() + ) + } + ) + val pushGatewayNotifyRequest = DefaultPushGatewayNotifyRequest( + pushGatewayApiFactory = factory, + ) + pushGatewayNotifyRequest.execute( + PushGatewayNotifyRequest.Params( + url = "aUrl" + PushGatewayConfig.URI_PUSH_GATEWAY_PREFIX_PATH, + appId = "anAppId", + pushKey = "aPushKey", + eventId = DefaultTestPush.TEST_EVENT_ID, + roomId = DefaultTestPush.TEST_ROOM_ID, + ) + ) + assertThat(factory.baseUrlParameter).isEqualTo("aUrl") + } + + @Test + fun `notify with rejected push key should throw expected Exception`() { + val factory = FakePushGatewayApiFactory( + notifyResponse = { + PushGatewayNotifyResponse( + rejectedPushKeys = listOf("aPushKey") + ) + } + ) + val pushGatewayNotifyRequest = DefaultPushGatewayNotifyRequest( + pushGatewayApiFactory = factory, + ) + assertThrows(PushGatewayFailure.PusherRejected::class.java) { + runTest { + pushGatewayNotifyRequest.execute( + PushGatewayNotifyRequest.Params( + url = "aUrl", + appId = "anAppId", + pushKey = "aPushKey", + eventId = DefaultTestPush.TEST_EVENT_ID, + roomId = DefaultTestPush.TEST_ROOM_ID, + ) + ) + } + } + assertThat(factory.baseUrlParameter).isEqualTo("aUrl") + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/pushgateway/FakePushGatewayApiFactory.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/pushgateway/FakePushGatewayApiFactory.kt new file mode 100644 index 0000000000..0b9730843e --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/pushgateway/FakePushGatewayApiFactory.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.push.impl.pushgateway + +class FakePushGatewayApiFactory( + private val notifyResponse: () -> PushGatewayNotifyResponse +) : PushGatewayApiFactory { + var baseUrlParameter: String? = null + private set + + override fun create(baseUrl: String): PushGatewayAPI { + baseUrlParameter = baseUrl + return FakePushGatewayAPI(notifyResponse) + } +} + +class FakePushGatewayAPI( + private val notifyResponse: () -> PushGatewayNotifyResponse +) : PushGatewayAPI { + override suspend fun notify(body: PushGatewayNotifyBody): PushGatewayNotifyResponse { + return notifyResponse() + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/test/DefaultTestPushTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/test/DefaultTestPushTest.kt new file mode 100644 index 0000000000..0ccd08df82 --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/test/DefaultTestPushTest.kt @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.push.impl.test + +import io.element.android.appconfig.PushConfig +import io.element.android.libraries.push.impl.pushgateway.PushGatewayNotifyRequest +import io.element.android.libraries.pushproviders.api.CurrentUserPushConfig +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class DefaultTestPushTest { + @Test + fun `test DefaultTestPush`() = runTest { + val executeResult = lambdaRecorder { } + val defaultTestPush = DefaultTestPush( + pushGatewayNotifyRequest = FakePushGatewayNotifyRequest( + executeResult = executeResult, + ) + ) + val aConfig = CurrentUserPushConfig( + url = "aUrl", + pushKey = "aPushKey", + ) + defaultTestPush.execute(aConfig) + executeResult.assertions() + .isCalledOnce() + .with( + value( + PushGatewayNotifyRequest.Params( + url = aConfig.url, + appId = PushConfig.PUSHER_APP_ID, + pushKey = aConfig.pushKey, + eventId = DefaultTestPush.TEST_EVENT_ID, + roomId = DefaultTestPush.TEST_ROOM_ID, + ) + ) + ) + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/test/FakePushGatewayNotifyRequest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/test/FakePushGatewayNotifyRequest.kt new file mode 100644 index 0000000000..d0fa5a546f --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/test/FakePushGatewayNotifyRequest.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.push.impl.test + +import io.element.android.libraries.push.impl.pushgateway.PushGatewayNotifyRequest +import io.element.android.tests.testutils.lambda.lambdaError + +class FakePushGatewayNotifyRequest( + private val executeResult: (PushGatewayNotifyRequest.Params) -> Unit = { lambdaError() } +) : PushGatewayNotifyRequest { + override suspend fun execute(params: PushGatewayNotifyRequest.Params) { + executeResult(params) + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/test/FakeTestPush.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/test/FakeTestPush.kt new file mode 100644 index 0000000000..d7bf8c8c42 --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/test/FakeTestPush.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.push.impl.test + +import io.element.android.libraries.pushproviders.api.CurrentUserPushConfig +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeTestPush( + private val executeResult: (CurrentUserPushConfig) -> Unit = { lambdaError() } +) : TestPush { + override suspend fun execute(config: CurrentUserPushConfig) { + executeResult(config) + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/troubleshoot/NotificationTestTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/troubleshoot/NotificationTestTest.kt index 1351117527..1f5b1c43db 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/troubleshoot/NotificationTestTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/troubleshoot/NotificationTestTest.kt @@ -18,27 +18,27 @@ package io.element.android.libraries.push.impl.troubleshoot import app.cash.turbine.test import com.google.common.truth.Truth.assertThat -import io.element.android.libraries.push.impl.notifications.fake.MockkNotificationCreator -import io.element.android.libraries.push.impl.notifications.fake.MockkNotificationDisplayer +import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationCreator +import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationDisplayer import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootTestState import io.element.android.services.toolbox.test.strings.FakeStringProvider +import io.element.android.tests.testutils.lambda.lambdaRecorder import kotlinx.coroutines.launch import kotlinx.coroutines.test.runTest import org.junit.Test class NotificationTestTest { - private val mockkNotificationCreator = MockkNotificationCreator().apply { - givenCreateDiagnosticNotification() - } - private val mockkNotificationDisplayer = MockkNotificationDisplayer().apply { - givenDisplayDiagnosticNotificationResult(true) - } + private val notificationCreator = FakeNotificationCreator() + private val fakeNotificationDisplayer = FakeNotificationDisplayer( + displayDiagnosticNotificationResult = lambdaRecorder { _ -> true }, + dismissDiagnosticNotificationResult = lambdaRecorder { -> } + ) private val notificationClickHandler = NotificationClickHandler() @Test fun `test NotificationTest notification cannot be displayed`() = runTest { - mockkNotificationDisplayer.givenDisplayDiagnosticNotificationResult(false) + fakeNotificationDisplayer.displayDiagnosticNotificationResult = lambdaRecorder { _ -> false } val sut = createNotificationTest() launch { sut.run(this) @@ -81,8 +81,8 @@ class NotificationTestTest { private fun createNotificationTest(): NotificationTest { return NotificationTest( - notificationCreator = mockkNotificationCreator.instance, - notificationDisplayer = mockkNotificationDisplayer.instance, + notificationCreator = notificationCreator, + notificationDisplayer = fakeNotificationDisplayer, notificationClickHandler = notificationClickHandler, stringProvider = FakeStringProvider(), ) diff --git a/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/FakePushService.kt b/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/FakePushService.kt index 969815ec66..5e7d9e7ff1 100644 --- a/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/FakePushService.kt +++ b/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/FakePushService.kt @@ -23,16 +23,33 @@ import io.element.android.libraries.pushproviders.api.PushProvider import io.element.android.tests.testutils.simulateLongTask class FakePushService( - private val testPushBlock: suspend () -> Boolean = { true } + private val testPushBlock: suspend () -> Boolean = { true }, + private val availablePushProviders: List = emptyList(), + private val registerWithLambda: suspend (MatrixClient, PushProvider, Distributor) -> Result = { _, _, _ -> + Result.success(Unit) + }, ) : PushService { - override fun notificationStyleChanged() { + override suspend fun getCurrentPushProvider(): PushProvider? { + return registeredPushProvider ?: availablePushProviders.firstOrNull() } override fun getAvailablePushProviders(): List { - return emptyList() + return availablePushProviders } - override suspend fun registerWith(matrixClient: MatrixClient, pushProvider: PushProvider, distributor: Distributor) { + private var registeredPushProvider: PushProvider? = null + + override suspend fun registerWith( + matrixClient: MatrixClient, + pushProvider: PushProvider, + distributor: Distributor, + ): Result = simulateLongTask { + return registerWithLambda(matrixClient, pushProvider, distributor) + .also { + if (it.isSuccess) { + registeredPushProvider = pushProvider + } + } } override suspend fun testPush(): Boolean = simulateLongTask { diff --git a/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/FakePusherSubscriber.kt b/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/FakePusherSubscriber.kt new file mode 100644 index 0000000000..8338bb1e4c --- /dev/null +++ b/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/FakePusherSubscriber.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.push.test + +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.pushproviders.api.PusherSubscriber +import io.element.android.tests.testutils.lambda.lambdaError + +class FakePusherSubscriber( + private val registerPusherResult: (MatrixClient, String, String) -> Result = { _, _, _ -> lambdaError() }, + private val unregisterPusherResult: (MatrixClient, String, String) -> Result = { _, _, _ -> lambdaError() }, +) : PusherSubscriber { + override suspend fun registerPusher(matrixClient: MatrixClient, pushKey: String, gateway: String): Result { + return registerPusherResult(matrixClient, pushKey, gateway) + } + + override suspend fun unregisterPusher(matrixClient: MatrixClient, pushKey: String, gateway: String): Result { + return unregisterPusherResult(matrixClient, pushKey, gateway) + } +} diff --git a/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/FakeNotificationDrawerManager.kt b/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/FakeNotificationDrawerManager.kt index 702e46c3ae..1531d2df48 100644 --- a/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/FakeNotificationDrawerManager.kt +++ b/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/notifications/FakeNotificationDrawerManager.kt @@ -28,7 +28,7 @@ class FakeNotificationDrawerManager : NotificationDrawerManager { clearMemberShipNotificationForSessionCallsCount.merge(sessionId.value, 1) { oldValue, value -> oldValue + value } } - override fun clearMembershipNotificationForRoom(sessionId: SessionId, roomId: RoomId, doRender: Boolean) { + override fun clearMembershipNotificationForRoom(sessionId: SessionId, roomId: RoomId) { val key = getMembershipNotificationKey(sessionId, roomId) clearMemberShipNotificationForRoomCallsCount.merge(key, 1) { oldValue, value -> oldValue + value } } diff --git a/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/test/FakePushHandler.kt b/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/test/FakePushHandler.kt new file mode 100644 index 0000000000..c370250bd0 --- /dev/null +++ b/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/test/FakePushHandler.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.push.test.test + +import io.element.android.libraries.pushproviders.api.PushData +import io.element.android.libraries.pushproviders.api.PushHandler +import io.element.android.tests.testutils.lambda.lambdaError + +class FakePushHandler( + private val handleResult: (PushData) -> Unit = { lambdaError() } +) : PushHandler { + override suspend fun handle(pushData: PushData) { + handleResult(pushData) + } +} diff --git a/libraries/pushproviders/api/src/main/kotlin/io/element/android/libraries/pushproviders/api/Distributor.kt b/libraries/pushproviders/api/src/main/kotlin/io/element/android/libraries/pushproviders/api/Distributor.kt index 7eda80fed9..1db3dc2610 100644 --- a/libraries/pushproviders/api/src/main/kotlin/io/element/android/libraries/pushproviders/api/Distributor.kt +++ b/libraries/pushproviders/api/src/main/kotlin/io/element/android/libraries/pushproviders/api/Distributor.kt @@ -16,6 +16,14 @@ package io.element.android.libraries.pushproviders.api +/** + * Firebase does not have the concept of distributor. So for Firebase, there will be one distributor: + * Distributor("Firebase", "Firebase"). + * + * For UnifiedPush, for instance, the Distributor can be: + * Distributor("io.heckel.ntfy", "ntfy"). + * But other values are possible. + */ data class Distributor( val value: String, val name: String, diff --git a/libraries/pushproviders/api/src/main/kotlin/io/element/android/libraries/pushproviders/api/PushProvider.kt b/libraries/pushproviders/api/src/main/kotlin/io/element/android/libraries/pushproviders/api/PushProvider.kt index 4e9b818dd4..d111dc139f 100644 --- a/libraries/pushproviders/api/src/main/kotlin/io/element/android/libraries/pushproviders/api/PushProvider.kt +++ b/libraries/pushproviders/api/src/main/kotlin/io/element/android/libraries/pushproviders/api/PushProvider.kt @@ -42,12 +42,17 @@ interface PushProvider { /** * Register the pusher to the homeserver. */ - suspend fun registerWith(matrixClient: MatrixClient, distributor: Distributor) + suspend fun registerWith(matrixClient: MatrixClient, distributor: Distributor): Result + + /** + * Return the current distributor, or null if none. + */ + suspend fun getCurrentDistributor(matrixClient: MatrixClient): Distributor? /** * Unregister the pusher. */ - suspend fun unregister(matrixClient: MatrixClient) + suspend fun unregister(matrixClient: MatrixClient): Result suspend fun getCurrentUserPushConfig(): CurrentUserPushConfig? } diff --git a/libraries/pushproviders/api/src/main/kotlin/io/element/android/libraries/pushproviders/api/PusherSubscriber.kt b/libraries/pushproviders/api/src/main/kotlin/io/element/android/libraries/pushproviders/api/PusherSubscriber.kt index 2529e4bb96..d38f5dec1e 100644 --- a/libraries/pushproviders/api/src/main/kotlin/io/element/android/libraries/pushproviders/api/PusherSubscriber.kt +++ b/libraries/pushproviders/api/src/main/kotlin/io/element/android/libraries/pushproviders/api/PusherSubscriber.kt @@ -19,6 +19,6 @@ package io.element.android.libraries.pushproviders.api import io.element.android.libraries.matrix.api.MatrixClient interface PusherSubscriber { - suspend fun registerPusher(matrixClient: MatrixClient, pushKey: String, gateway: String) - suspend fun unregisterPusher(matrixClient: MatrixClient, pushKey: String, gateway: String) + suspend fun registerPusher(matrixClient: MatrixClient, pushKey: String, gateway: String): Result + suspend fun unregisterPusher(matrixClient: MatrixClient, pushKey: String, gateway: String): Result } diff --git a/libraries/pushproviders/firebase/build.gradle.kts b/libraries/pushproviders/firebase/build.gradle.kts index 6e36b92a09..58d3b882d9 100644 --- a/libraries/pushproviders/firebase/build.gradle.kts +++ b/libraries/pushproviders/firebase/build.gradle.kts @@ -58,7 +58,11 @@ dependencies { testImplementation(libs.test.junit) testImplementation(libs.test.truth) testImplementation(libs.test.turbine) + testImplementation(libs.test.robolectric) testImplementation(projects.libraries.matrix.test) + testImplementation(projects.libraries.push.test) + testImplementation(projects.libraries.pushstore.test) + testImplementation(projects.libraries.sessionStorage.implMemory) testImplementation(projects.tests.testutils) testImplementation(projects.services.toolbox.test) } diff --git a/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebaseNewTokenHandler.kt b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebaseNewTokenHandler.kt index 20d0de4ebf..baddab1a0d 100644 --- a/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebaseNewTokenHandler.kt +++ b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebaseNewTokenHandler.kt @@ -16,7 +16,10 @@ package io.element.android.libraries.pushproviders.firebase +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.core.extensions.flatMap import io.element.android.libraries.core.log.logger.LoggerTag +import io.element.android.libraries.di.AppScope import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.pushproviders.api.PusherSubscriber @@ -31,24 +34,42 @@ private val loggerTag = LoggerTag("FirebaseNewTokenHandler", LoggerTag.PushLogge /** * Handle new token receive from Firebase. Will update all the sessions which are using Firebase as a push provider. */ -class FirebaseNewTokenHandler @Inject constructor( +interface FirebaseNewTokenHandler { + suspend fun handle(firebaseToken: String) +} + +@ContributesBinding(AppScope::class) +class DefaultFirebaseNewTokenHandler @Inject constructor( private val pusherSubscriber: PusherSubscriber, private val sessionStore: SessionStore, private val userPushStoreFactory: UserPushStoreFactory, private val matrixAuthenticationService: MatrixAuthenticationService, private val firebaseStore: FirebaseStore, -) { - suspend fun handle(firebaseToken: String) { +) : FirebaseNewTokenHandler { + override suspend fun handle(firebaseToken: String) { firebaseStore.storeFcmToken(firebaseToken) // Register the pusher for all the sessions sessionStore.getAllSessions().toUserList() .map { SessionId(it) } - .forEach { userId -> - val userDataStore = userPushStoreFactory.getOrCreate(userId) + .forEach { sessionId -> + val userDataStore = userPushStoreFactory.getOrCreate(sessionId) if (userDataStore.getPushProviderName() == FirebaseConfig.NAME) { - matrixAuthenticationService.restoreSession(userId).getOrNull()?.use { client -> - pusherSubscriber.registerPusher(client, firebaseToken, FirebaseConfig.PUSHER_HTTP_URL) - } + matrixAuthenticationService + .restoreSession(sessionId) + .onFailure { + Timber.tag(loggerTag.value).e(it, "Failed to restore session $sessionId") + } + .flatMap { client -> + pusherSubscriber + .registerPusher( + matrixClient = client, + pushKey = firebaseToken, + gateway = FirebaseConfig.PUSHER_HTTP_URL, + ) + .onFailure { + Timber.tag(loggerTag.value).e(it, "Failed to register pusher for session $sessionId") + } + } } else { Timber.tag(loggerTag.value).d("This session is not using Firebase pusher") } diff --git a/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebasePushProvider.kt b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebasePushProvider.kt index 317d49f3b6..0228f8f74b 100644 --- a/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebasePushProvider.kt +++ b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebasePushProvider.kt @@ -43,21 +43,34 @@ class FirebasePushProvider @Inject constructor( } override fun getDistributors(): List { - return listOf(Distributor("Firebase", "Firebase")) + return listOf(firebaseDistributor) } - override suspend fun registerWith(matrixClient: MatrixClient, distributor: Distributor) { - val pushKey = firebaseStore.getFcmToken() ?: return Unit.also { + override suspend fun registerWith(matrixClient: MatrixClient, distributor: Distributor): Result { + val pushKey = firebaseStore.getFcmToken() ?: return Result.failure( + IllegalStateException( + "Unable to register pusher, Firebase token is not known." + ) + ).also { Timber.tag(loggerTag.value).w("Unable to register pusher, Firebase token is not known.") } - pusherSubscriber.registerPusher(matrixClient, pushKey, FirebaseConfig.PUSHER_HTTP_URL) + return pusherSubscriber.registerPusher( + matrixClient = matrixClient, + pushKey = pushKey, + gateway = FirebaseConfig.PUSHER_HTTP_URL, + ) } - override suspend fun unregister(matrixClient: MatrixClient) { - val pushKey = firebaseStore.getFcmToken() ?: return Unit.also { + override suspend fun getCurrentDistributor(matrixClient: MatrixClient) = firebaseDistributor + + override suspend fun unregister(matrixClient: MatrixClient): Result { + val pushKey = firebaseStore.getFcmToken() + return if (pushKey == null) { Timber.tag(loggerTag.value).w("Unable to unregister pusher, Firebase token is not known.") + Result.success(Unit) + } else { + pusherSubscriber.unregisterPusher(matrixClient, pushKey, FirebaseConfig.PUSHER_HTTP_URL) } - pusherSubscriber.unregisterPusher(matrixClient, pushKey, FirebaseConfig.PUSHER_HTTP_URL) } override suspend fun getCurrentUserPushConfig(): CurrentUserPushConfig? { @@ -68,4 +81,8 @@ class FirebasePushProvider @Inject constructor( ) } } + + companion object { + private val firebaseDistributor = Distributor("Firebase", "Firebase") + } } diff --git a/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/VectorFirebaseMessagingService.kt b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/VectorFirebaseMessagingService.kt index 3d251f6e64..a8bc069893 100644 --- a/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/VectorFirebaseMessagingService.kt +++ b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/VectorFirebaseMessagingService.kt @@ -22,7 +22,6 @@ import io.element.android.libraries.architecture.bindings import io.element.android.libraries.core.log.logger.LoggerTag import io.element.android.libraries.pushproviders.api.PushHandler import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch import timber.log.Timber import javax.inject.Inject @@ -33,8 +32,7 @@ class VectorFirebaseMessagingService : FirebaseMessagingService() { @Inject lateinit var firebaseNewTokenHandler: FirebaseNewTokenHandler @Inject lateinit var pushParser: FirebasePushParser @Inject lateinit var pushHandler: PushHandler - - private val coroutineScope = CoroutineScope(SupervisorJob()) + @Inject lateinit var coroutineScope: CoroutineScope override fun onCreate() { super.onCreate() diff --git a/libraries/pushproviders/firebase/src/main/res/values-pt-rBR/translations.xml b/libraries/pushproviders/firebase/src/main/res/values-pt-rBR/translations.xml new file mode 100644 index 0000000000..2c9344dcb3 --- /dev/null +++ b/libraries/pushproviders/firebase/src/main/res/values-pt-rBR/translations.xml @@ -0,0 +1,11 @@ + + + "Certifica que a Firebase está disponível" + "Firebase indisponível." + "Firebase disponível." + "Verificar a Firebase" + "Certifica que o \"token\" da Firebase está disponível." + "\"Token\" da Firebase desconhecido." + "\"Token\" da Firebase: %1$s." + "Verificar \"token\" da Firebase" + diff --git a/libraries/pushproviders/firebase/src/main/res/values-ro/translations.xml b/libraries/pushproviders/firebase/src/main/res/values-ro/translations.xml new file mode 100644 index 0000000000..72e13904d5 --- /dev/null +++ b/libraries/pushproviders/firebase/src/main/res/values-ro/translations.xml @@ -0,0 +1,11 @@ + + + "Asigurați-vă că Firebase este disponibil." + "Firebase nu este disponibil." + "Firebase este disponibil." + "Verificați Firebase" + "Asigurați-vă că tokenul Firebase este disponibil." + "Tokenul Firebase nu este cunoscut." + "Token Firebase: %1$s." + "Verificați token-ul Firebase" + diff --git a/libraries/pushproviders/firebase/src/main/res/values-zh/translations.xml b/libraries/pushproviders/firebase/src/main/res/values-zh/translations.xml new file mode 100644 index 0000000000..661d2d13d7 --- /dev/null +++ b/libraries/pushproviders/firebase/src/main/res/values-zh/translations.xml @@ -0,0 +1,11 @@ + + + "确保 Firebase 可用。" + "Firebase 不可用。" + "Firebase 可用。" + "检查 Firebase" + "确保 Firebase 令牌可用。" + "Firebas 令牌未知。" + "Firebase 令牌:%1$s 。" + "检查 Firebase 令牌" + diff --git a/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/DefaultFirebaseNewTokenHandlerTest.kt b/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/DefaultFirebaseNewTokenHandlerTest.kt new file mode 100644 index 0000000000..585a5e2a08 --- /dev/null +++ b/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/DefaultFirebaseNewTokenHandlerTest.kt @@ -0,0 +1,186 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.pushproviders.firebase + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.test.AN_EXCEPTION +import io.element.android.libraries.matrix.test.A_USER_ID +import io.element.android.libraries.matrix.test.A_USER_ID_2 +import io.element.android.libraries.matrix.test.A_USER_ID_3 +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService +import io.element.android.libraries.push.test.FakePusherSubscriber +import io.element.android.libraries.pushproviders.api.PusherSubscriber +import io.element.android.libraries.pushstore.api.UserPushStoreFactory +import io.element.android.libraries.pushstore.test.userpushstore.FakeUserPushStore +import io.element.android.libraries.pushstore.test.userpushstore.FakeUserPushStoreFactory +import io.element.android.libraries.sessionstorage.api.LoginType +import io.element.android.libraries.sessionstorage.api.SessionData +import io.element.android.libraries.sessionstorage.api.SessionStore +import io.element.android.libraries.sessionstorage.impl.memory.InMemoryMultiSessionsStore +import io.element.android.libraries.sessionstorage.impl.memory.InMemorySessionStore +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class DefaultFirebaseNewTokenHandlerTest { + @Test + fun `when a new token is received it is stored in the firebase store`() = runTest { + val firebaseStore = InMemoryFirebaseStore() + assertThat(firebaseStore.getFcmToken()).isNull() + val firebaseNewTokenHandler = createDefaultFirebaseNewTokenHandler( + firebaseStore = firebaseStore, + ) + firebaseNewTokenHandler.handle("aToken") + assertThat(firebaseStore.getFcmToken()).isEqualTo("aToken") + } + + @Test + fun `when a new token is received, the handler registers the pusher again to all sessions using Firebase`() = runTest { + val aMatrixClient1 = FakeMatrixClient(A_USER_ID) + val aMatrixClient2 = FakeMatrixClient(A_USER_ID_2) + val aMatrixClient3 = FakeMatrixClient(A_USER_ID_3) + val registerPusherResult = lambdaRecorder> { _, _, _ -> Result.success(Unit) } + val pusherSubscriber = FakePusherSubscriber(registerPusherResult = registerPusherResult) + val firebaseNewTokenHandler = createDefaultFirebaseNewTokenHandler( + sessionStore = InMemoryMultiSessionsStore().apply { + storeData(aSessionData(A_USER_ID)) + storeData(aSessionData(A_USER_ID_2)) + storeData(aSessionData(A_USER_ID_3)) + }, + matrixAuthenticationService = FakeMatrixAuthenticationService( + matrixClientResult = { sessionId -> + when (sessionId) { + A_USER_ID -> Result.success(aMatrixClient1) + A_USER_ID_2 -> Result.success(aMatrixClient2) + A_USER_ID_3 -> Result.success(aMatrixClient3) + else -> Result.failure(IllegalStateException()) + } + } + ), + userPushStoreFactory = FakeUserPushStoreFactory( + userPushStore = { sessionId -> + when (sessionId) { + A_USER_ID -> FakeUserPushStore(pushProviderName = FirebaseConfig.NAME) + A_USER_ID_2 -> FakeUserPushStore(pushProviderName = "Other") + A_USER_ID_3 -> FakeUserPushStore(pushProviderName = FirebaseConfig.NAME) + else -> error("Unexpected sessionId: $sessionId") + } + } + ), + pusherSubscriber = pusherSubscriber, + ) + firebaseNewTokenHandler.handle("aToken") + registerPusherResult.assertions() + .isCalledExactly(2) + .withSequence( + listOf(value(aMatrixClient1), value("aToken"), value(FirebaseConfig.PUSHER_HTTP_URL)), + listOf(value(aMatrixClient3), value("aToken"), value(FirebaseConfig.PUSHER_HTTP_URL)), + ) + } + + @Test + fun `when a new token is received, if the session cannot be restore, nothing happen`() = runTest { + val registerPusherResult = lambdaRecorder> { _, _, _ -> Result.success(Unit) } + val pusherSubscriber = FakePusherSubscriber(registerPusherResult = registerPusherResult) + val firebaseNewTokenHandler = createDefaultFirebaseNewTokenHandler( + sessionStore = InMemoryMultiSessionsStore().apply { + storeData(aSessionData(A_USER_ID)) + }, + matrixAuthenticationService = FakeMatrixAuthenticationService( + matrixClientResult = { _ -> + Result.failure(IllegalStateException()) + } + ), + userPushStoreFactory = FakeUserPushStoreFactory( + userPushStore = { _ -> + FakeUserPushStore(pushProviderName = FirebaseConfig.NAME) + } + ), + pusherSubscriber = pusherSubscriber, + ) + firebaseNewTokenHandler.handle("aToken") + registerPusherResult.assertions() + .isNeverCalled() + } + + @Test + fun `when a new token is received, error when registering the pusher is ignored`() = runTest { + val aMatrixClient1 = FakeMatrixClient(A_USER_ID) + val registerPusherResult = lambdaRecorder> { _, _, _ -> Result.failure(AN_EXCEPTION) } + val pusherSubscriber = FakePusherSubscriber(registerPusherResult = registerPusherResult) + val firebaseNewTokenHandler = createDefaultFirebaseNewTokenHandler( + sessionStore = InMemoryMultiSessionsStore().apply { + storeData(aSessionData(A_USER_ID)) + }, + matrixAuthenticationService = FakeMatrixAuthenticationService( + matrixClientResult = { _ -> + Result.success(aMatrixClient1) + } + ), + userPushStoreFactory = FakeUserPushStoreFactory( + userPushStore = { _ -> + FakeUserPushStore(pushProviderName = FirebaseConfig.NAME) + } + ), + pusherSubscriber = pusherSubscriber, + ) + firebaseNewTokenHandler.handle("aToken") + registerPusherResult.assertions() + registerPusherResult.assertions() + .isCalledOnce() + .with(value(aMatrixClient1), value("aToken"), value(FirebaseConfig.PUSHER_HTTP_URL)) + } + + private fun createDefaultFirebaseNewTokenHandler( + pusherSubscriber: PusherSubscriber = FakePusherSubscriber(), + sessionStore: SessionStore = InMemorySessionStore(), + userPushStoreFactory: UserPushStoreFactory = FakeUserPushStoreFactory(), + matrixAuthenticationService: MatrixAuthenticationService = FakeMatrixAuthenticationService(), + firebaseStore: FirebaseStore = InMemoryFirebaseStore(), + ): FirebaseNewTokenHandler { + return DefaultFirebaseNewTokenHandler( + pusherSubscriber = pusherSubscriber, + sessionStore = sessionStore, + userPushStoreFactory = userPushStoreFactory, + matrixAuthenticationService = matrixAuthenticationService, + firebaseStore = firebaseStore + ) + } + + private fun aSessionData( + sessionId: SessionId, + ): SessionData { + return SessionData( + userId = sessionId.value, + deviceId = "aDeviceId", + accessToken = "anAccessToken", + refreshToken = "aRefreshToken", + homeserverUrl = "aHomeserverUrl", + oidcData = null, + slidingSyncProxy = null, + loginTimestamp = null, + isTokenValid = true, + loginType = LoginType.UNKNOWN, + passphrase = null, + ) + } +} diff --git a/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/FakeFirebaseNewTokenHandler.kt b/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/FakeFirebaseNewTokenHandler.kt new file mode 100644 index 0000000000..aa66f0288c --- /dev/null +++ b/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/FakeFirebaseNewTokenHandler.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.pushproviders.firebase + +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeFirebaseNewTokenHandler( + private val handleResult: (String) -> Unit = { lambdaError() } +) : FirebaseNewTokenHandler { + override suspend fun handle(firebaseToken: String) { + handleResult(firebaseToken) + } +} diff --git a/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/FakeIsPlayServiceAvailable.kt b/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/FakeIsPlayServiceAvailable.kt new file mode 100644 index 0000000000..6994e6140e --- /dev/null +++ b/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/FakeIsPlayServiceAvailable.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.pushproviders.firebase + +class FakeIsPlayServiceAvailable( + private val isAvailable: Boolean, +) : IsPlayServiceAvailable { + override fun isAvailable() = isAvailable +} diff --git a/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/FirebasePushProviderTest.kt b/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/FirebasePushProviderTest.kt new file mode 100644 index 0000000000..880be2f053 --- /dev/null +++ b/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/FirebasePushProviderTest.kt @@ -0,0 +1,194 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.pushproviders.firebase + +import com.google.common.truth.Truth.assertThat +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.push.test.FakePusherSubscriber +import io.element.android.libraries.pushproviders.api.CurrentUserPushConfig +import io.element.android.libraries.pushproviders.api.Distributor +import io.element.android.libraries.pushproviders.api.PusherSubscriber +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class FirebasePushProviderTest { + @Test + fun `test index and name`() { + val firebasePushProvider = createFirebasePushProvider() + assertThat(firebasePushProvider.name).isEqualTo(FirebaseConfig.NAME) + assertThat(firebasePushProvider.index).isEqualTo(FirebaseConfig.INDEX) + } + + @Test + fun `getDistributors return the unique distributor`() { + val firebasePushProvider = createFirebasePushProvider() + val result = firebasePushProvider.getDistributors() + assertThat(result).containsExactly(Distributor("Firebase", "Firebase")) + } + + @Test + fun `getCurrentDistributor always return the unique distributor`() = runTest { + val firebasePushProvider = createFirebasePushProvider() + val result = firebasePushProvider.getCurrentDistributor(FakeMatrixClient()) + assertThat(result).isEqualTo(Distributor("Firebase", "Firebase")) + } + + @Test + fun `isAvailable true`() { + val firebasePushProvider = createFirebasePushProvider( + isPlayServiceAvailable = FakeIsPlayServiceAvailable(isAvailable = true) + ) + assertThat(firebasePushProvider.isAvailable()).isTrue() + } + + @Test + fun `isAvailable false`() { + val firebasePushProvider = createFirebasePushProvider( + isPlayServiceAvailable = FakeIsPlayServiceAvailable(isAvailable = false) + ) + assertThat(firebasePushProvider.isAvailable()).isFalse() + } + + @Test + fun `register ok`() = runTest { + val matrixClient = FakeMatrixClient() + val registerPusherResultLambda = lambdaRecorder> { _, _, _ -> Result.success(Unit) } + val firebasePushProvider = createFirebasePushProvider( + firebaseStore = InMemoryFirebaseStore( + token = "aToken" + ), + pusherSubscriber = FakePusherSubscriber( + registerPusherResult = registerPusherResultLambda + ) + ) + val result = firebasePushProvider.registerWith(matrixClient, Distributor("value", "Name")) + assertThat(result).isEqualTo(Result.success(Unit)) + registerPusherResultLambda.assertions() + .isCalledOnce() + .with(value(matrixClient), value("aToken"), value(FirebaseConfig.PUSHER_HTTP_URL)) + } + + @Test + fun `register ko no token`() = runTest { + val firebasePushProvider = createFirebasePushProvider( + firebaseStore = InMemoryFirebaseStore( + token = null + ), + pusherSubscriber = FakePusherSubscriber( + registerPusherResult = { _, _, _ -> Result.success(Unit) } + ) + ) + val result = firebasePushProvider.registerWith(FakeMatrixClient(), Distributor("value", "Name")) + assertThat(result.isFailure).isTrue() + } + + @Test + fun `register ko error`() = runTest { + val firebasePushProvider = createFirebasePushProvider( + firebaseStore = InMemoryFirebaseStore( + token = "aToken" + ), + pusherSubscriber = FakePusherSubscriber( + registerPusherResult = { _, _, _ -> Result.failure(AN_EXCEPTION) } + ) + ) + val result = firebasePushProvider.registerWith(FakeMatrixClient(), Distributor("value", "Name")) + assertThat(result.isFailure).isTrue() + } + + @Test + fun `unregister ok`() = runTest { + val matrixClient = FakeMatrixClient() + val unregisterPusherResultLambda = lambdaRecorder> { _, _, _ -> Result.success(Unit) } + val firebasePushProvider = createFirebasePushProvider( + firebaseStore = InMemoryFirebaseStore( + token = "aToken" + ), + pusherSubscriber = FakePusherSubscriber( + unregisterPusherResult = unregisterPusherResultLambda + ) + ) + val result = firebasePushProvider.unregister(matrixClient) + assertThat(result).isEqualTo(Result.success(Unit)) + unregisterPusherResultLambda.assertions() + .isCalledOnce() + .with(value(matrixClient), value("aToken"), value(FirebaseConfig.PUSHER_HTTP_URL)) + } + + @Test + fun `unregister no token - in this case, the error is ignored`() = runTest { + val firebasePushProvider = createFirebasePushProvider( + firebaseStore = InMemoryFirebaseStore( + token = null + ), + ) + val result = firebasePushProvider.unregister(FakeMatrixClient()) + assertThat(result.isSuccess).isTrue() + } + + @Test + fun `unregister ko error`() = runTest { + val firebasePushProvider = createFirebasePushProvider( + firebaseStore = InMemoryFirebaseStore( + token = "aToken" + ), + pusherSubscriber = FakePusherSubscriber( + unregisterPusherResult = { _, _, _ -> Result.failure(AN_EXCEPTION) } + ) + ) + val result = firebasePushProvider.unregister(FakeMatrixClient()) + assertThat(result.isFailure).isTrue() + } + + @Test + fun `getCurrentUserPushConfig no push ket`() = runTest { + val firebasePushProvider = createFirebasePushProvider( + firebaseStore = InMemoryFirebaseStore( + token = null + ) + ) + val result = firebasePushProvider.getCurrentUserPushConfig() + assertThat(result).isNull() + } + + @Test + fun `getCurrentUserPushConfig ok`() = runTest { + val firebasePushProvider = createFirebasePushProvider( + firebaseStore = InMemoryFirebaseStore( + token = "aToken" + ), + ) + val result = firebasePushProvider.getCurrentUserPushConfig() + assertThat(result).isEqualTo(CurrentUserPushConfig(FirebaseConfig.PUSHER_HTTP_URL, "aToken")) + } + + private fun createFirebasePushProvider( + firebaseStore: FirebaseStore = InMemoryFirebaseStore(), + pusherSubscriber: PusherSubscriber = FakePusherSubscriber(), + isPlayServiceAvailable: IsPlayServiceAvailable = FakeIsPlayServiceAvailable(false), + ): FirebasePushProvider { + return FirebasePushProvider( + firebaseStore = firebaseStore, + pusherSubscriber = pusherSubscriber, + isPlayServiceAvailable = isPlayServiceAvailable, + ) + } +} diff --git a/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/VectorFirebaseMessagingServiceTest.kt b/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/VectorFirebaseMessagingServiceTest.kt new file mode 100644 index 0000000000..9dfb453919 --- /dev/null +++ b/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/VectorFirebaseMessagingServiceTest.kt @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package io.element.android.libraries.pushproviders.firebase + +import android.os.Bundle +import com.google.firebase.messaging.RemoteMessage +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_SECRET +import io.element.android.libraries.push.test.test.FakePushHandler +import io.element.android.libraries.pushproviders.api.PushData +import io.element.android.libraries.pushproviders.api.PushHandler +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class VectorFirebaseMessagingServiceTest { + @Test + fun `test receiving invalid data`() = runTest { + val lambda = lambdaRecorder(ensureNeverCalled = true) { } + val vectorFirebaseMessagingService = createVectorFirebaseMessagingService( + pushHandler = FakePushHandler(handleResult = lambda) + ) + vectorFirebaseMessagingService.onMessageReceived(RemoteMessage(Bundle())) + } + + @Test + fun `test receiving valid data`() = runTest { + val lambda = lambdaRecorder { } + val vectorFirebaseMessagingService = createVectorFirebaseMessagingService( + pushHandler = FakePushHandler(handleResult = lambda) + ) + vectorFirebaseMessagingService.onMessageReceived( + message = RemoteMessage( + Bundle().apply { + putString("event_id", AN_EVENT_ID.value) + putString("room_id", A_ROOM_ID.value) + putString("cs", A_SECRET) + }, + ) + ) + advanceUntilIdle() + lambda.assertions() + .isCalledOnce() + .with(value(PushData(AN_EVENT_ID, A_ROOM_ID, null, A_SECRET))) + } + + @Test + fun `test new token is forwarded to the handler`() = runTest { + val lambda = lambdaRecorder { } + val vectorFirebaseMessagingService = createVectorFirebaseMessagingService( + firebaseNewTokenHandler = FakeFirebaseNewTokenHandler(handleResult = lambda) + ) + vectorFirebaseMessagingService.onNewToken("aToken") + advanceUntilIdle() + lambda.assertions() + .isCalledOnce() + .with(value("aToken")) + } + + private fun TestScope.createVectorFirebaseMessagingService( + firebaseNewTokenHandler: FirebaseNewTokenHandler = FakeFirebaseNewTokenHandler(), + pushHandler: PushHandler = FakePushHandler(), + ): VectorFirebaseMessagingService { + return VectorFirebaseMessagingService().apply { + this.firebaseNewTokenHandler = firebaseNewTokenHandler + this.pushParser = FirebasePushParser() + this.pushHandler = pushHandler + this.coroutineScope = this@createVectorFirebaseMessagingService + } + } +} diff --git a/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/troubleshoot/FirebaseAvailabilityTestTest.kt b/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/troubleshoot/FirebaseAvailabilityTestTest.kt index 6f1a3da7cb..fae5ba9f12 100644 --- a/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/troubleshoot/FirebaseAvailabilityTestTest.kt +++ b/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/troubleshoot/FirebaseAvailabilityTestTest.kt @@ -18,8 +18,10 @@ package io.element.android.libraries.pushproviders.firebase.troubleshoot import app.cash.turbine.test import com.google.common.truth.Truth.assertThat -import io.element.android.libraries.pushproviders.firebase.IsPlayServiceAvailable +import io.element.android.libraries.pushproviders.firebase.FakeIsPlayServiceAvailable +import io.element.android.libraries.pushproviders.firebase.FirebaseConfig import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootTestState +import io.element.android.libraries.troubleshoot.api.test.TestFilterData import io.element.android.services.toolbox.test.strings.FakeStringProvider import kotlinx.coroutines.launch import kotlinx.coroutines.test.runTest @@ -29,11 +31,7 @@ class FirebaseAvailabilityTestTest { @Test fun `test FirebaseAvailabilityTest success`() = runTest { val sut = FirebaseAvailabilityTest( - isPlayServiceAvailable = object : IsPlayServiceAvailable { - override fun isAvailable(): Boolean { - return true - } - }, + isPlayServiceAvailable = FakeIsPlayServiceAvailable(true), stringProvider = FakeStringProvider(), ) launch { @@ -50,11 +48,7 @@ class FirebaseAvailabilityTestTest { @Test fun `test FirebaseAvailabilityTest failure`() = runTest { val sut = FirebaseAvailabilityTest( - isPlayServiceAvailable = object : IsPlayServiceAvailable { - override fun isAvailable(): Boolean { - return false - } - }, + isPlayServiceAvailable = FakeIsPlayServiceAvailable(false), stringProvider = FakeStringProvider(), ) launch { @@ -67,4 +61,14 @@ class FirebaseAvailabilityTestTest { assertThat(lastItem.status).isEqualTo(NotificationTroubleshootTestState.Status.Failure(false)) } } + + @Test + fun `test FirebaseAvailabilityTest isRelevant`() { + val sut = FirebaseAvailabilityTest( + isPlayServiceAvailable = FakeIsPlayServiceAvailable(false), + stringProvider = FakeStringProvider(), + ) + assertThat(sut.isRelevant(TestFilterData(currentPushProviderName = "unknown"))).isFalse() + assertThat(sut.isRelevant(TestFilterData(currentPushProviderName = FirebaseConfig.NAME))).isTrue() + } } diff --git a/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/troubleshoot/FirebaseTokenTestTest.kt b/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/troubleshoot/FirebaseTokenTestTest.kt index 2d8de62ad9..245a6095d4 100644 --- a/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/troubleshoot/FirebaseTokenTestTest.kt +++ b/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/troubleshoot/FirebaseTokenTestTest.kt @@ -19,8 +19,10 @@ package io.element.android.libraries.pushproviders.firebase.troubleshoot import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import io.element.android.libraries.pushproviders.firebase.FakeFirebaseTroubleshooter +import io.element.android.libraries.pushproviders.firebase.FirebaseConfig import io.element.android.libraries.pushproviders.firebase.InMemoryFirebaseStore import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootTestState +import io.element.android.libraries.troubleshoot.api.test.TestFilterData import io.element.android.services.toolbox.test.strings.FakeStringProvider import kotlinx.coroutines.launch import kotlinx.coroutines.test.runTest @@ -75,6 +77,17 @@ class FirebaseTokenTestTest { } } + @Test + fun `test FirebaseTokenTest isRelevant`() { + val sut = FirebaseTokenTest( + firebaseStore = InMemoryFirebaseStore(null), + firebaseTroubleshooter = FakeFirebaseTroubleshooter(), + stringProvider = FakeStringProvider(), + ) + assertThat(sut.isRelevant(TestFilterData(currentPushProviderName = "unknown"))).isFalse() + assertThat(sut.isRelevant(TestFilterData(currentPushProviderName = FirebaseConfig.NAME))).isTrue() + } + companion object { private const val FAKE_TOKEN = "abcdefghijk" } diff --git a/libraries/pushproviders/test/build.gradle.kts b/libraries/pushproviders/test/build.gradle.kts index ddb68ed43f..9a0d2c139c 100644 --- a/libraries/pushproviders/test/build.gradle.kts +++ b/libraries/pushproviders/test/build.gradle.kts @@ -24,4 +24,5 @@ android { dependencies { implementation(projects.libraries.matrix.api) implementation(projects.libraries.pushproviders.api) + implementation(projects.tests.testutils) } diff --git a/libraries/pushproviders/test/src/main/kotlin/io/element/android/libraries/pushproviders/test/FakePushProvider.kt b/libraries/pushproviders/test/src/main/kotlin/io/element/android/libraries/pushproviders/test/FakePushProvider.kt index 8d8b94ec19..7b37d0d296 100644 --- a/libraries/pushproviders/test/src/main/kotlin/io/element/android/libraries/pushproviders/test/FakePushProvider.kt +++ b/libraries/pushproviders/test/src/main/kotlin/io/element/android/libraries/pushproviders/test/FakePushProvider.kt @@ -20,26 +20,34 @@ import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.pushproviders.api.CurrentUserPushConfig import io.element.android.libraries.pushproviders.api.Distributor import io.element.android.libraries.pushproviders.api.PushProvider +import io.element.android.tests.testutils.lambda.lambdaError class FakePushProvider( override val index: Int = 0, override val name: String = "aFakePushProvider", private val isAvailable: Boolean = true, - private val distributors: List = emptyList() + private val distributors: List = listOf(Distributor("aDistributorValue", "aDistributorName")), + private val currentUserPushConfig: CurrentUserPushConfig? = null, + private val registerWithResult: (MatrixClient, Distributor) -> Result = { _, _ -> lambdaError() }, + private val unregisterWithResult: (MatrixClient) -> Result = { lambdaError() }, ) : PushProvider { override fun isAvailable(): Boolean = isAvailable override fun getDistributors(): List = distributors - override suspend fun registerWith(matrixClient: MatrixClient, distributor: Distributor) { - // No-op + override suspend fun registerWith(matrixClient: MatrixClient, distributor: Distributor): Result { + return registerWithResult(matrixClient, distributor) } - override suspend fun unregister(matrixClient: MatrixClient) { - // No-op + override suspend fun getCurrentDistributor(matrixClient: MatrixClient): Distributor? { + return distributors.firstOrNull() + } + + override suspend fun unregister(matrixClient: MatrixClient): Result { + return unregisterWithResult(matrixClient) } override suspend fun getCurrentUserPushConfig(): CurrentUserPushConfig? { - return null + return currentUserPushConfig } } diff --git a/libraries/pushproviders/unifiedpush/build.gradle.kts b/libraries/pushproviders/unifiedpush/build.gradle.kts index d5dcc9727d..d1c8cf5ee1 100644 --- a/libraries/pushproviders/unifiedpush/build.gradle.kts +++ b/libraries/pushproviders/unifiedpush/build.gradle.kts @@ -55,9 +55,13 @@ dependencies { testImplementation(libs.coroutines.test) testImplementation(libs.test.junit) + testImplementation(libs.test.robolectric) testImplementation(libs.test.truth) testImplementation(libs.test.turbine) testImplementation(projects.libraries.matrix.test) + testImplementation(projects.libraries.push.test) + testImplementation(projects.libraries.pushstore.test) testImplementation(projects.tests.testutils) testImplementation(projects.services.toolbox.test) + testImplementation(projects.services.appnavstate.test) } diff --git a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/RegisterUnifiedPushUseCase.kt b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/RegisterUnifiedPushUseCase.kt index 8dd71118b3..6670f18ea2 100644 --- a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/RegisterUnifiedPushUseCase.kt +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/RegisterUnifiedPushUseCase.kt @@ -17,56 +17,41 @@ package io.element.android.libraries.pushproviders.unifiedpush import android.content.Context +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.ApplicationContext -import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.pushproviders.api.Distributor -import io.element.android.libraries.pushproviders.api.PusherSubscriber +import io.element.android.libraries.pushproviders.unifiedpush.registration.EndpointRegistrationHandler +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withTimeout import org.unifiedpush.android.connector.UnifiedPush import javax.inject.Inject +import kotlin.time.Duration.Companion.seconds -class RegisterUnifiedPushUseCase @Inject constructor( +interface RegisterUnifiedPushUseCase { + suspend fun execute(distributor: Distributor, clientSecret: String): Result +} + +@ContributesBinding(AppScope::class) +class DefaultRegisterUnifiedPushUseCase @Inject constructor( @ApplicationContext private val context: Context, - private val pusherSubscriber: PusherSubscriber, - private val unifiedPushStore: UnifiedPushStore, -) { - sealed interface RegisterUnifiedPushResult { - data object Success : RegisterUnifiedPushResult - data object NeedToAskUserForDistributor : RegisterUnifiedPushResult - data object Error : RegisterUnifiedPushResult - } - - suspend fun execute(matrixClient: MatrixClient, distributor: Distributor, clientSecret: String): RegisterUnifiedPushResult { - val distributorValue = distributor.value - if (distributorValue.isNotEmpty()) { - saveAndRegisterApp(distributorValue, clientSecret) - val endpoint = unifiedPushStore.getEndpoint(clientSecret) ?: return RegisterUnifiedPushResult.Error - val gateway = unifiedPushStore.getPushGateway(clientSecret) ?: return RegisterUnifiedPushResult.Error - pusherSubscriber.registerPusher(matrixClient, endpoint, gateway) - return RegisterUnifiedPushResult.Success - } - - // TODO Below should never happen? - if (UnifiedPush.getDistributor(context).isNotEmpty()) { - registerApp(clientSecret) - return RegisterUnifiedPushResult.Success - } - - val distributors = UnifiedPush.getDistributors(context) - - return if (distributors.size == 1) { - saveAndRegisterApp(distributors.first(), clientSecret) - RegisterUnifiedPushResult.Success - } else { - RegisterUnifiedPushResult.NeedToAskUserForDistributor - } - } - - private fun saveAndRegisterApp(distributor: String, clientSecret: String) { - UnifiedPush.saveDistributor(context, distributor) - registerApp(clientSecret) - } - - private fun registerApp(clientSecret: String) { + private val endpointRegistrationHandler: EndpointRegistrationHandler, +) : RegisterUnifiedPushUseCase { + override suspend fun execute(distributor: Distributor, clientSecret: String): Result { + UnifiedPush.saveDistributor(context, distributor.value) + // This will trigger the callback + // VectorUnifiedPushMessagingReceiver.onNewEndpoint UnifiedPush.registerApp(context = context, instance = clientSecret) + // Wait for VectorUnifiedPushMessagingReceiver.onNewEndpoint to proceed + return runCatching { + withTimeout(30.seconds) { + val result = endpointRegistrationHandler.state + .filter { it.clientSecret == clientSecret } + .first() + .result + result.getOrThrow() + } + } } } diff --git a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushApiFactory.kt b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushApiFactory.kt new file mode 100644 index 0000000000..84a923df44 --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushApiFactory.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.pushproviders.unifiedpush + +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.network.RetrofitFactory +import io.element.android.libraries.pushproviders.unifiedpush.network.UnifiedPushApi +import javax.inject.Inject + +interface UnifiedPushApiFactory { + fun create(baseUrl: String): UnifiedPushApi +} + +@ContributesBinding(AppScope::class) +class DefaultUnifiedPushApiFactory @Inject constructor( + private val retrofitFactory: RetrofitFactory, +) : UnifiedPushApiFactory { + override fun create(baseUrl: String): UnifiedPushApi { + return retrofitFactory.create(baseUrl) + .create(UnifiedPushApi::class.java) + } +} diff --git a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushGatewayResolver.kt b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushGatewayResolver.kt index 54b80a5110..c39c7ec066 100644 --- a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushGatewayResolver.kt +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushGatewayResolver.kt @@ -16,29 +16,33 @@ package io.element.android.libraries.pushproviders.unifiedpush +import com.squareup.anvil.annotations.ContributesBinding import io.element.android.libraries.core.coroutine.CoroutineDispatchers -import io.element.android.libraries.network.RetrofitFactory -import io.element.android.libraries.pushproviders.unifiedpush.network.UnifiedPushApi +import io.element.android.libraries.di.AppScope import kotlinx.coroutines.withContext import timber.log.Timber import java.net.URL import javax.inject.Inject -class UnifiedPushGatewayResolver @Inject constructor( - private val retrofitFactory: RetrofitFactory, +interface UnifiedPushGatewayResolver { + suspend fun getGateway(endpoint: String): String +} + +@ContributesBinding(AppScope::class) +class DefaultUnifiedPushGatewayResolver @Inject constructor( + private val unifiedPushApiFactory: UnifiedPushApiFactory, private val coroutineDispatchers: CoroutineDispatchers, -) { - suspend fun getGateway(endpoint: String): String? { +) : UnifiedPushGatewayResolver { + override suspend fun getGateway(endpoint: String): String { val gateway = UnifiedPushConfig.DEFAULT_PUSH_GATEWAY_HTTP_URL - val url = URL(endpoint) - val port = if (url.port != -1) ":${url.port}" else "" - val customBase = "${url.protocol}://${url.host}$port" - val customUrl = "$customBase/_matrix/push/v1/notify" - Timber.i("Testing $customUrl") try { + val url = URL(endpoint) + val port = if (url.port != -1) ":${url.port}" else "" + val customBase = "${url.protocol}://${url.host}$port" + val customUrl = "$customBase/_matrix/push/v1/notify" + Timber.i("Testing $customUrl") return withContext(coroutineDispatchers.io) { - val api = retrofitFactory.create(customBase) - .create(UnifiedPushApi::class.java) + val api = unifiedPushApiFactory.create(customBase) try { val discoveryResponse = api.discover() if (discoveryResponse.unifiedpush.gateway == "matrix") { diff --git a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushNewGatewayHandler.kt b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushNewGatewayHandler.kt index 4ee637a3ab..f839e6a03e 100644 --- a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushNewGatewayHandler.kt +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushNewGatewayHandler.kt @@ -16,7 +16,10 @@ package io.element.android.libraries.pushproviders.unifiedpush +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.core.extensions.flatMap import io.element.android.libraries.core.log.logger.LoggerTag +import io.element.android.libraries.di.AppScope import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService import io.element.android.libraries.pushproviders.api.PusherSubscriber import io.element.android.libraries.pushstore.api.UserPushStoreFactory @@ -24,29 +27,41 @@ import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecret import timber.log.Timber import javax.inject.Inject -private val loggerTag = LoggerTag("UnifiedPushNewGatewayHandler", LoggerTag.PushLoggerTag) +private val loggerTag = LoggerTag("DefaultUnifiedPushNewGatewayHandler", LoggerTag.PushLoggerTag) /** - * Handle new endpoint received from UnifiedPush. Will update all the sessions which are using UnifiedPush as a push provider. + * Handle new endpoint received from UnifiedPush. Will update the session matching the client secret. */ -class UnifiedPushNewGatewayHandler @Inject constructor( +interface UnifiedPushNewGatewayHandler { + suspend fun handle(endpoint: String, pushGateway: String, clientSecret: String): Result +} + +@ContributesBinding(AppScope::class) +class DefaultUnifiedPushNewGatewayHandler @Inject constructor( private val pusherSubscriber: PusherSubscriber, private val userPushStoreFactory: UserPushStoreFactory, private val pushClientSecret: PushClientSecret, private val matrixAuthenticationService: MatrixAuthenticationService, -) { - suspend fun handle(endpoint: String, pushGateway: String, clientSecret: String) { +) : UnifiedPushNewGatewayHandler { + override suspend fun handle(endpoint: String, pushGateway: String, clientSecret: String): Result { // Register the pusher for the session with this client secret, if is it using UnifiedPush. - val userId = pushClientSecret.getUserIdFromSecret(clientSecret) ?: return Unit.also { + val userId = pushClientSecret.getUserIdFromSecret(clientSecret) ?: return Result.failure( + IllegalStateException("Unable to retrieve session") + ).also { Timber.w("Unable to retrieve session") } val userDataStore = userPushStoreFactory.getOrCreate(userId) - if (userDataStore.getPushProviderName() == UnifiedPushConfig.NAME) { - matrixAuthenticationService.restoreSession(userId).getOrNull()?.use { client -> - pusherSubscriber.registerPusher(client, endpoint, pushGateway) - } + return if (userDataStore.getPushProviderName() == UnifiedPushConfig.NAME) { + matrixAuthenticationService + .restoreSession(userId) + .flatMap { client -> + pusherSubscriber.registerPusher(client, endpoint, pushGateway) + } } else { Timber.tag(loggerTag.value).d("This session is not using UnifiedPush pusher") + Result.failure( + IllegalStateException("This session is not using UnifiedPush pusher") + ) } } } diff --git a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushParser.kt b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushParser.kt index f68cd8542b..46c7c0f9bb 100644 --- a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushParser.kt +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushParser.kt @@ -18,7 +18,6 @@ package io.element.android.libraries.pushproviders.unifiedpush import io.element.android.libraries.core.data.tryOrNull import io.element.android.libraries.pushproviders.api.PushData -import kotlinx.serialization.decodeFromString import kotlinx.serialization.json.Json import javax.inject.Inject diff --git a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushProvider.kt b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushProvider.kt index e7ea1841c5..4530a4667f 100644 --- a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushProvider.kt +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushProvider.kt @@ -58,14 +58,22 @@ class UnifiedPushProvider @Inject constructor( return unifiedPushDistributorProvider.getDistributors() } - override suspend fun registerWith(matrixClient: MatrixClient, distributor: Distributor) { + override suspend fun registerWith(matrixClient: MatrixClient, distributor: Distributor): Result { val clientSecret = pushClientSecret.getSecretForUser(matrixClient.sessionId) - registerUnifiedPushUseCase.execute(matrixClient, distributor, clientSecret) + return registerUnifiedPushUseCase.execute(distributor, clientSecret) + .onSuccess { + unifiedPushStore.setDistributorValue(matrixClient.sessionId, distributor.value) + } } - override suspend fun unregister(matrixClient: MatrixClient) { + override suspend fun getCurrentDistributor(matrixClient: MatrixClient): Distributor? { + val distributorValue = unifiedPushStore.getDistributorValue(matrixClient.sessionId) + return getDistributors().find { it.value == distributorValue } + } + + override suspend fun unregister(matrixClient: MatrixClient): Result { val clientSecret = pushClientSecret.getSecretForUser(matrixClient.sessionId) - unRegisterUnifiedPushUseCase.execute(clientSecret) + return unRegisterUnifiedPushUseCase.execute(matrixClient, clientSecret) } override suspend fun getCurrentUserPushConfig(): CurrentUserPushConfig? { diff --git a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushStore.kt b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushStore.kt index d063dfce3e..dc1bd86d9e 100644 --- a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushStore.kt +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushStore.kt @@ -19,31 +19,44 @@ package io.element.android.libraries.pushproviders.unifiedpush import android.content.Context import android.content.SharedPreferences import androidx.core.content.edit +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.ApplicationContext import io.element.android.libraries.di.DefaultPreferences +import io.element.android.libraries.matrix.api.core.UserId import javax.inject.Inject -class UnifiedPushStore @Inject constructor( +interface UnifiedPushStore { + fun getEndpoint(clientSecret: String): String? + fun storeUpEndpoint(clientSecret: String, endpoint: String?) + fun getPushGateway(clientSecret: String): String? + fun storePushGateway(clientSecret: String, gateway: String?) + fun getDistributorValue(userId: UserId): String? + fun setDistributorValue(userId: UserId, value: String) +} + +@ContributesBinding(AppScope::class) +class DefaultUnifiedPushStore @Inject constructor( @ApplicationContext val context: Context, @DefaultPreferences private val defaultPrefs: SharedPreferences, -) { +) : UnifiedPushStore { /** * Retrieves the UnifiedPush Endpoint. * * @param clientSecret the client secret, to identify the session * @return the UnifiedPush Endpoint or null if not received */ - fun getEndpoint(clientSecret: String): String? { + override fun getEndpoint(clientSecret: String): String? { return defaultPrefs.getString(PREFS_ENDPOINT_OR_TOKEN + clientSecret, null) } /** * Store UnifiedPush Endpoint to the SharedPrefs. * - * @param endpoint the endpoint to store * @param clientSecret the client secret, to identify the session + * @param endpoint the endpoint to store */ - fun storeUpEndpoint(endpoint: String?, clientSecret: String) { + override fun storeUpEndpoint(clientSecret: String, endpoint: String?) { defaultPrefs.edit { putString(PREFS_ENDPOINT_OR_TOKEN + clientSecret, endpoint) } @@ -55,24 +68,35 @@ class UnifiedPushStore @Inject constructor( * @param clientSecret the client secret, to identify the session * @return the Push Gateway or null if not defined */ - fun getPushGateway(clientSecret: String): String? { + override fun getPushGateway(clientSecret: String): String? { return defaultPrefs.getString(PREFS_PUSH_GATEWAY + clientSecret, null) } /** * Store Push Gateway to the SharedPrefs. * - * @param gateway the push gateway to store * @param clientSecret the client secret, to identify the session + * @param gateway the push gateway to store */ - fun storePushGateway(gateway: String?, clientSecret: String) { + override fun storePushGateway(clientSecret: String, gateway: String?) { defaultPrefs.edit { putString(PREFS_PUSH_GATEWAY + clientSecret, gateway) } } + override fun getDistributorValue(userId: UserId): String? { + return defaultPrefs.getString(PREFS_DISTRIBUTOR + userId, null) + } + + override fun setDistributorValue(userId: UserId, value: String) { + defaultPrefs.edit { + putString(PREFS_DISTRIBUTOR + userId, value) + } + } + companion object { private const val PREFS_ENDPOINT_OR_TOKEN = "UP_ENDPOINT_OR_TOKEN" private const val PREFS_PUSH_GATEWAY = "PUSH_GATEWAY" + private const val PREFS_DISTRIBUTOR = "DISTRIBUTOR" } } diff --git a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnregisterUnifiedPushUseCase.kt b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnregisterUnifiedPushUseCase.kt index b6030564ca..769f6507d5 100644 --- a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnregisterUnifiedPushUseCase.kt +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnregisterUnifiedPushUseCase.kt @@ -17,30 +17,40 @@ package io.element.android.libraries.pushproviders.unifiedpush import android.content.Context +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.ApplicationContext +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.pushproviders.api.PusherSubscriber import org.unifiedpush.android.connector.UnifiedPush import timber.log.Timber import javax.inject.Inject -class UnregisterUnifiedPushUseCase @Inject constructor( +interface UnregisterUnifiedPushUseCase { + suspend fun execute(matrixClient: MatrixClient, clientSecret: String): Result +} + +@ContributesBinding(AppScope::class) +class DefaultUnregisterUnifiedPushUseCase @Inject constructor( @ApplicationContext private val context: Context, - // private val pushDataStore: PushDataStore, private val unifiedPushStore: UnifiedPushStore, - // private val unifiedPushGatewayResolver: UnifiedPushGatewayResolver, -) { - suspend fun execute(clientSecret: String) { - // val mode = BackgroundSyncMode.FDROID_BACKGROUND_SYNC_MODE_FOR_REALTIME - // pushDataStore.setFdroidSyncBackgroundMode(mode) - try { - unifiedPushStore.getEndpoint(clientSecret)?.let { - Timber.d("Removing $it") - // TODO pushersManager?.unregisterPusher(it) - } - } catch (e: Exception) { - Timber.d(e, "Probably unregistering a non existing pusher") + private val pusherSubscriber: PusherSubscriber, +) : UnregisterUnifiedPushUseCase { + override suspend fun execute(matrixClient: MatrixClient, clientSecret: String): Result { + val endpoint = unifiedPushStore.getEndpoint(clientSecret) + val gateway = unifiedPushStore.getPushGateway(clientSecret) + if (endpoint == null || gateway == null) { + Timber.w("No endpoint or gateway found for client secret") + // Ensure we don't have any remaining data, but ignore this error + unifiedPushStore.storeUpEndpoint(clientSecret, null) + unifiedPushStore.storePushGateway(clientSecret, null) + return Result.success(Unit) } - unifiedPushStore.storeUpEndpoint(null, clientSecret) - unifiedPushStore.storePushGateway(null, clientSecret) - UnifiedPush.unregisterApp(context) + return pusherSubscriber.unregisterPusher(matrixClient, endpoint, gateway) + .onSuccess { + unifiedPushStore.storeUpEndpoint(clientSecret, null) + unifiedPushStore.storePushGateway(clientSecret, null) + UnifiedPush.unregisterApp(context) + } } } diff --git a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/VectorUnifiedPushMessagingReceiver.kt b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/VectorUnifiedPushMessagingReceiver.kt index e28d67ecf7..a52b1b0e6e 100644 --- a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/VectorUnifiedPushMessagingReceiver.kt +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/VectorUnifiedPushMessagingReceiver.kt @@ -21,8 +21,9 @@ import android.content.Intent import io.element.android.libraries.architecture.bindings import io.element.android.libraries.core.log.logger.LoggerTag import io.element.android.libraries.pushproviders.api.PushHandler +import io.element.android.libraries.pushproviders.unifiedpush.registration.EndpointRegistrationHandler +import io.element.android.libraries.pushproviders.unifiedpush.registration.RegistrationResult import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch import org.unifiedpush.android.connector.MessagingReceiver import timber.log.Timber @@ -37,8 +38,8 @@ class VectorUnifiedPushMessagingReceiver : MessagingReceiver() { @Inject lateinit var unifiedPushStore: UnifiedPushStore @Inject lateinit var unifiedPushGatewayResolver: UnifiedPushGatewayResolver @Inject lateinit var newGatewayHandler: UnifiedPushNewGatewayHandler - - private val coroutineScope = CoroutineScope(SupervisorJob()) + @Inject lateinit var endpointRegistrationHandler: EndpointRegistrationHandler + @Inject lateinit var coroutineScope: CoroutineScope override fun onReceive(context: Context, intent: Intent) { context.applicationContext.bindings().inject(this) @@ -69,20 +70,23 @@ class VectorUnifiedPushMessagingReceiver : MessagingReceiver() { * You should send the endpoint to your application server and sync for missing notifications. */ override fun onNewEndpoint(context: Context, endpoint: String, instance: String) { - Timber.tag(loggerTag.value).i("onNewEndpoint: adding $endpoint") - // If the endpoint has changed - // or the gateway has changed - if (unifiedPushStore.getEndpoint(instance) != endpoint) { - unifiedPushStore.storeUpEndpoint(endpoint, instance) - coroutineScope.launch { - val gateway = unifiedPushGatewayResolver.getGateway(endpoint) - unifiedPushStore.storePushGateway(gateway, instance) - gateway?.let { pushGateway -> - newGatewayHandler.handle(endpoint, pushGateway, instance) + Timber.tag(loggerTag.value).i("onNewEndpoint: $endpoint") + coroutineScope.launch { + val gateway = unifiedPushGatewayResolver.getGateway(endpoint) + unifiedPushStore.storePushGateway(instance, gateway) + val result = newGatewayHandler.handle(endpoint, gateway, instance) + .onFailure { + Timber.tag(loggerTag.value).e(it, "Failed to handle new gateway") } - } - } else { - Timber.tag(loggerTag.value).i("onNewEndpoint: skipped") + .onSuccess { + unifiedPushStore.storeUpEndpoint(instance, endpoint) + } + endpointRegistrationHandler.registrationDone( + RegistrationResult( + clientSecret = instance, + result = result, + ) + ) } guardServiceStarter.stop() } diff --git a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/registration/EndpointRegistrationHandler.kt b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/registration/EndpointRegistrationHandler.kt new file mode 100644 index 0000000000..504ae51916 --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/registration/EndpointRegistrationHandler.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.pushproviders.unifiedpush.registration + +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.di.SingleIn +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import javax.inject.Inject + +data class RegistrationResult( + val clientSecret: String, + val result: Result, +) + +@SingleIn(AppScope::class) +class EndpointRegistrationHandler @Inject constructor() { + private val _state = MutableSharedFlow() + val state: SharedFlow = _state + + suspend fun registrationDone(result: RegistrationResult) { + _state.emit(result) + } +} diff --git a/libraries/pushproviders/unifiedpush/src/main/res/values-it/translations.xml b/libraries/pushproviders/unifiedpush/src/main/res/values-it/translations.xml index a2ec4fa24a..a92663fd24 100644 --- a/libraries/pushproviders/unifiedpush/src/main/res/values-it/translations.xml +++ b/libraries/pushproviders/unifiedpush/src/main/res/values-it/translations.xml @@ -1,4 +1,10 @@ + "Assicurati che i distributori UnifiedPush siano disponibili." + "Nessun distributore di notifiche push trovato." + + "%1$d distributore trovato: %2$s." + "%1$d distributori trovati: %2$s." + "Controlla UnifiedPush" diff --git a/libraries/pushproviders/unifiedpush/src/main/res/values-pt-rBR/translations.xml b/libraries/pushproviders/unifiedpush/src/main/res/values-pt-rBR/translations.xml new file mode 100644 index 0000000000..8fec0ef977 --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/main/res/values-pt-rBR/translations.xml @@ -0,0 +1,10 @@ + + + "Certifica que os distribuidores UnifiedPush estão disponíveis." + "Nenhum distribuidor encontrado." + + "%1$d distribuidor encontrado: %2$s." + "%1$d distribuidores encontrados: %2$s." + + "Verificar UnifiedPush" + diff --git a/libraries/pushproviders/unifiedpush/src/main/res/values-ro/translations.xml b/libraries/pushproviders/unifiedpush/src/main/res/values-ro/translations.xml new file mode 100644 index 0000000000..fa96a07474 --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/main/res/values-ro/translations.xml @@ -0,0 +1,11 @@ + + + "Asigurați-vă că distribuitorii UnifiedPush sunt disponibili." + "Nu au fost găsiți distribuitori push." + + "%1$d distribuitor găsit: %2$s." + "%1$d distribuitori găsiți: %2$s." + "%1$d distribuitori găsiți: %2$s." + + "Verificați UnifiedPush" + diff --git a/libraries/pushproviders/unifiedpush/src/main/res/values-zh/translations.xml b/libraries/pushproviders/unifiedpush/src/main/res/values-zh/translations.xml new file mode 100644 index 0000000000..2fac432ca8 --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/main/res/values-zh/translations.xml @@ -0,0 +1,9 @@ + + + "确保 UnifiedPush distributor 可用。" + "未找到推送 distributor。" + + "找到 %1$d 个 distributors:%2$s" + + "检查 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 new file mode 100644 index 0000000000..cda9516b65 --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/DefaultRegisterUnifiedPushUseCaseTest.kt @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.pushproviders.unifiedpush + +import androidx.test.platform.app.InstrumentationRegistry +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.test.AN_EXCEPTION +import io.element.android.libraries.matrix.test.A_SECRET +import io.element.android.libraries.pushproviders.api.Distributor +import io.element.android.libraries.pushproviders.unifiedpush.registration.EndpointRegistrationHandler +import io.element.android.libraries.pushproviders.unifiedpush.registration.RegistrationResult +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class DefaultRegisterUnifiedPushUseCaseTest { + @Test + fun `test registration successful`() = runTest { + val endpointRegistrationHandler = EndpointRegistrationHandler() + val useCase = createDefaultRegisterUnifiedPushUseCase( + endpointRegistrationHandler = endpointRegistrationHandler + ) + val aDistributor = Distributor("aValue", "aName") + launch { + delay(100) + endpointRegistrationHandler.registrationDone(RegistrationResult(A_SECRET, Result.success(Unit))) + } + val result = useCase.execute(aDistributor, A_SECRET) + assertThat(result.isSuccess).isTrue() + } + + @Test + fun `test registration error`() = runTest { + val endpointRegistrationHandler = EndpointRegistrationHandler() + val useCase = createDefaultRegisterUnifiedPushUseCase( + endpointRegistrationHandler = endpointRegistrationHandler + ) + val aDistributor = Distributor("aValue", "aName") + launch { + delay(100) + endpointRegistrationHandler.registrationDone(RegistrationResult(A_SECRET, Result.failure(AN_EXCEPTION))) + } + val result = useCase.execute(aDistributor, A_SECRET) + assertThat(result.isSuccess).isFalse() + } + + @Test + fun `test registration timeout`() = runTest { + val endpointRegistrationHandler = EndpointRegistrationHandler() + val useCase = createDefaultRegisterUnifiedPushUseCase( + endpointRegistrationHandler = endpointRegistrationHandler + ) + val aDistributor = Distributor("aValue", "aName") + val result = useCase.execute(aDistributor, A_SECRET) + assertThat(result.isSuccess).isFalse() + } + + private fun TestScope.createDefaultRegisterUnifiedPushUseCase( + endpointRegistrationHandler: EndpointRegistrationHandler + ): DefaultRegisterUnifiedPushUseCase { + val context = InstrumentationRegistry.getInstrumentation().context + return DefaultRegisterUnifiedPushUseCase( + context = context, + endpointRegistrationHandler = endpointRegistrationHandler, + ) + } +} diff --git a/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/DefaultUnifiedPushGatewayResolverTest.kt b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/DefaultUnifiedPushGatewayResolverTest.kt new file mode 100644 index 0000000000..e180e36c38 --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/DefaultUnifiedPushGatewayResolverTest.kt @@ -0,0 +1,144 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.pushproviders.unifiedpush + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.test.AN_EXCEPTION +import io.element.android.libraries.pushproviders.unifiedpush.network.DiscoveryResponse +import io.element.android.libraries.pushproviders.unifiedpush.network.DiscoveryUnifiedPush +import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class DefaultUnifiedPushGatewayResolverTest { + private val matrixDiscoveryResponse = { + DiscoveryResponse( + unifiedpush = DiscoveryUnifiedPush( + gateway = "matrix" + ) + ) + } + + private val invalidDiscoveryResponse = { + DiscoveryResponse( + unifiedpush = DiscoveryUnifiedPush( + gateway = "" + ) + ) + } + + @Test + fun `when a custom url provide a correct matrix gateway, the custom url is returned`() = runTest { + val unifiedPushApiFactory = FakeUnifiedPushApiFactory( + discoveryResponse = matrixDiscoveryResponse + ) + val sut = createDefaultUnifiedPushGatewayResolver( + unifiedPushApiFactory = unifiedPushApiFactory + ) + val result = sut.getGateway("https://custom.url") + assertThat(unifiedPushApiFactory.baseUrlParameter).isEqualTo("https://custom.url") + assertThat(result).isEqualTo("https://custom.url/_matrix/push/v1/notify") + } + + @Test + fun `when a custom url with port provides a correct matrix gateway, the custom url is returned`() = runTest { + val unifiedPushApiFactory = FakeUnifiedPushApiFactory( + discoveryResponse = matrixDiscoveryResponse + ) + val sut = createDefaultUnifiedPushGatewayResolver( + unifiedPushApiFactory = unifiedPushApiFactory + ) + val result = sut.getGateway("https://custom.url:123") + assertThat(unifiedPushApiFactory.baseUrlParameter).isEqualTo("https://custom.url:123") + assertThat(result).isEqualTo("https://custom.url:123/_matrix/push/v1/notify") + } + + @Test + fun `when a custom url with port and path provides a correct matrix gateway, the custom url is returned`() = runTest { + val unifiedPushApiFactory = FakeUnifiedPushApiFactory( + discoveryResponse = matrixDiscoveryResponse + ) + val sut = createDefaultUnifiedPushGatewayResolver( + unifiedPushApiFactory = unifiedPushApiFactory + ) + val result = sut.getGateway("https://custom.url:123/some/path") + assertThat(unifiedPushApiFactory.baseUrlParameter).isEqualTo("https://custom.url:123") + assertThat(result).isEqualTo("https://custom.url:123/_matrix/push/v1/notify") + } + + @Test + fun `when a custom url with http scheme provides a correct matrix gateway, the custom url is returned`() = runTest { + val unifiedPushApiFactory = FakeUnifiedPushApiFactory( + discoveryResponse = matrixDiscoveryResponse + ) + val sut = createDefaultUnifiedPushGatewayResolver( + unifiedPushApiFactory = unifiedPushApiFactory + ) + val result = sut.getGateway("http://custom.url:123/some/path") + assertThat(unifiedPushApiFactory.baseUrlParameter).isEqualTo("http://custom.url:123") + assertThat(result).isEqualTo("http://custom.url:123/_matrix/push/v1/notify") + } + + @Test + fun `when a custom url is not reachable, the default url is returned`() = runTest { + val unifiedPushApiFactory = FakeUnifiedPushApiFactory( + discoveryResponse = { throw AN_EXCEPTION } + ) + val sut = createDefaultUnifiedPushGatewayResolver( + unifiedPushApiFactory = unifiedPushApiFactory + ) + val result = sut.getGateway("http://custom.url") + assertThat(unifiedPushApiFactory.baseUrlParameter).isEqualTo("http://custom.url") + assertThat(result).isEqualTo(UnifiedPushConfig.DEFAULT_PUSH_GATEWAY_HTTP_URL) + } + + @Test + fun `when a custom url is invalid, the default url is returned`() = runTest { + val unifiedPushApiFactory = FakeUnifiedPushApiFactory( + discoveryResponse = matrixDiscoveryResponse + ) + val sut = createDefaultUnifiedPushGatewayResolver( + unifiedPushApiFactory = unifiedPushApiFactory + ) + val result = sut.getGateway("invalid") + assertThat(unifiedPushApiFactory.baseUrlParameter).isNull() + assertThat(result).isEqualTo(UnifiedPushConfig.DEFAULT_PUSH_GATEWAY_HTTP_URL) + } + + @Test + fun `when a custom url provides a invalid matrix gateway, the default url is returned`() = runTest { + val unifiedPushApiFactory = FakeUnifiedPushApiFactory( + discoveryResponse = invalidDiscoveryResponse + ) + val sut = createDefaultUnifiedPushGatewayResolver( + unifiedPushApiFactory = unifiedPushApiFactory + ) + val result = sut.getGateway("https://custom.url") + assertThat(unifiedPushApiFactory.baseUrlParameter).isEqualTo("https://custom.url") + assertThat(result).isEqualTo(UnifiedPushConfig.DEFAULT_PUSH_GATEWAY_HTTP_URL) + } + + private fun TestScope.createDefaultUnifiedPushGatewayResolver( + unifiedPushApiFactory: UnifiedPushApiFactory = FakeUnifiedPushApiFactory( + discoveryResponse = { DiscoveryResponse() } + ) + ) = DefaultUnifiedPushGatewayResolver( + unifiedPushApiFactory = unifiedPushApiFactory, + coroutineDispatchers = testCoroutineDispatchers() + ) +} diff --git a/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/DefaultUnifiedPushNewGatewayHandlerTest.kt b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/DefaultUnifiedPushNewGatewayHandlerTest.kt new file mode 100644 index 0000000000..09637fcd21 --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/DefaultUnifiedPushNewGatewayHandlerTest.kt @@ -0,0 +1,143 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.pushproviders.unifiedpush + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService +import io.element.android.libraries.matrix.test.A_SECRET +import io.element.android.libraries.matrix.test.A_USER_ID +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService +import io.element.android.libraries.push.test.FakePusherSubscriber +import io.element.android.libraries.pushproviders.api.PusherSubscriber +import io.element.android.libraries.pushstore.api.UserPushStoreFactory +import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecret +import io.element.android.libraries.pushstore.test.userpushstore.FakeUserPushStore +import io.element.android.libraries.pushstore.test.userpushstore.FakeUserPushStoreFactory +import io.element.android.libraries.pushstore.test.userpushstore.clientsecret.FakePushClientSecret +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class DefaultUnifiedPushNewGatewayHandlerTest { + @Test + fun `error when fail to retrieve the session`() = runTest { + val defaultUnifiedPushNewGatewayHandler = createDefaultUnifiedPushNewGatewayHandler( + pushClientSecret = FakePushClientSecret( + getUserIdFromSecretResult = { null } + ) + ) + val result = defaultUnifiedPushNewGatewayHandler.handle( + endpoint = "aEndpoint", + pushGateway = "aPushGateway", + clientSecret = A_SECRET, + ) + assertThat(result.isFailure).isTrue() + assertThat(result.exceptionOrNull()).isInstanceOf(IllegalStateException::class.java) + assertThat(result.exceptionOrNull()?.message).isEqualTo("Unable to retrieve session") + } + + @Test + fun `error when the session is not using UnifiedPush`() = runTest { + val defaultUnifiedPushNewGatewayHandler = createDefaultUnifiedPushNewGatewayHandler( + pushClientSecret = FakePushClientSecret( + getUserIdFromSecretResult = { A_USER_ID } + ), + userPushStoreFactory = FakeUserPushStoreFactory( + userPushStore = { FakeUserPushStore(pushProviderName = "other") } + ) + ) + val result = defaultUnifiedPushNewGatewayHandler.handle( + endpoint = "aEndpoint", + pushGateway = "aPushGateway", + clientSecret = A_SECRET, + ) + assertThat(result.isFailure).isTrue() + assertThat(result.exceptionOrNull()).isInstanceOf(IllegalStateException::class.java) + assertThat(result.exceptionOrNull()?.message).isEqualTo("This session is not using UnifiedPush pusher") + } + + @Test + fun `error when the registration fails`() = runTest { + val aMatrixClient = FakeMatrixClient() + val defaultUnifiedPushNewGatewayHandler = createDefaultUnifiedPushNewGatewayHandler( + pushClientSecret = FakePushClientSecret( + getUserIdFromSecretResult = { A_USER_ID } + ), + userPushStoreFactory = FakeUserPushStoreFactory( + userPushStore = { FakeUserPushStore(pushProviderName = UnifiedPushConfig.NAME) } + ), + pusherSubscriber = FakePusherSubscriber( + registerPusherResult = { _, _, _ -> Result.failure(IllegalStateException("an error")) } + ), + matrixAuthenticationService = FakeMatrixAuthenticationService(matrixClientResult = { Result.success(aMatrixClient) }), + ) + val result = defaultUnifiedPushNewGatewayHandler.handle( + endpoint = "aEndpoint", + pushGateway = "aPushGateway", + clientSecret = A_SECRET, + ) + assertThat(result.isFailure).isTrue() + assertThat(result.exceptionOrNull()).isInstanceOf(IllegalStateException::class.java) + assertThat(result.exceptionOrNull()?.message).isEqualTo("an error") + } + + @Test + fun `happy path`() = runTest { + val aMatrixClient = FakeMatrixClient() + val lambda = lambdaRecorder { _: MatrixClient, _: String, _: String -> + Result.success(Unit) + } + val defaultUnifiedPushNewGatewayHandler = createDefaultUnifiedPushNewGatewayHandler( + pushClientSecret = FakePushClientSecret( + getUserIdFromSecretResult = { A_USER_ID } + ), + userPushStoreFactory = FakeUserPushStoreFactory( + userPushStore = { FakeUserPushStore(pushProviderName = UnifiedPushConfig.NAME) } + ), + pusherSubscriber = FakePusherSubscriber( + registerPusherResult = lambda + ), + matrixAuthenticationService = FakeMatrixAuthenticationService(matrixClientResult = { Result.success(aMatrixClient) }), + ) + val result = defaultUnifiedPushNewGatewayHandler.handle( + endpoint = "aEndpoint", + pushGateway = "aPushGateway", + clientSecret = A_SECRET, + ) + assertThat(result).isEqualTo(Result.success(Unit)) + lambda.assertions() + .isCalledOnce() + .with(value(aMatrixClient), value("aEndpoint"), value("aPushGateway")) + } + + private fun createDefaultUnifiedPushNewGatewayHandler( + pusherSubscriber: PusherSubscriber = FakePusherSubscriber(), + userPushStoreFactory: UserPushStoreFactory = FakeUserPushStoreFactory(), + pushClientSecret: PushClientSecret = FakePushClientSecret(), + matrixAuthenticationService: MatrixAuthenticationService = FakeMatrixAuthenticationService() + ): DefaultUnifiedPushNewGatewayHandler { + return DefaultUnifiedPushNewGatewayHandler( + pusherSubscriber = pusherSubscriber, + userPushStoreFactory = userPushStoreFactory, + pushClientSecret = pushClientSecret, + matrixAuthenticationService = matrixAuthenticationService + ) + } +} diff --git a/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/DefaultUnregisterUnifiedPushUseCaseTest.kt b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/DefaultUnregisterUnifiedPushUseCaseTest.kt new file mode 100644 index 0000000000..dfa03707cc --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/DefaultUnregisterUnifiedPushUseCaseTest.kt @@ -0,0 +1,139 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.pushproviders.unifiedpush + +import androidx.test.platform.app.InstrumentationRegistry +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.test.AN_EXCEPTION +import io.element.android.libraries.matrix.test.A_SECRET +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.push.test.FakePusherSubscriber +import io.element.android.libraries.pushproviders.api.PusherSubscriber +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class DefaultUnregisterUnifiedPushUseCaseTest { + @Test + fun `test un registration successful`() = runTest { + val lambda = lambdaRecorder { _: MatrixClient, _: String, _: String -> Result.success(Unit) } + val storeUpEndpointResult = lambdaRecorder { _: String, _: String? -> } + val storePushGatewayResult = lambdaRecorder { _: String, _: String? -> } + val matrixClient = FakeMatrixClient() + val useCase = createDefaultUnregisterUnifiedPushUseCase( + unifiedPushStore = FakeUnifiedPushStore( + getEndpointResult = { "aEndpoint" }, + getPushGatewayResult = { "aGateway" }, + storeUpEndpointResult = storeUpEndpointResult, + storePushGatewayResult = storePushGatewayResult, + ), + pusherSubscriber = FakePusherSubscriber( + unregisterPusherResult = lambda + ) + ) + val result = useCase.execute(matrixClient, A_SECRET) + assertThat(result.isSuccess).isTrue() + lambda.assertions() + .isCalledOnce() + .with(value(matrixClient), value("aEndpoint"), value("aGateway")) + storeUpEndpointResult.assertions() + .isCalledOnce() + .with(value(A_SECRET), value(null)) + storePushGatewayResult.assertions() + .isCalledOnce() + .with(value(A_SECRET), value(null)) + } + + @Test + fun `test un registration error - no endpoint - will not unregister but return success`() = runTest { + val matrixClient = FakeMatrixClient() + val storeUpEndpointResult = lambdaRecorder { _: String, _: String? -> } + val storePushGatewayResult = lambdaRecorder { _: String, _: String? -> } + val useCase = createDefaultUnregisterUnifiedPushUseCase( + unifiedPushStore = FakeUnifiedPushStore( + getEndpointResult = { null }, + getPushGatewayResult = { "aGateway" }, + storeUpEndpointResult = storeUpEndpointResult, + storePushGatewayResult = storePushGatewayResult, + ), + ) + val result = useCase.execute(matrixClient, A_SECRET) + assertThat(result.isSuccess).isTrue() + storeUpEndpointResult.assertions() + .isCalledOnce() + .with(value(A_SECRET), value(null)) + storePushGatewayResult.assertions() + .isCalledOnce() + .with(value(A_SECRET), value(null)) + } + + @Test + fun `test un registration error - no gateway - will not unregister but return success`() = runTest { + val matrixClient = FakeMatrixClient() + val storeUpEndpointResult = lambdaRecorder { _: String, _: String? -> } + val storePushGatewayResult = lambdaRecorder { _: String, _: String? -> } + val useCase = createDefaultUnregisterUnifiedPushUseCase( + unifiedPushStore = FakeUnifiedPushStore( + getEndpointResult = { "aEndpoint" }, + getPushGatewayResult = { null }, + storeUpEndpointResult = storeUpEndpointResult, + storePushGatewayResult = storePushGatewayResult, + ), + ) + val result = useCase.execute(matrixClient, A_SECRET) + assertThat(result.isSuccess).isTrue() + storeUpEndpointResult.assertions() + .isCalledOnce() + .with(value(A_SECRET), value(null)) + storePushGatewayResult.assertions() + .isCalledOnce() + .with(value(A_SECRET), value(null)) + } + + @Test + fun `test un registration error`() = runTest { + val matrixClient = FakeMatrixClient() + val useCase = createDefaultUnregisterUnifiedPushUseCase( + unifiedPushStore = FakeUnifiedPushStore( + getEndpointResult = { "aEndpoint" }, + getPushGatewayResult = { "aGateway" }, + ), + pusherSubscriber = FakePusherSubscriber( + unregisterPusherResult = { _, _, _ -> Result.failure(AN_EXCEPTION) } + ) + ) + val result = useCase.execute(matrixClient, A_SECRET) + assertThat(result.isFailure).isTrue() + } + + private fun createDefaultUnregisterUnifiedPushUseCase( + unifiedPushStore: UnifiedPushStore = FakeUnifiedPushStore(), + pusherSubscriber: PusherSubscriber = FakePusherSubscriber() + ): DefaultUnregisterUnifiedPushUseCase { + val context = InstrumentationRegistry.getInstrumentation().context + return DefaultUnregisterUnifiedPushUseCase( + context = context, + unifiedPushStore = unifiedPushStore, + pusherSubscriber = pusherSubscriber + ) + } +} diff --git a/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/FakeRegisterUnifiedPushUseCase.kt b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/FakeRegisterUnifiedPushUseCase.kt new file mode 100644 index 0000000000..1800903dea --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/FakeRegisterUnifiedPushUseCase.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.pushproviders.unifiedpush + +import io.element.android.libraries.pushproviders.api.Distributor +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeRegisterUnifiedPushUseCase( + private val result: (Distributor, String) -> Result = { _, _ -> lambdaError() } +) : RegisterUnifiedPushUseCase { + override suspend fun execute(distributor: Distributor, clientSecret: String): Result { + return result(distributor, clientSecret) + } +} diff --git a/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/FakeUnifiedPushApiFactory.kt b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/FakeUnifiedPushApiFactory.kt new file mode 100644 index 0000000000..e0d7808505 --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/FakeUnifiedPushApiFactory.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.pushproviders.unifiedpush + +import io.element.android.libraries.pushproviders.unifiedpush.network.DiscoveryResponse +import io.element.android.libraries.pushproviders.unifiedpush.network.UnifiedPushApi + +class FakeUnifiedPushApiFactory( + private val discoveryResponse: () -> DiscoveryResponse +) : UnifiedPushApiFactory { + var baseUrlParameter: String? = null + private set + + override fun create(baseUrl: String): UnifiedPushApi { + baseUrlParameter = baseUrl + return FakeUnifiedPushApi(discoveryResponse) + } +} + +class FakeUnifiedPushApi( + private val discoveryResponse: () -> DiscoveryResponse +) : UnifiedPushApi { + override suspend fun discover(): DiscoveryResponse { + return discoveryResponse() + } +} diff --git a/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/FakeUnifiedPushGatewayResolver.kt b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/FakeUnifiedPushGatewayResolver.kt new file mode 100644 index 0000000000..0bc52fbae8 --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/FakeUnifiedPushGatewayResolver.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.pushproviders.unifiedpush + +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeUnifiedPushGatewayResolver( + private val getGatewayResult: (String) -> String = { lambdaError() }, +) : UnifiedPushGatewayResolver { + override suspend fun getGateway(endpoint: String): String { + return getGatewayResult(endpoint) + } +} diff --git a/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/FakeUnifiedPushNewGatewayHandler.kt b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/FakeUnifiedPushNewGatewayHandler.kt new file mode 100644 index 0000000000..b8d70baada --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/FakeUnifiedPushNewGatewayHandler.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.pushproviders.unifiedpush + +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeUnifiedPushNewGatewayHandler( + private val handleResult: suspend (String, String, String) -> Result = { _, _, _ -> lambdaError() }, +) : UnifiedPushNewGatewayHandler { + override suspend fun handle(endpoint: String, pushGateway: String, clientSecret: String): Result { + return handleResult(endpoint, pushGateway, clientSecret) + } +} diff --git a/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/FakeUnifiedPushStore.kt b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/FakeUnifiedPushStore.kt new file mode 100644 index 0000000000..aa381d9535 --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/FakeUnifiedPushStore.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.pushproviders.unifiedpush + +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeUnifiedPushStore( + private val getEndpointResult: (String) -> String? = { lambdaError() }, + private val storeUpEndpointResult: (String, String?) -> Unit = { _, _ -> lambdaError() }, + private val getPushGatewayResult: (String) -> String? = { lambdaError() }, + private val storePushGatewayResult: (String, String?) -> Unit = { _, _ -> lambdaError() }, + private val getDistributorValueResult: (UserId) -> String? = { lambdaError() }, + private val setDistributorValueResult: (UserId, String) -> Unit = { _, _ -> lambdaError() }, +) : UnifiedPushStore { + override fun getEndpoint(clientSecret: String): String? { + return getEndpointResult(clientSecret) + } + + override fun storeUpEndpoint(clientSecret: String, endpoint: String?) { + storeUpEndpointResult(clientSecret, endpoint) + } + + override fun getPushGateway(clientSecret: String): String? { + return getPushGatewayResult(clientSecret) + } + + override fun storePushGateway(clientSecret: String, gateway: String?) { + storePushGatewayResult(clientSecret, gateway) + } + + override fun getDistributorValue(userId: UserId): String? { + return getDistributorValueResult(userId) + } + + override fun setDistributorValue(userId: UserId, value: String) { + setDistributorValueResult(userId, value) + } +} diff --git a/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/FakeUnregisterUnifiedPushUseCase.kt b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/FakeUnregisterUnifiedPushUseCase.kt new file mode 100644 index 0000000000..9f3293420a --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/FakeUnregisterUnifiedPushUseCase.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.pushproviders.unifiedpush + +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeUnregisterUnifiedPushUseCase( + private val result: (MatrixClient, String) -> Result = { _, _ -> lambdaError() } +) : UnregisterUnifiedPushUseCase { + override suspend fun execute(matrixClient: MatrixClient, clientSecret: String): Result { + return result(matrixClient, clientSecret) + } +} diff --git a/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushParserTest.kt b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushParserTest.kt index bbccc92581..da710037c4 100644 --- a/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushParserTest.kt +++ b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushParserTest.kt @@ -82,9 +82,8 @@ class UnifiedPushParserTest { } companion object { - private val UNIFIED_PUSH_DATA = + val UNIFIED_PUSH_DATA = "{\"notification\":{\"event_id\":\"$AN_EVENT_ID\",\"room_id\":\"$A_ROOM_ID\",\"counts\":{\"unread\":1},\"prio\":\"high\"}}" - // TODO Check client secret format? } } diff --git a/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushProviderTest.kt b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushProviderTest.kt new file mode 100644 index 0000000000..826f08a1b0 --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushProviderTest.kt @@ -0,0 +1,322 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.pushproviders.unifiedpush + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.test.AN_EXCEPTION +import io.element.android.libraries.matrix.test.A_SECRET +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.pushproviders.api.CurrentUserPushConfig +import io.element.android.libraries.pushproviders.api.Distributor +import io.element.android.libraries.pushproviders.unifiedpush.troubleshoot.FakeUnifiedPushDistributorProvider +import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecret +import io.element.android.libraries.pushstore.test.userpushstore.clientsecret.FakePushClientSecret +import io.element.android.services.appnavstate.api.AppNavigationState +import io.element.android.services.appnavstate.api.AppNavigationStateService +import io.element.android.services.appnavstate.api.NavigationState +import io.element.android.services.appnavstate.test.FakeAppNavigationStateService +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class UnifiedPushProviderTest { + @Test + fun `test index and name`() { + val unifiedPushProvider = createUnifiedPushProvider() + assertThat(unifiedPushProvider.name).isEqualTo(UnifiedPushConfig.NAME) + assertThat(unifiedPushProvider.index).isEqualTo(UnifiedPushConfig.INDEX) + } + + @Test + fun `getDistributors return the available distributors`() { + val unifiedPushProvider = createUnifiedPushProvider( + unifiedPushDistributorProvider = FakeUnifiedPushDistributorProvider( + getDistributorsResult = listOf( + Distributor("value", "Name"), + ) + ) + ) + val result = unifiedPushProvider.getDistributors() + assertThat(result).containsExactly(Distributor("value", "Name")) + assertThat(unifiedPushProvider.isAvailable()).isTrue() + } + + @Test + fun `getDistributors return empty`() { + val unifiedPushProvider = createUnifiedPushProvider( + unifiedPushDistributorProvider = FakeUnifiedPushDistributorProvider( + getDistributorsResult = emptyList() + ) + ) + val result = unifiedPushProvider.getDistributors() + assertThat(result).isEmpty() + assertThat(unifiedPushProvider.isAvailable()).isFalse() + } + + @Test + fun `register ok`() = runTest { + val getSecretForUserResultLambda = lambdaRecorder { A_SECRET } + val executeLambda = lambdaRecorder> { _, _ -> Result.success(Unit) } + val setDistributorValueResultLambda = lambdaRecorder { _, _ -> } + val unifiedPushProvider = createUnifiedPushProvider( + pushClientSecret = FakePushClientSecret( + getSecretForUserResult = getSecretForUserResultLambda, + ), + registerUnifiedPushUseCase = FakeRegisterUnifiedPushUseCase( + result = executeLambda, + ), + unifiedPushStore = FakeUnifiedPushStore( + setDistributorValueResult = setDistributorValueResultLambda, + ), + ) + val result = unifiedPushProvider.registerWith(FakeMatrixClient(), Distributor("value", "Name")) + assertThat(result).isEqualTo(Result.success(Unit)) + getSecretForUserResultLambda.assertions() + .isCalledOnce() + .with(value(A_SESSION_ID)) + executeLambda.assertions() + .isCalledOnce() + .with(value(Distributor("value", "Name")), value(A_SECRET)) + setDistributorValueResultLambda.assertions() + .isCalledOnce() + .with(value(A_SESSION_ID), value("value")) + } + + @Test + fun `register ko`() = runTest { + val getSecretForUserResultLambda = lambdaRecorder { A_SECRET } + val executeLambda = lambdaRecorder> { _, _ -> Result.failure(AN_EXCEPTION) } + val setDistributorValueResultLambda = lambdaRecorder(ensureNeverCalled = true) { _, _ -> } + val unifiedPushProvider = createUnifiedPushProvider( + pushClientSecret = FakePushClientSecret( + getSecretForUserResult = getSecretForUserResultLambda, + ), + registerUnifiedPushUseCase = FakeRegisterUnifiedPushUseCase( + result = executeLambda, + ), + unifiedPushStore = FakeUnifiedPushStore( + setDistributorValueResult = setDistributorValueResultLambda, + ), + ) + val result = unifiedPushProvider.registerWith(FakeMatrixClient(), Distributor("value", "Name")) + assertThat(result).isEqualTo(Result.failure(AN_EXCEPTION)) + getSecretForUserResultLambda.assertions() + .isCalledOnce() + .with(value(A_SESSION_ID)) + executeLambda.assertions() + .isCalledOnce() + .with(value(Distributor("value", "Name")), value(A_SECRET)) + } + + @Test + fun `unregister ok`() = runTest { + val matrixClient = FakeMatrixClient() + val getSecretForUserResultLambda = lambdaRecorder { A_SECRET } + val executeLambda = lambdaRecorder> { _, _ -> Result.success(Unit) } + val unifiedPushProvider = createUnifiedPushProvider( + pushClientSecret = FakePushClientSecret( + getSecretForUserResult = getSecretForUserResultLambda, + ), + unRegisterUnifiedPushUseCase = FakeUnregisterUnifiedPushUseCase( + result = executeLambda, + ), + ) + val result = unifiedPushProvider.unregister(matrixClient) + assertThat(result).isEqualTo(Result.success(Unit)) + getSecretForUserResultLambda.assertions() + .isCalledOnce() + .with(value(A_SESSION_ID)) + executeLambda.assertions() + .isCalledOnce() + .with(value(matrixClient), value(A_SECRET)) + } + + @Test + fun `unregister ko`() = runTest { + val matrixClient = FakeMatrixClient() + val getSecretForUserResultLambda = lambdaRecorder { A_SECRET } + val executeLambda = lambdaRecorder> { _, _ -> Result.failure(AN_EXCEPTION) } + val unifiedPushProvider = createUnifiedPushProvider( + pushClientSecret = FakePushClientSecret( + getSecretForUserResult = getSecretForUserResultLambda, + ), + unRegisterUnifiedPushUseCase = FakeUnregisterUnifiedPushUseCase( + result = executeLambda, + ), + ) + val result = unifiedPushProvider.unregister(matrixClient) + assertThat(result).isEqualTo(Result.failure(AN_EXCEPTION)) + getSecretForUserResultLambda.assertions() + .isCalledOnce() + .with(value(A_SESSION_ID)) + executeLambda.assertions() + .isCalledOnce() + .with(value(matrixClient), value(A_SECRET)) + } + + @Test + fun `getCurrentDistributor ok`() = runTest { + val distributor = Distributor("value", "Name") + val matrixClient = FakeMatrixClient() + val unifiedPushProvider = createUnifiedPushProvider( + unifiedPushStore = FakeUnifiedPushStore( + getDistributorValueResult = { distributor.value } + ), + unifiedPushDistributorProvider = FakeUnifiedPushDistributorProvider( + getDistributorsResult = listOf( + Distributor("value2", "Name2"), + distributor, + ) + ) + ) + val result = unifiedPushProvider.getCurrentDistributor(matrixClient) + assertThat(result).isEqualTo(distributor) + } + + @Test + fun `getCurrentDistributor not know`() = runTest { + val distributor = Distributor("value", "Name") + val matrixClient = FakeMatrixClient() + val unifiedPushProvider = createUnifiedPushProvider( + unifiedPushStore = FakeUnifiedPushStore( + getDistributorValueResult = { "unknown" } + ), + unifiedPushDistributorProvider = FakeUnifiedPushDistributorProvider( + getDistributorsResult = listOf( + distributor, + ) + ) + ) + val result = unifiedPushProvider.getCurrentDistributor(matrixClient) + assertThat(result).isNull() + } + + @Test + fun `getCurrentDistributor not found`() = runTest { + val distributor = Distributor("value", "Name") + val matrixClient = FakeMatrixClient() + val unifiedPushProvider = createUnifiedPushProvider( + unifiedPushStore = FakeUnifiedPushStore( + getDistributorValueResult = { distributor.value } + ), + unifiedPushDistributorProvider = FakeUnifiedPushDistributorProvider( + getDistributorsResult = emptyList() + ) + ) + val result = unifiedPushProvider.getCurrentDistributor(matrixClient) + assertThat(result).isNull() + } + + @Test + fun `getCurrentUserPushConfig no session`() = runTest { + val unifiedPushProvider = createUnifiedPushProvider() + val result = unifiedPushProvider.getCurrentUserPushConfig() + assertThat(result).isNull() + } + + @Test + fun `getCurrentUserPushConfig no push gateway`() = runTest { + val unifiedPushProvider = createUnifiedPushProvider( + appNavigationStateService = FakeAppNavigationStateService( + appNavigationState = MutableStateFlow( + AppNavigationState( + navigationState = NavigationState.Session(owner = "owner", sessionId = A_SESSION_ID), + isInForeground = true + ) + ) + ), + pushClientSecret = FakePushClientSecret( + getSecretForUserResult = { A_SECRET } + ), + unifiedPushStore = FakeUnifiedPushStore( + getPushGatewayResult = { null } + ), + ) + val result = unifiedPushProvider.getCurrentUserPushConfig() + assertThat(result).isNull() + } + + @Test + fun `getCurrentUserPushConfig no push key`() = runTest { + val unifiedPushProvider = createUnifiedPushProvider( + appNavigationStateService = FakeAppNavigationStateService( + appNavigationState = MutableStateFlow( + AppNavigationState( + navigationState = NavigationState.Session(owner = "owner", sessionId = A_SESSION_ID), + isInForeground = true + ) + ) + ), + pushClientSecret = FakePushClientSecret( + getSecretForUserResult = { A_SECRET } + ), + unifiedPushStore = FakeUnifiedPushStore( + getPushGatewayResult = { "aPushGateway" }, + getEndpointResult = { null } + ), + ) + val result = unifiedPushProvider.getCurrentUserPushConfig() + assertThat(result).isNull() + } + + @Test + fun `getCurrentUserPushConfig ok`() = runTest { + val unifiedPushProvider = createUnifiedPushProvider( + appNavigationStateService = FakeAppNavigationStateService( + appNavigationState = MutableStateFlow( + AppNavigationState( + navigationState = NavigationState.Session(owner = "owner", sessionId = A_SESSION_ID), + isInForeground = true + ) + ) + ), + pushClientSecret = FakePushClientSecret( + getSecretForUserResult = { A_SECRET } + ), + unifiedPushStore = FakeUnifiedPushStore( + getPushGatewayResult = { "aPushGateway" }, + getEndpointResult = { "aEndpoint" } + ), + ) + val result = unifiedPushProvider.getCurrentUserPushConfig() + assertThat(result).isEqualTo(CurrentUserPushConfig("aPushGateway", "aEndpoint")) + } + + private fun createUnifiedPushProvider( + unifiedPushDistributorProvider: UnifiedPushDistributorProvider = FakeUnifiedPushDistributorProvider(), + registerUnifiedPushUseCase: RegisterUnifiedPushUseCase = FakeRegisterUnifiedPushUseCase(), + unRegisterUnifiedPushUseCase: UnregisterUnifiedPushUseCase = FakeUnregisterUnifiedPushUseCase(), + pushClientSecret: PushClientSecret = FakePushClientSecret(), + unifiedPushStore: UnifiedPushStore = FakeUnifiedPushStore(), + appNavigationStateService: AppNavigationStateService = FakeAppNavigationStateService(), + ): UnifiedPushProvider { + return UnifiedPushProvider( + unifiedPushDistributorProvider = unifiedPushDistributorProvider, + registerUnifiedPushUseCase = registerUnifiedPushUseCase, + unRegisterUnifiedPushUseCase = unRegisterUnifiedPushUseCase, + pushClientSecret = pushClientSecret, + unifiedPushStore = unifiedPushStore, + appNavigationStateService = appNavigationStateService + ) + } +} diff --git a/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/VectorUnifiedPushMessagingReceiverTest.kt b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/VectorUnifiedPushMessagingReceiverTest.kt new file mode 100644 index 0000000000..e2054caacb --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/VectorUnifiedPushMessagingReceiverTest.kt @@ -0,0 +1,196 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package io.element.android.libraries.pushproviders.unifiedpush + +import androidx.test.platform.app.InstrumentationRegistry +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.test.AN_EVENT_ID +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_SECRET +import io.element.android.libraries.push.test.test.FakePushHandler +import io.element.android.libraries.pushproviders.api.PushData +import io.element.android.libraries.pushproviders.api.PushHandler +import io.element.android.libraries.pushproviders.unifiedpush.registration.EndpointRegistrationHandler +import io.element.android.libraries.pushproviders.unifiedpush.registration.RegistrationResult +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class VectorUnifiedPushMessagingReceiverTest { + @Test + fun `onUnregistered does nothing`() = runTest { + val context = InstrumentationRegistry.getInstrumentation().context + val vectorUnifiedPushMessagingReceiver = createVectorUnifiedPushMessagingReceiver() + vectorUnifiedPushMessagingReceiver.onUnregistered(context, A_SECRET) + } + + @Test + fun `onRegistrationFailed does nothing`() = runTest { + val context = InstrumentationRegistry.getInstrumentation().context + val vectorUnifiedPushMessagingReceiver = createVectorUnifiedPushMessagingReceiver() + vectorUnifiedPushMessagingReceiver.onRegistrationFailed(context, A_SECRET) + } + + @Test + fun `onMessage valid invoke the push handler`() = runTest { + val context = InstrumentationRegistry.getInstrumentation().context + val pushHandlerResult = lambdaRecorder {} + val vectorUnifiedPushMessagingReceiver = createVectorUnifiedPushMessagingReceiver( + pushHandler = FakePushHandler( + handleResult = pushHandlerResult + ), + ) + vectorUnifiedPushMessagingReceiver.onMessage(context, UnifiedPushParserTest.UNIFIED_PUSH_DATA.toByteArray(), A_SECRET) + advanceUntilIdle() + pushHandlerResult.assertions() + .isCalledOnce() + .with( + value( + PushData( + eventId = AN_EVENT_ID, + roomId = A_ROOM_ID, + unread = 1, + clientSecret = A_SECRET + ) + ) + ) + } + + @Test + fun `onMessage invalid does not invoke the push handler`() = runTest { + val context = InstrumentationRegistry.getInstrumentation().context + val pushHandlerResult = lambdaRecorder {} + val vectorUnifiedPushMessagingReceiver = createVectorUnifiedPushMessagingReceiver( + pushHandler = FakePushHandler( + handleResult = pushHandlerResult + ), + ) + vectorUnifiedPushMessagingReceiver.onMessage(context, "".toByteArray(), A_SECRET) + advanceUntilIdle() + pushHandlerResult.assertions() + .isNeverCalled() + } + + @Test + fun `onNewEndpoint run the expected tasks`() = runTest { + val context = InstrumentationRegistry.getInstrumentation().context + val storePushGatewayResult = lambdaRecorder { _, _ -> } + val storeUpEndpointResult = lambdaRecorder { _, _ -> } + val unifiedPushStore = FakeUnifiedPushStore( + storePushGatewayResult = storePushGatewayResult, + storeUpEndpointResult = storeUpEndpointResult, + ) + val endpointRegistrationHandler = EndpointRegistrationHandler() + val handleResult = lambdaRecorder> { _, _, _ -> Result.success(Unit) } + val unifiedPushNewGatewayHandler = FakeUnifiedPushNewGatewayHandler( + handleResult = handleResult + ) + val vectorUnifiedPushMessagingReceiver = createVectorUnifiedPushMessagingReceiver( + unifiedPushStore = unifiedPushStore, + unifiedPushGatewayResolver = FakeUnifiedPushGatewayResolver( + getGatewayResult = { "aGateway" } + ), + endpointRegistrationHandler = endpointRegistrationHandler, + unifiedPushNewGatewayHandler = unifiedPushNewGatewayHandler, + ) + endpointRegistrationHandler.state.test { + vectorUnifiedPushMessagingReceiver.onNewEndpoint(context, "anEndpoint", A_SECRET) + advanceUntilIdle() + assertThat(awaitItem()).isEqualTo( + RegistrationResult( + clientSecret = A_SECRET, + result = Result.success(Unit) + ) + ) + } + storePushGatewayResult.assertions() + .isCalledOnce() + .with(value(A_SECRET), value("aGateway")) + storeUpEndpointResult.assertions() + .isCalledOnce() + .with(value(A_SECRET), value("anEndpoint")) + } + + @Test + fun `onNewEndpoint, if registration fails, the endpoint should not be stored`() = runTest { + val context = InstrumentationRegistry.getInstrumentation().context + val storePushGatewayResult = lambdaRecorder { _, _ -> } + val storeUpEndpointResult = lambdaRecorder { _, _ -> } + val unifiedPushStore = FakeUnifiedPushStore( + storePushGatewayResult = storePushGatewayResult, + storeUpEndpointResult = storeUpEndpointResult, + ) + val endpointRegistrationHandler = EndpointRegistrationHandler() + val handleResult = lambdaRecorder> { _, _, _ -> Result.failure(AN_EXCEPTION) } + val unifiedPushNewGatewayHandler = FakeUnifiedPushNewGatewayHandler( + handleResult = handleResult + ) + val vectorUnifiedPushMessagingReceiver = createVectorUnifiedPushMessagingReceiver( + unifiedPushStore = unifiedPushStore, + unifiedPushGatewayResolver = FakeUnifiedPushGatewayResolver( + getGatewayResult = { "aGateway" } + ), + endpointRegistrationHandler = endpointRegistrationHandler, + unifiedPushNewGatewayHandler = unifiedPushNewGatewayHandler, + ) + endpointRegistrationHandler.state.test { + vectorUnifiedPushMessagingReceiver.onNewEndpoint(context, "anEndpoint", A_SECRET) + advanceUntilIdle() + assertThat(awaitItem()).isEqualTo( + RegistrationResult( + clientSecret = A_SECRET, + result = Result.failure(AN_EXCEPTION) + ) + ) + } + storePushGatewayResult.assertions() + .isCalledOnce() + .with(value(A_SECRET), value("aGateway")) + storeUpEndpointResult.assertions() + .isNeverCalled() + } + + private fun TestScope.createVectorUnifiedPushMessagingReceiver( + pushHandler: PushHandler = FakePushHandler(), + unifiedPushStore: UnifiedPushStore = FakeUnifiedPushStore(), + unifiedPushGatewayResolver: UnifiedPushGatewayResolver = FakeUnifiedPushGatewayResolver(), + unifiedPushNewGatewayHandler: UnifiedPushNewGatewayHandler = FakeUnifiedPushNewGatewayHandler(), + endpointRegistrationHandler: EndpointRegistrationHandler = EndpointRegistrationHandler(), + ): VectorUnifiedPushMessagingReceiver { + return VectorUnifiedPushMessagingReceiver().apply { + this.pushParser = UnifiedPushParser() + this.pushHandler = pushHandler + this.guardServiceStarter = NoopGuardServiceStarter() + this.unifiedPushStore = unifiedPushStore + this.unifiedPushGatewayResolver = unifiedPushGatewayResolver + this.newGatewayHandler = unifiedPushNewGatewayHandler + this.endpointRegistrationHandler = endpointRegistrationHandler + this.coroutineScope = this@createVectorUnifiedPushMessagingReceiver + } + } +} diff --git a/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/troubleshoot/UnifiedPushTestTest.kt b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/troubleshoot/UnifiedPushTestTest.kt index 117e8b7457..9f79f7363b 100644 --- a/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/troubleshoot/UnifiedPushTestTest.kt +++ b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/troubleshoot/UnifiedPushTestTest.kt @@ -19,7 +19,9 @@ package io.element.android.libraries.pushproviders.unifiedpush.troubleshoot import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import io.element.android.libraries.pushproviders.api.Distributor +import io.element.android.libraries.pushproviders.unifiedpush.UnifiedPushConfig import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootTestState +import io.element.android.libraries.troubleshoot.api.test.TestFilterData import io.element.android.services.toolbox.test.strings.FakeStringProvider import kotlinx.coroutines.launch import kotlinx.coroutines.test.runTest @@ -81,4 +83,15 @@ class UnifiedPushTestTest { assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.Success) } } + + @Test + fun `test isRelevant`() { + val sut = UnifiedPushTest( + unifiedPushDistributorProvider = FakeUnifiedPushDistributorProvider(), + openDistributorWebPageAction = FakeOpenDistributorWebPageAction(), + stringProvider = FakeStringProvider(), + ) + assertThat(sut.isRelevant(TestFilterData(currentPushProviderName = UnifiedPushConfig.NAME))).isTrue() + assertThat(sut.isRelevant(TestFilterData(currentPushProviderName = "other"))).isFalse() + } } diff --git a/libraries/pushstore/api/src/main/kotlin/io/element/android/libraries/pushstore/api/UserPushStore.kt b/libraries/pushstore/api/src/main/kotlin/io/element/android/libraries/pushstore/api/UserPushStore.kt index fcfd6475c3..d24cc5ff5e 100644 --- a/libraries/pushstore/api/src/main/kotlin/io/element/android/libraries/pushstore/api/UserPushStore.kt +++ b/libraries/pushstore/api/src/main/kotlin/io/element/android/libraries/pushstore/api/UserPushStore.kt @@ -24,7 +24,7 @@ interface UserPushStore { suspend fun getPushProviderName(): String? suspend fun setPushProviderName(value: String) suspend fun getCurrentRegisteredPushKey(): String? - suspend fun setCurrentRegisteredPushKey(value: String) + suspend fun setCurrentRegisteredPushKey(value: String?) fun getNotificationEnabledForDevice(): Flow suspend fun setNotificationEnabledForDevice(enabled: Boolean) diff --git a/libraries/pushstore/impl/build.gradle.kts b/libraries/pushstore/impl/build.gradle.kts index 28e53e011c..69f0f21e55 100644 --- a/libraries/pushstore/impl/build.gradle.kts +++ b/libraries/pushstore/impl/build.gradle.kts @@ -49,6 +49,7 @@ dependencies { testImplementation(libs.coroutines.test) testImplementation(projects.libraries.matrix.test) testImplementation(projects.services.appnavstate.test) + testImplementation(projects.libraries.pushstore.test) testImplementation(projects.libraries.sessionStorage.test) androidTestImplementation(libs.coroutines.test) diff --git a/libraries/pushstore/impl/src/main/kotlin/io/element/android/libraries/pushstore/impl/UserPushStoreDataStore.kt b/libraries/pushstore/impl/src/main/kotlin/io/element/android/libraries/pushstore/impl/UserPushStoreDataStore.kt index f7a159f6c3..cfcc9e3da4 100644 --- a/libraries/pushstore/impl/src/main/kotlin/io/element/android/libraries/pushstore/impl/UserPushStoreDataStore.kt +++ b/libraries/pushstore/impl/src/main/kotlin/io/element/android/libraries/pushstore/impl/UserPushStoreDataStore.kt @@ -76,9 +76,13 @@ class UserPushStoreDataStore( return context.dataStore.data.first()[currentPushKey] } - override suspend fun setCurrentRegisteredPushKey(value: String) { + override suspend fun setCurrentRegisteredPushKey(value: String?) { context.dataStore.edit { - it[currentPushKey] = value + if (value == null) { + it.remove(currentPushKey) + } else { + it[currentPushKey] = value + } } } diff --git a/libraries/pushstore/impl/src/test/kotlin/io/element/android/libraries/pushstore/impl/clientsecret/PushClientSecretImplTest.kt b/libraries/pushstore/impl/src/test/kotlin/io/element/android/libraries/pushstore/impl/clientsecret/PushClientSecretImplTest.kt index dc0e5b3651..0277feef45 100644 --- a/libraries/pushstore/impl/src/test/kotlin/io/element/android/libraries/pushstore/impl/clientsecret/PushClientSecretImplTest.kt +++ b/libraries/pushstore/impl/src/test/kotlin/io/element/android/libraries/pushstore/impl/clientsecret/PushClientSecretImplTest.kt @@ -18,6 +18,7 @@ package io.element.android.libraries.pushstore.impl.clientsecret import com.google.common.truth.Truth.assertThat import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.pushstore.test.userpushstore.clientsecret.InMemoryPushClientSecretStore import io.element.android.libraries.sessionstorage.test.observer.NoOpSessionObserver import kotlinx.coroutines.test.runTest import org.junit.Test diff --git a/libraries/pushstore/test/src/main/kotlin/com/element/android/libraries/pushstore/test/userpushstore/FakeUserPushStore.kt b/libraries/pushstore/test/src/main/kotlin/io/element/android/libraries/pushstore/test/userpushstore/FakeUserPushStore.kt similarity index 93% rename from libraries/pushstore/test/src/main/kotlin/com/element/android/libraries/pushstore/test/userpushstore/FakeUserPushStore.kt rename to libraries/pushstore/test/src/main/kotlin/io/element/android/libraries/pushstore/test/userpushstore/FakeUserPushStore.kt index 2afbf3210e..112a752368 100644 --- a/libraries/pushstore/test/src/main/kotlin/com/element/android/libraries/pushstore/test/userpushstore/FakeUserPushStore.kt +++ b/libraries/pushstore/test/src/main/kotlin/io/element/android/libraries/pushstore/test/userpushstore/FakeUserPushStore.kt @@ -14,14 +14,15 @@ * limitations under the License. */ -package com.element.android.libraries.pushstore.test.userpushstore +package io.element.android.libraries.pushstore.test.userpushstore import io.element.android.libraries.pushstore.api.UserPushStore import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow -class FakeUserPushStore : UserPushStore { +class FakeUserPushStore( private var pushProviderName: String? = null +) : UserPushStore { private var currentRegisteredPushKey: String? = null private val notificationEnabledForDevice = MutableStateFlow(true) override suspend fun getPushProviderName(): String? { @@ -36,7 +37,7 @@ class FakeUserPushStore : UserPushStore { return currentRegisteredPushKey } - override suspend fun setCurrentRegisteredPushKey(value: String) { + override suspend fun setCurrentRegisteredPushKey(value: String?) { currentRegisteredPushKey = value } diff --git a/libraries/pushstore/test/src/main/kotlin/com/element/android/libraries/pushstore/test/userpushstore/FakeUserPushStoreFactory.kt b/libraries/pushstore/test/src/main/kotlin/io/element/android/libraries/pushstore/test/userpushstore/FakeUserPushStoreFactory.kt similarity index 78% rename from libraries/pushstore/test/src/main/kotlin/com/element/android/libraries/pushstore/test/userpushstore/FakeUserPushStoreFactory.kt rename to libraries/pushstore/test/src/main/kotlin/io/element/android/libraries/pushstore/test/userpushstore/FakeUserPushStoreFactory.kt index 2f4f524cc2..14fd4ce3a6 100644 --- a/libraries/pushstore/test/src/main/kotlin/com/element/android/libraries/pushstore/test/userpushstore/FakeUserPushStoreFactory.kt +++ b/libraries/pushstore/test/src/main/kotlin/io/element/android/libraries/pushstore/test/userpushstore/FakeUserPushStoreFactory.kt @@ -14,14 +14,16 @@ * limitations under the License. */ -package com.element.android.libraries.pushstore.test.userpushstore +package io.element.android.libraries.pushstore.test.userpushstore import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.pushstore.api.UserPushStore import io.element.android.libraries.pushstore.api.UserPushStoreFactory -class FakeUserPushStoreFactory : UserPushStoreFactory { +class FakeUserPushStoreFactory( + val userPushStore: (SessionId) -> UserPushStore = { FakeUserPushStore() } +) : UserPushStoreFactory { override fun getOrCreate(userId: SessionId): UserPushStore { - return FakeUserPushStore() + return userPushStore(userId) } } diff --git a/libraries/pushstore/test/src/main/kotlin/io/element/android/libraries/pushstore/test/userpushstore/clientsecret/FakePushClientSecret.kt b/libraries/pushstore/test/src/main/kotlin/io/element/android/libraries/pushstore/test/userpushstore/clientsecret/FakePushClientSecret.kt new file mode 100644 index 0000000000..25759ecc45 --- /dev/null +++ b/libraries/pushstore/test/src/main/kotlin/io/element/android/libraries/pushstore/test/userpushstore/clientsecret/FakePushClientSecret.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.pushstore.test.userpushstore.clientsecret + +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecret +import io.element.android.tests.testutils.lambda.lambdaError + +class FakePushClientSecret( + private val getSecretForUserResult: (SessionId) -> String = { lambdaError() }, + private val getUserIdFromSecretResult: (String) -> SessionId? = { lambdaError() } +) : PushClientSecret { + override suspend fun getSecretForUser(userId: SessionId): String { + return getSecretForUserResult(userId) + } + + override suspend fun getUserIdFromSecret(clientSecret: String): SessionId? { + return getUserIdFromSecretResult(clientSecret) + } +} diff --git a/libraries/pushstore/impl/src/test/kotlin/io/element/android/libraries/pushstore/impl/clientsecret/InMemoryPushClientSecretStore.kt b/libraries/pushstore/test/src/main/kotlin/io/element/android/libraries/pushstore/test/userpushstore/clientsecret/InMemoryPushClientSecretStore.kt similarity index 94% rename from libraries/pushstore/impl/src/test/kotlin/io/element/android/libraries/pushstore/impl/clientsecret/InMemoryPushClientSecretStore.kt rename to libraries/pushstore/test/src/main/kotlin/io/element/android/libraries/pushstore/test/userpushstore/clientsecret/InMemoryPushClientSecretStore.kt index 8c9b577967..632014109e 100644 --- a/libraries/pushstore/impl/src/test/kotlin/io/element/android/libraries/pushstore/impl/clientsecret/InMemoryPushClientSecretStore.kt +++ b/libraries/pushstore/test/src/main/kotlin/io/element/android/libraries/pushstore/test/userpushstore/clientsecret/InMemoryPushClientSecretStore.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package io.element.android.libraries.pushstore.impl.clientsecret +package io.element.android.libraries.pushstore.test.userpushstore.clientsecret import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecretStore diff --git a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectView.kt b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectView.kt index 77df71c268..438cb8d4e6 100644 --- a/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectView.kt +++ b/libraries/roomselect/impl/src/main/kotlin/io/element/android/libraries/roomselect/impl/RoomSelectView.kt @@ -82,7 +82,7 @@ fun RoomSelectView( if (isForwarding) return SelectedRooms( selectedRooms = selectedRooms, - onRoomRemoved = ::onRoomRemoved, + onRemoveRoom = ::onRoomRemoved, modifier = Modifier.padding(vertical = 16.dp) ) } @@ -192,7 +192,7 @@ fun RoomSelectView( @Composable private fun SelectedRooms( selectedRooms: ImmutableList, - onRoomRemoved: (RoomSummaryDetails) -> Unit, + onRemoveRoom: (RoomSummaryDetails) -> Unit, modifier: Modifier = Modifier, ) { LazyRow( @@ -201,7 +201,7 @@ private fun SelectedRooms( horizontalArrangement = Arrangement.spacedBy(32.dp) ) { items(selectedRooms, key = { it.roomId.value }) { roomSummary -> - SelectedRoom(roomSummary = roomSummary, onRoomRemoved = onRoomRemoved) + SelectedRoom(roomSummary = roomSummary, onRemoveRoom = onRemoveRoom) } } } diff --git a/libraries/session-storage/impl-memory/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/memory/InMemoryMultiSessionsStore.kt b/libraries/session-storage/impl-memory/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/memory/InMemoryMultiSessionsStore.kt new file mode 100644 index 0000000000..5331d5755b --- /dev/null +++ b/libraries/session-storage/impl-memory/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/memory/InMemoryMultiSessionsStore.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.sessionstorage.impl.memory + +import io.element.android.libraries.sessionstorage.api.LoggedInState +import io.element.android.libraries.sessionstorage.api.SessionData +import io.element.android.libraries.sessionstorage.api.SessionStore +import kotlinx.coroutines.flow.Flow + +class InMemoryMultiSessionsStore : SessionStore { + private val sessions = mutableListOf() + + override fun isLoggedIn(): Flow = error("Not implemented") + + override fun sessionsFlow(): Flow> = error("Not implemented") + + override suspend fun storeData(sessionData: SessionData) { + sessions.add(sessionData) + } + + override suspend fun updateData(sessionData: SessionData) = error("Not implemented") + + override suspend fun getSession(sessionId: String): SessionData? = error("Not implemented") + + override suspend fun getAllSessions(): List = sessions + + override suspend fun getLatestSession(): SessionData = error("Not implemented") + + override suspend fun removeSession(sessionId: String) = error("Not implemented") +} diff --git a/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/di/SessionStorageModule.kt b/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/di/SessionStorageModule.kt index 052943388e..8d22dcfa61 100644 --- a/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/di/SessionStorageModule.kt +++ b/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/di/SessionStorageModule.kt @@ -35,6 +35,13 @@ object SessionStorageModule { fun provideMatrixDatabase(@ApplicationContext context: Context): SessionDatabase { val name = "session_database" val secretFile = context.getDatabasePath("$name.key") + + // Make sure the parent directory of the key file exists, otherwise it will crash in older Android versions + val parentDir = secretFile.parentFile + if (parentDir != null && !parentDir.exists()) { + parentDir.mkdirs() + } + val passphraseProvider = RandomSecretPassphraseProvider(context, secretFile) val driver = SqlCipherDriverFactory(passphraseProvider) .create(SessionDatabase.Schema, "$name.db", context) diff --git a/libraries/testtags/src/main/kotlin/io/element/android/libraries/testtags/TestTags.kt b/libraries/testtags/src/main/kotlin/io/element/android/libraries/testtags/TestTags.kt index 3046ba3372..3bad8f648d 100644 --- a/libraries/testtags/src/main/kotlin/io/element/android/libraries/testtags/TestTags.kt +++ b/libraries/testtags/src/main/kotlin/io/element/android/libraries/testtags/TestTags.kt @@ -64,15 +64,25 @@ object TestTags { */ val memberDetailAvatar = TestTag("member_detail-avatar") + /** + * Edit avatar. + */ + val editAvatar = TestTag("edit-avatar") + /** * Welcome screen. */ val welcomeScreenTitle = TestTag("welcome_screen-title") /** - * RichTextEditor. + * TextEditor. */ - val richTextEditor = TestTag("rich_text_editor") + val textEditor = TestTag("text_editor") + + /** + * EditText inside the MarkdownTextInput. + */ + val plainTextEditor = TestTag("plain_text_editor") /** * Message bubble. diff --git a/libraries/textcomposer/impl/build.gradle.kts b/libraries/textcomposer/impl/build.gradle.kts index 0bbb508b3c..21fd124765 100644 --- a/libraries/textcomposer/impl/build.gradle.kts +++ b/libraries/textcomposer/impl/build.gradle.kts @@ -22,6 +22,9 @@ plugins { android { namespace = "io.element.android.libraries.textcomposer" + testOptions { + unitTests.isIncludeAndroidResources = true + } } dependencies { @@ -47,9 +50,13 @@ dependencies { ksp(libs.showkase.processor) testImplementation(libs.test.junit) - testImplementation(libs.test.truth) testImplementation(libs.coroutines.test) + testImplementation(libs.molecule.runtime) testImplementation(libs.test.robolectric) + testImplementation(libs.test.truth) + testImplementation(libs.test.turbine) testImplementation(projects.libraries.matrix.test) testImplementation(projects.tests.testutils) + testImplementation(libs.androidx.compose.ui.test.junit) + testReleaseImplementation(libs.androidx.compose.ui.test.manifest) } diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/ComposerModeView.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/ComposerModeView.kt new file mode 100644 index 0000000000..c50c81fb87 --- /dev/null +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/ComposerModeView.kt @@ -0,0 +1,184 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.textcomposer + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.ripple.rememberRipple +import androidx.compose.material3.MaterialTheme +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.draw.clipToBounds +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +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.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.matrix.ui.components.AttachmentThumbnail +import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo +import io.element.android.libraries.textcomposer.model.MessageComposerMode +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +internal fun ComposerModeView( + composerMode: MessageComposerMode, + onResetComposerMode: () -> Unit, +) { + when (composerMode) { + is MessageComposerMode.Edit -> { + EditingModeView(onResetComposerMode = onResetComposerMode) + } + is MessageComposerMode.Reply -> { + ReplyToModeView( + modifier = Modifier.padding(8.dp), + senderName = composerMode.senderName, + text = composerMode.defaultContent, + attachmentThumbnailInfo = composerMode.attachmentThumbnailInfo, + onResetComposerMode = onResetComposerMode, + ) + } + else -> Unit + } +} + +@Composable +private fun EditingModeView( + onResetComposerMode: () -> Unit, +) { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(start = 12.dp) + ) { + Icon( + imageVector = CompoundIcons.Edit(), + contentDescription = stringResource(CommonStrings.common_editing), + tint = ElementTheme.materialColors.secondary, + modifier = Modifier + .padding(vertical = 8.dp) + .size(16.dp), + ) + Text( + stringResource(CommonStrings.common_editing), + style = ElementTheme.typography.fontBodySmRegular, + textAlign = TextAlign.Start, + color = ElementTheme.materialColors.secondary, + modifier = Modifier + .padding(vertical = 8.dp) + .weight(1f) + ) + Icon( + imageVector = CompoundIcons.Close(), + contentDescription = stringResource(CommonStrings.action_close), + tint = ElementTheme.materialColors.secondary, + modifier = Modifier + .padding(top = 8.dp, bottom = 8.dp, start = 16.dp, end = 12.dp) + .size(16.dp) + .clickable( + enabled = true, + onClick = onResetComposerMode, + interactionSource = remember { MutableInteractionSource() }, + indication = rememberRipple(bounded = false) + ), + ) + } +} + +@Composable +private fun ReplyToModeView( + senderName: String, + text: String?, + attachmentThumbnailInfo: AttachmentThumbnailInfo?, + onResetComposerMode: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier + .clip(RoundedCornerShape(13.dp)) + .background(MaterialTheme.colorScheme.surface) + .padding(4.dp) + ) { + if (attachmentThumbnailInfo != null) { + AttachmentThumbnail( + info = attachmentThumbnailInfo, + backgroundColor = MaterialTheme.colorScheme.surfaceVariant, + modifier = Modifier + .size(36.dp) + .clip(RoundedCornerShape(9.dp)) + ) + } + Spacer(modifier = Modifier.width(8.dp)) + Column( + modifier = Modifier + .weight(1f) + .align(Alignment.CenterVertically) + ) { + Text( + text = senderName, + modifier = Modifier + .fillMaxWidth() + .clipToBounds(), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = ElementTheme.typography.fontBodySmMedium, + textAlign = TextAlign.Start, + color = ElementTheme.materialColors.primary, + ) + Text( + modifier = Modifier.fillMaxWidth(), + text = text.orEmpty(), + style = ElementTheme.typography.fontBodyMdRegular, + textAlign = TextAlign.Start, + color = ElementTheme.materialColors.secondary, + maxLines = if (attachmentThumbnailInfo != null) 1 else 2, + overflow = TextOverflow.Ellipsis, + ) + } + Icon( + imageVector = CompoundIcons.Close(), + contentDescription = stringResource(CommonStrings.action_close), + tint = MaterialTheme.colorScheme.secondary, + modifier = Modifier + .padding(end = 4.dp, top = 4.dp, start = 16.dp, bottom = 16.dp) + .size(16.dp) + .clickable( + enabled = true, + onClick = onResetComposerMode, + interactionSource = remember { MutableInteractionSource() }, + indication = rememberRipple(bounded = false) + ), + ) + } +} 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 e31183bd14..81a019a43a 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 @@ -19,8 +19,6 @@ package io.element.android.libraries.textcomposer import android.net.Uri 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.Column @@ -34,30 +32,22 @@ 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.RoundedCornerShape -import androidx.compose.material.ripple.rememberRipple -import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow 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.media.createFakeWaveform 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.designsystem.theme.components.Text import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.TransactionId @@ -66,7 +56,6 @@ import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.api.permalink.PermalinkData import io.element.android.libraries.matrix.api.permalink.PermalinkParser import io.element.android.libraries.matrix.ui.components.A_BLUR_HASH -import io.element.android.libraries.matrix.ui.components.AttachmentThumbnail import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType import io.element.android.libraries.testtags.TestTags @@ -79,11 +68,13 @@ import io.element.android.libraries.textcomposer.components.VoiceMessageDeleteBu import io.element.android.libraries.textcomposer.components.VoiceMessagePreview import io.element.android.libraries.textcomposer.components.VoiceMessageRecorderButton import io.element.android.libraries.textcomposer.components.VoiceMessageRecording +import io.element.android.libraries.textcomposer.components.markdown.MarkdownTextInput +import io.element.android.libraries.textcomposer.components.markdown.aMarkdownTextEditorState import io.element.android.libraries.textcomposer.components.textInputRoundedCornerShape import io.element.android.libraries.textcomposer.mentions.rememberMentionSpanProvider -import io.element.android.libraries.textcomposer.model.Message import io.element.android.libraries.textcomposer.model.MessageComposerMode import io.element.android.libraries.textcomposer.model.Suggestion +import io.element.android.libraries.textcomposer.model.TextEditorState import io.element.android.libraries.textcomposer.model.VoiceMessagePlayerEvent import io.element.android.libraries.textcomposer.model.VoiceMessageRecorderEvent import io.element.android.libraries.textcomposer.model.VoiceMessageState @@ -98,15 +89,14 @@ import kotlin.time.Duration.Companion.seconds @Composable fun TextComposer( - state: RichTextEditorState, + state: TextEditorState, voiceMessageState: VoiceMessageState, permalinkParser: PermalinkParser, composerMode: MessageComposerMode, - enableTextFormatting: Boolean, enableVoiceMessages: Boolean, currentUserId: UserId, onRequestFocus: () -> Unit, - onSendMessage: (Message) -> Unit, + onSendMessage: () -> Unit, onResetComposerMode: () -> Unit, onAddAttachment: () -> Unit, onDismissTextFormatting: () -> Unit, @@ -116,22 +106,25 @@ fun TextComposer( onDeleteVoiceMessage: () -> Unit, onError: (Throwable) -> Unit, onTyping: (Boolean) -> Unit, - onSuggestionReceived: (Suggestion?) -> Unit, - onRichContentSelected: ((Uri) -> Unit)?, + onReceiveSuggestion: (Suggestion?) -> Unit, + onSelectRichContent: ((Uri) -> Unit)?, modifier: Modifier = Modifier, showTextFormatting: Boolean = false, subcomposing: Boolean = false, ) { - val onSendClicked = { - val html = if (enableTextFormatting) state.messageHtml else null - onSendMessage(Message(html = html, markdown = state.messageMarkdown)) + val markdown = when (state) { + is TextEditorState.Markdown -> state.state.text.value() + is TextEditorState.Rich -> state.richTextEditorState.messageMarkdown + } + val onSendClick = { + onSendMessage() } - val onPlayVoiceMessageClicked = { + val onPlayVoiceMessageClick = { onVoicePlayerEvent(VoiceMessagePlayerEvent.Play) } - val onPauseVoiceMessageClicked = { + val onPauseVoiceMessageClick = { onVoicePlayerEvent(VoiceMessagePlayerEvent.Pause) } @@ -153,36 +146,62 @@ fun TextComposer( } } - val textInput: @Composable () -> Unit = remember(state, subcomposing, composerMode, onResetComposerMode, onError) { - @Composable { - val mentionSpanProvider = rememberMentionSpanProvider( - currentUserId = currentUserId, - permalinkParser = permalinkParser, - ) - TextInput( - state = state, - subcomposing = subcomposing, - placeholder = if (composerMode.inThread) { - stringResource(id = CommonStrings.action_reply_in_thread) - } else { - stringResource(id = R.string.rich_text_editor_composer_placeholder) - }, - composerMode = composerMode, - onResetComposerMode = onResetComposerMode, - resolveMentionDisplay = { text, url -> TextDisplay.Custom(mentionSpanProvider.getMentionSpanFor(text, url)) }, - resolveRoomMentionDisplay = { TextDisplay.Custom(mentionSpanProvider.getMentionSpanFor("@room", "#")) }, - onError = onError, - onTyping = onTyping, - onRichContentSelected = onRichContentSelected, - ) + val placeholder = if (composerMode.inThread) { + stringResource(id = CommonStrings.action_reply_in_thread) + } else { + stringResource(id = R.string.rich_text_editor_composer_placeholder) + } + val textInput: @Composable () -> Unit = when (state) { + is TextEditorState.Rich -> { + remember(state.richTextEditorState, subcomposing, composerMode, onResetComposerMode, onError) { + @Composable { + val mentionSpanProvider = rememberMentionSpanProvider( + currentUserId = currentUserId, + permalinkParser = permalinkParser, + ) + TextInput( + state = state.richTextEditorState, + subcomposing = subcomposing, + placeholder = placeholder, + composerMode = composerMode, + onResetComposerMode = onResetComposerMode, + resolveMentionDisplay = { text, url -> TextDisplay.Custom(mentionSpanProvider.getMentionSpanFor(text, url)) }, + resolveRoomMentionDisplay = { TextDisplay.Custom(mentionSpanProvider.getMentionSpanFor("@room", "#")) }, + onError = onError, + onTyping = onTyping, + onSelectRichContent = onSelectRichContent, + ) + } + } + } + is TextEditorState.Markdown -> { + @Composable { + val style = ElementRichTextEditorStyle.composerStyle(hasFocus = state.hasFocus()) + TextInputBox( + composerMode = composerMode, + onResetComposerMode = onResetComposerMode, + placeholder = placeholder, + showPlaceholder = { state.state.text.value().isEmpty() }, + subcomposing = subcomposing, + ) { + MarkdownTextInput( + state = state.state, + subcomposing = subcomposing, + onTyping = onTyping, + onReceiveSuggestion = onReceiveSuggestion, + richTextEditorStyle = style, + onSelectRichContent = onSelectRichContent, + ) + } + } } } - val canSendMessage by remember { derivedStateOf { state.messageMarkdown.isNotBlank() } } + val canSendMessage = markdown.isNotBlank() val sendButton = @Composable { SendButton( canSendMessage = canSendMessage, - onClick = onSendClicked, + onClick = onSendClick, composerMode = composerMode, ) } @@ -205,7 +224,9 @@ fun TextComposer( ) } - val textFormattingOptions = @Composable { TextFormatting(state = state) } + val textFormattingOptions: @Composable (() -> Unit)? = (state as? TextEditorState.Rich)?.let { + @Composable { TextFormatting(state = it.richTextEditorState) } + } val sendOrRecordButton = when { enableVoiceMessages && !canSendMessage -> @@ -217,8 +238,7 @@ fun TextComposer( false -> sendVoiceButton } } - else -> - sendButton + else -> sendButton } val voiceRecording = @Composable { @@ -231,8 +251,8 @@ fun TextComposer( waveform = voiceMessageState.waveform, playbackProgress = voiceMessageState.playbackProgress, time = voiceMessageState.time, - onPlayClick = onPlayVoiceMessageClicked, - onPauseClick = onPauseVoiceMessageClicked, + onPlayClick = onPlayVoiceMessageClick, + onPauseClick = onPauseVoiceMessageClick, onSeek = onSeekVoiceMessage, ) is VoiceMessageState.Recording -> @@ -251,7 +271,7 @@ fun TextComposer( } } - if (showTextFormatting) { + if (showTextFormatting && textFormattingOptions != null) { TextFormattingLayout( modifier = layoutModifier, textInput = textInput, @@ -282,14 +302,16 @@ fun TextComposer( SoftKeyboardEffect(showTextFormatting, onRequestFocus) { it } } - val menuAction = state.menuAction - val latestOnSuggestionReceived by rememberUpdatedState(onSuggestionReceived) - LaunchedEffect(menuAction) { - if (menuAction is MenuAction.Suggestion) { - val suggestion = Suggestion(menuAction.suggestionPattern) - latestOnSuggestionReceived(suggestion) - } else { - latestOnSuggestionReceived(null) + val latestOnReceiveSuggestion by rememberUpdatedState(onReceiveSuggestion) + if (state is TextEditorState.Rich) { + val menuAction = state.richTextEditorState.menuAction + LaunchedEffect(menuAction) { + if (menuAction is MenuAction.Suggestion) { + val suggestion = Suggestion(menuAction.suggestionPattern) + latestOnReceiveSuggestion(suggestion) + } else { + latestOnReceiveSuggestion(null) + } } } } @@ -400,17 +422,13 @@ private fun TextFormattingLayout( } @Composable -private fun TextInput( - state: RichTextEditorState, - subcomposing: Boolean, - placeholder: String, +private fun TextInputBox( composerMode: MessageComposerMode, onResetComposerMode: () -> Unit, - resolveRoomMentionDisplay: () -> TextDisplay, - resolveMentionDisplay: (text: String, url: String) -> TextDisplay, - onError: (Throwable) -> Unit, - onTyping: (Boolean) -> Unit, - onRichContentSelected: ((Uri) -> Unit)?, + placeholder: String, + showPlaceholder: () -> Boolean, + subcomposing: Boolean, + textInput: @Composable () -> Unit, ) { val bgColor = ElementTheme.colors.bgSubtleSecondary val borderColor = ElementTheme.colors.borderDisabled @@ -431,11 +449,12 @@ private fun TextInput( Box( modifier = Modifier .padding(top = 4.dp, bottom = 4.dp, start = 12.dp, end = 42.dp) - .testTag(TestTags.richTextEditor), + // Apply test tag only once, otherwise 2 nodes will have it (both the normal and subcomposing one) and tests will fail + .then(if (!subcomposing) Modifier.testTag(TestTags.textEditor) else Modifier), contentAlignment = Alignment.CenterStart, ) { // Placeholder - if (state.messageHtml.isEmpty()) { + if (showPlaceholder()) { Text( placeholder, style = defaultTypography.copy( @@ -446,155 +465,45 @@ private fun TextInput( ) } - RichTextEditor( - state = state, - // Disable most of the editor functionality if it's just being measured for a subcomposition. - // This prevents it gaining focus and mutating the state. - registerStateUpdates = !subcomposing, - modifier = Modifier - .padding(top = 6.dp, bottom = 6.dp) - .fillMaxWidth(), - style = ElementRichTextEditorStyle.composerStyle(hasFocus = state.hasFocus), - resolveMentionDisplay = resolveMentionDisplay, - resolveRoomMentionDisplay = resolveRoomMentionDisplay, - onError = onError, - onRichContentSelected = onRichContentSelected, - onTyping = onTyping, - ) + textInput() } } } @Composable -private fun ComposerModeView( +private fun TextInput( + state: RichTextEditorState, + subcomposing: Boolean, + placeholder: String, composerMode: MessageComposerMode, onResetComposerMode: () -> Unit, + resolveRoomMentionDisplay: () -> TextDisplay, + resolveMentionDisplay: (text: String, url: String) -> TextDisplay, + onError: (Throwable) -> Unit, + onTyping: (Boolean) -> Unit, + onSelectRichContent: ((Uri) -> Unit)?, ) { - when (composerMode) { - is MessageComposerMode.Edit -> { - EditingModeView(onResetComposerMode = onResetComposerMode) - } - is MessageComposerMode.Reply -> { - ReplyToModeView( - modifier = Modifier.padding(8.dp), - senderName = composerMode.senderName, - text = composerMode.defaultContent, - attachmentThumbnailInfo = composerMode.attachmentThumbnailInfo, - onResetComposerMode = onResetComposerMode, - ) - } - else -> Unit - } -} - -@Composable -private fun EditingModeView( - onResetComposerMode: () -> Unit, -) { - Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .padding(start = 12.dp) + TextInputBox( + composerMode = composerMode, + onResetComposerMode = onResetComposerMode, + placeholder = placeholder, + showPlaceholder = { state.messageHtml.isEmpty() }, + subcomposing = subcomposing, ) { - Icon( - imageVector = CompoundIcons.Edit(), - contentDescription = stringResource(CommonStrings.common_editing), - tint = ElementTheme.materialColors.secondary, + RichTextEditor( + state = state, + // Disable most of the editor functionality if it's just being measured for a subcomposition. + // This prevents it gaining focus and mutating the state. + registerStateUpdates = !subcomposing, modifier = Modifier - .padding(vertical = 8.dp) - .size(16.dp), - ) - Text( - stringResource(CommonStrings.common_editing), - style = ElementTheme.typography.fontBodySmRegular, - textAlign = TextAlign.Start, - color = ElementTheme.materialColors.secondary, - modifier = Modifier - .padding(vertical = 8.dp) - .weight(1f) - ) - Icon( - imageVector = CompoundIcons.Close(), - contentDescription = stringResource(CommonStrings.action_close), - tint = ElementTheme.materialColors.secondary, - modifier = Modifier - .padding(top = 8.dp, bottom = 8.dp, start = 16.dp, end = 12.dp) - .size(16.dp) - .clickable( - enabled = true, - onClick = onResetComposerMode, - interactionSource = remember { MutableInteractionSource() }, - indication = rememberRipple(bounded = false) - ), - ) - } -} - -@Composable -private fun ReplyToModeView( - senderName: String, - text: String?, - attachmentThumbnailInfo: AttachmentThumbnailInfo?, - onResetComposerMode: () -> Unit, - modifier: Modifier = Modifier, -) { - Row( - modifier - .clip(RoundedCornerShape(13.dp)) - .background(MaterialTheme.colorScheme.surface) - .padding(4.dp) - ) { - if (attachmentThumbnailInfo != null) { - AttachmentThumbnail( - info = attachmentThumbnailInfo, - backgroundColor = MaterialTheme.colorScheme.surfaceVariant, - modifier = Modifier - .size(36.dp) - .clip(RoundedCornerShape(9.dp)) - ) - } - Spacer(modifier = Modifier.width(8.dp)) - Column( - modifier = Modifier - .weight(1f) - .align(Alignment.CenterVertically) - ) { - Text( - text = senderName, - modifier = Modifier - .fillMaxWidth() - .clipToBounds(), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - style = ElementTheme.typography.fontBodySmMedium, - textAlign = TextAlign.Start, - color = ElementTheme.materialColors.primary, - ) - Text( - modifier = Modifier.fillMaxWidth(), - text = text.orEmpty(), - style = ElementTheme.typography.fontBodyMdRegular, - textAlign = TextAlign.Start, - color = ElementTheme.materialColors.secondary, - maxLines = if (attachmentThumbnailInfo != null) 1 else 2, - overflow = TextOverflow.Ellipsis, - ) - } - Icon( - imageVector = CompoundIcons.Close(), - contentDescription = stringResource(CommonStrings.action_close), - tint = MaterialTheme.colorScheme.secondary, - modifier = Modifier - .padding(end = 4.dp, top = 4.dp, start = 16.dp, bottom = 16.dp) - .size(16.dp) - .clickable( - enabled = true, - onClick = onResetComposerMode, - interactionSource = remember { MutableInteractionSource() }, - indication = rememberRipple(bounded = false) - ), + .padding(top = 6.dp, bottom = 6.dp) + .fillMaxWidth(), + style = ElementRichTextEditorStyle.composerStyle(hasFocus = state.hasFocus), + resolveMentionDisplay = resolveMentionDisplay, + resolveRoomMentionDisplay = resolveRoomMentionDisplay, + onError = onError, + onRichContentSelected = onSelectRichContent, + onTyping = onTyping, ) } } @@ -606,43 +515,41 @@ internal fun TextComposerSimplePreview() = ElementPreview { items = persistentListOf( { ATextComposer( - aRichTextEditorState(initialText = "", initialFocus = true), + TextEditorState.Markdown(aMarkdownTextEditorState(initialText = "", initialFocus = true)), voiceMessageState = VoiceMessageState.Idle, composerMode = MessageComposerMode.Normal, - enableTextFormatting = true, enableVoiceMessages = true, currentUserId = UserId("@alice:localhost"), ) }, { ATextComposer( - aRichTextEditorState(initialText = "A message", initialFocus = true), + TextEditorState.Markdown(aMarkdownTextEditorState(initialText = "A message", initialFocus = true)), voiceMessageState = VoiceMessageState.Idle, composerMode = MessageComposerMode.Normal, - enableTextFormatting = true, enableVoiceMessages = true, currentUserId = UserId("@alice:localhost") ) }, { ATextComposer( - aRichTextEditorState( - initialText = "A message\nWith several lines\nTo preview larger textfields and long lines with overflow", - initialFocus = true + TextEditorState.Markdown( + aMarkdownTextEditorState( + initialText = "A message\nWith several lines\nTo preview larger textfields and long lines with overflow", + initialFocus = true + ) ), voiceMessageState = VoiceMessageState.Idle, composerMode = MessageComposerMode.Normal, - enableTextFormatting = true, enableVoiceMessages = true, currentUserId = UserId("@alice:localhost") ) }, { ATextComposer( - aRichTextEditorState(initialText = "A message without focus"), + TextEditorState.Markdown(aMarkdownTextEditorState(initialText = "A message without focus", initialFocus = false)), voiceMessageState = VoiceMessageState.Idle, composerMode = MessageComposerMode.Normal, - enableTextFormatting = true, enableVoiceMessages = true, currentUserId = UserId("@alice:localhost") ) @@ -656,33 +563,32 @@ internal fun TextComposerSimplePreview() = ElementPreview { internal fun TextComposerFormattingPreview() = ElementPreview { PreviewColumn(items = persistentListOf({ ATextComposer( - aRichTextEditorState(), + TextEditorState.Rich(aRichTextEditorState()), voiceMessageState = VoiceMessageState.Idle, showTextFormatting = true, composerMode = MessageComposerMode.Normal, - enableTextFormatting = true, enableVoiceMessages = true, currentUserId = UserId("@alice:localhost") ) }, { ATextComposer( - aRichTextEditorState(initialText = "A message"), + TextEditorState.Rich(aRichTextEditorState(initialText = "A message")), voiceMessageState = VoiceMessageState.Idle, showTextFormatting = true, composerMode = MessageComposerMode.Normal, - enableTextFormatting = true, enableVoiceMessages = true, currentUserId = UserId("@alice:localhost") ) }, { ATextComposer( - aRichTextEditorState( - initialText = "A message\nWith several lines\nTo preview larger textfields and long lines with overflow", + TextEditorState.Rich( + aRichTextEditorState( + initialText = "A message\nWith several lines\nTo preview larger textfields and long lines with overflow", + ) ), voiceMessageState = VoiceMessageState.Idle, showTextFormatting = true, composerMode = MessageComposerMode.Normal, - enableTextFormatting = true, enableVoiceMessages = true, currentUserId = UserId("@alice:localhost") ) @@ -694,10 +600,23 @@ internal fun TextComposerFormattingPreview() = ElementPreview { internal fun TextComposerEditPreview() = ElementPreview { PreviewColumn(items = persistentListOf({ ATextComposer( - aRichTextEditorState(initialText = "A message", initialFocus = true), + TextEditorState.Rich(aRichTextEditorState(initialText = "A message", initialFocus = true)), + voiceMessageState = VoiceMessageState.Idle, + composerMode = MessageComposerMode.Edit(EventId("$1234"), "Some text", TransactionId("1234")), + enableVoiceMessages = true, + currentUserId = UserId("@alice:localhost") + ) + })) +} + +@PreviewsDayNight +@Composable +internal fun MarkdownTextComposerEditPreview() = ElementPreview { + PreviewColumn(items = persistentListOf({ + ATextComposer( + TextEditorState.Markdown(aMarkdownTextEditorState(initialText = "A message", initialFocus = true)), voiceMessageState = VoiceMessageState.Idle, composerMode = MessageComposerMode.Edit(EventId("$1234"), "Some text", TransactionId("1234")), - enableTextFormatting = true, enableVoiceMessages = true, currentUserId = UserId("@alice:localhost") ) @@ -711,7 +630,7 @@ internal fun TextComposerReplyPreview() = ElementPreview { items = persistentListOf( { ATextComposer( - aRichTextEditorState(), + TextEditorState.Rich(aRichTextEditorState()), voiceMessageState = VoiceMessageState.Idle, composerMode = MessageComposerMode.Reply( isThreaded = false, @@ -722,14 +641,13 @@ internal fun TextComposerReplyPreview() = ElementPreview { "With several lines\n" + "To preview larger textfields and long lines with overflow" ), - enableTextFormatting = true, enableVoiceMessages = true, currentUserId = UserId("@alice:localhost") ) }, { ATextComposer( - aRichTextEditorState(), + TextEditorState.Rich(aRichTextEditorState()), voiceMessageState = VoiceMessageState.Idle, composerMode = MessageComposerMode.Reply( isThreaded = true, @@ -740,14 +658,13 @@ internal fun TextComposerReplyPreview() = ElementPreview { "With several lines\n" + "To preview larger textfields and long lines with overflow" ), - enableTextFormatting = true, enableVoiceMessages = true, currentUserId = UserId("@alice:localhost") ) }, { ATextComposer( - aRichTextEditorState(initialText = "A message"), + TextEditorState.Rich(aRichTextEditorState(initialText = "A message")), voiceMessageState = VoiceMessageState.Idle, composerMode = MessageComposerMode.Reply( isThreaded = true, @@ -761,14 +678,13 @@ internal fun TextComposerReplyPreview() = ElementPreview { ), defaultContent = "image.jpg" ), - enableTextFormatting = true, enableVoiceMessages = true, currentUserId = UserId("@alice:localhost") ) }, { ATextComposer( - aRichTextEditorState(initialText = "A message"), + TextEditorState.Rich(aRichTextEditorState(initialText = "A message")), voiceMessageState = VoiceMessageState.Idle, composerMode = MessageComposerMode.Reply( isThreaded = false, @@ -782,14 +698,13 @@ internal fun TextComposerReplyPreview() = ElementPreview { ), defaultContent = "video.mp4" ), - enableTextFormatting = true, enableVoiceMessages = true, currentUserId = UserId("@alice:localhost") ) }, { ATextComposer( - aRichTextEditorState(initialText = "A message"), + TextEditorState.Rich(aRichTextEditorState(initialText = "A message")), voiceMessageState = VoiceMessageState.Idle, composerMode = MessageComposerMode.Reply( isThreaded = false, @@ -803,14 +718,13 @@ internal fun TextComposerReplyPreview() = ElementPreview { ), defaultContent = "logs.txt" ), - enableTextFormatting = true, enableVoiceMessages = true, currentUserId = UserId("@alice:localhost") ) }, { ATextComposer( - aRichTextEditorState(initialText = "A message", initialFocus = true), + TextEditorState.Rich(aRichTextEditorState(initialText = "A message", initialFocus = true)), voiceMessageState = VoiceMessageState.Idle, composerMode = MessageComposerMode.Reply( isThreaded = false, @@ -824,7 +738,6 @@ internal fun TextComposerReplyPreview() = ElementPreview { ), defaultContent = "Shared location" ), - enableTextFormatting = true, enableVoiceMessages = true, currentUserId = UserId("@alice:localhost") ) @@ -840,10 +753,9 @@ internal fun TextComposerVoicePreview() = ElementPreview { fun VoicePreview( voiceMessageState: VoiceMessageState ) = ATextComposer( - aRichTextEditorState(initialFocus = true), + TextEditorState.Rich(aRichTextEditorState(initialFocus = true)), voiceMessageState = voiceMessageState, composerMode = MessageComposerMode.Normal, - enableTextFormatting = true, enableVoiceMessages = true, currentUserId = UserId("@alice:localhost") ) @@ -902,23 +814,21 @@ private fun PreviewColumn( @Composable private fun ATextComposer( - richTextEditorState: RichTextEditorState, + state: TextEditorState, voiceMessageState: VoiceMessageState, composerMode: MessageComposerMode, - enableTextFormatting: Boolean, enableVoiceMessages: Boolean, currentUserId: UserId, showTextFormatting: Boolean = false, ) { TextComposer( - state = richTextEditorState, + state = state, showTextFormatting = showTextFormatting, voiceMessageState = voiceMessageState, permalinkParser = object : PermalinkParser { override fun parse(uriString: String): PermalinkData = TODO("Not yet implemented") }, composerMode = composerMode, - enableTextFormatting = enableTextFormatting, enableVoiceMessages = enableVoiceMessages, currentUserId = currentUserId, onRequestFocus = {}, @@ -932,8 +842,8 @@ private fun ATextComposer( onDeleteVoiceMessage = {}, onError = {}, onTyping = {}, - onSuggestionReceived = {}, - onRichContentSelected = null, + onReceiveSuggestion = {}, + onSelectRichContent = null, ) } diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposerLinkDialog.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposerLinkDialog.kt index b0078a40eb..3f026ed4fa 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposerLinkDialog.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposerLinkDialog.kt @@ -104,14 +104,14 @@ private fun CreateLinkWithTextDialog( TextFieldListItem( placeholder = stringResource(id = CommonStrings.common_text), text = linkText, - onTextChanged = { linkText = it }, + onTextChange = { linkText = it }, ) } item { TextFieldListItem( placeholder = stringResource(id = R.string.rich_text_editor_url_placeholder), text = linkUrl, - onTextChanged = { linkUrl = it }, + onTextChange = { linkUrl = it }, ) } } @@ -142,7 +142,7 @@ private fun CreateLinkWithoutTextDialog( TextFieldListItem( placeholder = stringResource(id = R.string.rich_text_editor_url_placeholder), text = linkUrl, - onTextChanged = { linkUrl = it }, + onTextChange = { linkUrl = it }, ) } } @@ -167,7 +167,7 @@ private fun EditLinkDialog( onDismissRequest() } - fun onRemoveClicked() { + fun onRemoveClick() { onRemoveLinkRequest() onDismissRequest() } @@ -182,7 +182,7 @@ private fun EditLinkDialog( TextFieldListItem( placeholder = stringResource(id = R.string.rich_text_editor_url_placeholder), text = linkUrl, - onTextChanged = { linkUrl = it }, + onTextChange = { linkUrl = it }, ) } item { @@ -193,7 +193,7 @@ private fun EditLinkDialog( color = ElementTheme.colors.textCriticalPrimary ) }, - onClick = ::onRemoveClicked, + onClick = ::onRemoveClick, ) } } diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/MarkdownEditText.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/MarkdownEditText.kt new file mode 100644 index 0000000000..98842c35de --- /dev/null +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/MarkdownEditText.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.textcomposer.components.markdown + +import android.content.Context +import androidx.appcompat.widget.AppCompatEditText + +internal class MarkdownEditText( + context: Context, +) : AppCompatEditText(context) { + var onSelectionChangeListener: ((Int, Int) -> Unit)? = null + + private var isModifyingText = false + + fun updateEditableText(charSequence: CharSequence) { + isModifyingText = true + editableText.clear() + editableText.append(charSequence) + isModifyingText = false + } + + override fun setText(text: CharSequence?, type: BufferType?) { + isModifyingText = true + super.setText(text, type) + isModifyingText = false + } + + override fun onSelectionChanged(selStart: Int, selEnd: Int) { + super.onSelectionChanged(selStart, selEnd) + if (!isModifyingText) { + onSelectionChangeListener?.invoke(selStart, selEnd) + } + } +} diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/MarkdownTextInput.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/MarkdownTextInput.kt new file mode 100644 index 0000000000..a30334f04c --- /dev/null +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/MarkdownTextInput.kt @@ -0,0 +1,204 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.textcomposer.components.markdown + +import android.content.ClipData +import android.graphics.Color +import android.net.Uri +import android.text.Editable +import android.text.InputType +import android.text.Selection +import android.view.View +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.text.getSpans +import androidx.core.view.ContentInfoCompat +import androidx.core.view.OnReceiveContentListener +import androidx.core.view.ViewCompat +import androidx.core.view.setPadding +import androidx.core.widget.addTextChangedListener +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.testtags.TestTags +import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle +import io.element.android.libraries.textcomposer.mentions.MentionSpan +import io.element.android.libraries.textcomposer.model.MarkdownTextEditorState +import io.element.android.libraries.textcomposer.model.Suggestion +import io.element.android.libraries.textcomposer.model.SuggestionType +import io.element.android.wysiwyg.compose.RichTextEditorStyle +import io.element.android.wysiwyg.compose.internal.applyStyleInCompose + +@Suppress("ModifierMissing") +@Composable +fun MarkdownTextInput( + state: MarkdownTextEditorState, + subcomposing: Boolean, + onTyping: (Boolean) -> Unit, + onReceiveSuggestion: (Suggestion?) -> Unit, + richTextEditorStyle: RichTextEditorStyle, + onSelectRichContent: ((Uri) -> Unit)?, +) { + val canUpdateState = !subcomposing + + // Copied from io.element.android.wysiwyg.internal.utils.UriContentListener + class ReceiveUriContentListener( + private val onContent: (uri: Uri) -> Unit, + ) : OnReceiveContentListener { + override fun onReceiveContent(view: View, payload: ContentInfoCompat): ContentInfoCompat? { + val split = payload.partition { item -> item.uri != null } + val uriContent = split.first + val remaining = split.second + + if (uriContent != null) { + val clip: ClipData = uriContent.clip + for (i in 0 until clip.itemCount) { + val uri = clip.getItemAt(i).uri + // ... app-specific logic to handle the URI ... + onContent(uri) + } + } + // Return anything that we didn't handle ourselves. This preserves the default platform + // behavior for text and anything else for which we are not implementing custom handling. + return remaining + } + } + + AndroidView( + modifier = Modifier + .padding(top = 6.dp, bottom = 6.dp) + .fillMaxWidth(), + factory = { context -> + MarkdownEditText(context).apply { + tag = TestTags.plainTextEditor.value // Needed for UI tests + setPadding(0) + setBackgroundColor(Color.TRANSPARENT) + setText(state.text.value()) + inputType = InputType.TYPE_CLASS_TEXT or + InputType.TYPE_TEXT_FLAG_CAP_SENTENCES or + InputType.TYPE_TEXT_FLAG_MULTI_LINE or + InputType.TYPE_TEXT_FLAG_AUTO_CORRECT + if (canUpdateState) { + setSelection(state.selection.first, state.selection.last) + setOnFocusChangeListener { _, hasFocus -> + state.hasFocus = hasFocus + } + addTextChangedListener { editable -> + onTyping(!editable.isNullOrEmpty()) + state.text.update(editable, false) + state.lineCount = lineCount + + state.currentMentionSuggestion = editable?.checkSuggestionNeeded() + onReceiveSuggestion(state.currentMentionSuggestion) + } + onSelectionChangeListener = { selStart, selEnd -> + state.selection = selStart..selEnd + state.currentMentionSuggestion = editableText.checkSuggestionNeeded() + onReceiveSuggestion(state.currentMentionSuggestion) + } + if (onSelectRichContent != null) { + ViewCompat.setOnReceiveContentListener( + this, + arrayOf("image/*"), + ReceiveUriContentListener { onSelectRichContent(it) } + ) + } + state.requestFocusAction = { this.requestFocus() } + } + } + }, + update = { editText -> + editText.applyStyleInCompose(richTextEditorStyle) + + if (state.text.needsDisplaying()) { + editText.updateEditableText(state.text.value()) + if (canUpdateState) { + state.text.update(editText.editableText, false) + } + } + if (canUpdateState) { + val newSelectionStart = state.selection.first + val newSelectionEnd = state.selection.last + val currentTextRange = 0..editText.editableText.length + val didSelectionChange = { editText.selectionStart != newSelectionStart || editText.selectionEnd != newSelectionEnd } + val isNewSelectionValid = { newSelectionStart in currentTextRange && newSelectionEnd in currentTextRange } + if (didSelectionChange() && isNewSelectionValid()) { + editText.setSelection(state.selection.first, state.selection.last) + } + } + } + ) +} + +private fun Editable.checkSuggestionNeeded(): Suggestion? { + if (this.isEmpty()) return null + val start = Selection.getSelectionStart(this) + val end = Selection.getSelectionEnd(this) + var startOfWord = start + while ((startOfWord > 0 || startOfWord == length) && !this[startOfWord - 1].isWhitespace()) { + startOfWord-- + } + if (startOfWord !in indices) return null + val firstChar = this[startOfWord] + + // If a mention span already exists we don't need suggestions + if (getSpans(startOfWord, startOfWord + 1).isNotEmpty()) return null + + return if (firstChar in listOf('@', '#', '/')) { + var endOfWord = end + while (endOfWord < this.length && !this[endOfWord].isWhitespace()) { + endOfWord++ + } + val text = this.subSequence(startOfWord + 1, endOfWord).toString() + val suggestionType = when (firstChar) { + '@' -> SuggestionType.Mention + '#' -> SuggestionType.Room + '/' -> SuggestionType.Command + else -> error("Unknown suggestion type. This should never happen.") + } + Suggestion(startOfWord, endOfWord, suggestionType, text) + } else { + null + } +} + +@PreviewsDayNight +@Composable +internal fun MarkdownTextInputPreview() { + ElementPreview { + val style = ElementRichTextEditorStyle.composerStyle(hasFocus = true) + MarkdownTextInput( + state = aMarkdownTextEditorState(), + subcomposing = false, + onTyping = {}, + onReceiveSuggestion = {}, + richTextEditorStyle = style, + onSelectRichContent = {}, + ) + } +} + +internal fun aMarkdownTextEditorState( + initialText: String = "Hello, World!", + initialFocus: Boolean = true, +) = MarkdownTextEditorState( + initialText = initialText, + initialFocus = initialFocus, +) diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/StableCharSequence.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/StableCharSequence.kt new file mode 100644 index 0000000000..5491f9ccf4 --- /dev/null +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/StableCharSequence.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.textcomposer.components.markdown + +import android.text.SpannableString +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import io.element.android.libraries.core.extensions.orEmpty + +@Stable +class StableCharSequence(initialText: CharSequence = "") { + private var value by mutableStateOf(SpannableString(initialText)) + private var needsDisplaying by mutableStateOf(false) + + fun update(newText: CharSequence?, needsDisplaying: Boolean) { + value = SpannableString(newText.orEmpty()) + this.needsDisplaying = needsDisplaying + } + + fun value(): CharSequence = value + fun needsDisplaying(): Boolean = needsDisplaying + + override fun toString(): String { + return "ImmutableCharSequence(value='$value', needsDisplaying=$needsDisplaying)" + } +} diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpan.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpan.kt index 9788f1f6c3..fe1c0c2167 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpan.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpan.kt @@ -26,6 +26,8 @@ import kotlin.math.min import kotlin.math.roundToInt class MentionSpan( + val text: String, + val rawValue: String, val type: Type, val backgroundColor: Int, val textColor: Int, @@ -39,29 +41,25 @@ class MentionSpan( private var actualText: CharSequence? = null private var textWidth = 0 - private var cachedRect: RectF = RectF() private val backgroundPaint = Paint().apply { isAntiAlias = true color = backgroundColor } override fun getSize(paint: Paint, text: CharSequence?, start: Int, end: Int, fm: Paint.FontMetricsInt?): Int { - val mentionText = getActualText(text, start, end) + val mentionText = getActualText(this.text) paint.typeface = typeface textWidth = paint.measureText(mentionText, 0, mentionText.length).roundToInt() return textWidth + startPadding + endPadding } override fun draw(canvas: Canvas, text: CharSequence?, start: Int, end: Int, x: Float, top: Int, y: Int, bottom: Int, paint: Paint) { - val mentionText = getActualText(text, start, end) + val mentionText = getActualText(this.text) // Extra vertical space to add below the baseline (y). This helps us center the span vertically val extraVerticalSpace = y + paint.ascent() + paint.descent() - top - if (cachedRect.isEmpty) { - cachedRect = RectF(x, top.toFloat(), x + textWidth + startPadding + endPadding, y.toFloat() + extraVerticalSpace) - } - val rect = cachedRect + val rect = RectF(x, top.toFloat(), x + textWidth + startPadding + endPadding, y.toFloat() + extraVerticalSpace) val radius = rect.height() / 2 canvas.drawRoundRect(rect, radius, radius, backgroundPaint) paint.color = textColor @@ -69,24 +67,24 @@ class MentionSpan( canvas.drawText(mentionText, 0, mentionText.length, x + startPadding, y.toFloat(), paint) } - private fun getActualText(text: CharSequence?, start: Int, end: Int): CharSequence { + private fun getActualText(text: String): CharSequence { if (actualText != null) return actualText!! return buildString { val mentionText = text.orEmpty() when (type) { Type.USER -> { - if (start in mentionText.indices && mentionText[start] != '@') { + if (text.firstOrNull() != '@') { append("@") } } Type.ROOM -> { - if (start in mentionText.indices && mentionText[start] != '#') { + if (text.firstOrNull() != '#') { append("#") } } } - append(mentionText.substring(start, min(end, start + MAX_LENGTH))) - if (end - start > MAX_LENGTH) { + append(mentionText.substring(0, min(mentionText.length, MAX_LENGTH))) + if (mentionText.length > MAX_LENGTH) { append("…") } actualText = this diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpanProvider.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpanProvider.kt index 7d8bfd34ce..e5c9f4793c 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpanProvider.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpanProvider.kt @@ -84,6 +84,8 @@ class MentionSpanProvider( permalinkData is PermalinkData.UserLink -> { val isCurrentUser = permalinkData.userId == currentSessionId MentionSpan( + text = text, + rawValue = permalinkData.userId.toString(), type = MentionSpan.Type.USER, backgroundColor = if (isCurrentUser) currentUserBackgroundColor else otherBackgroundColor, textColor = if (isCurrentUser) currentUserTextColor else otherTextColor, @@ -94,6 +96,8 @@ class MentionSpanProvider( } text == "@room" && permalinkData is PermalinkData.FallbackLink -> { MentionSpan( + text = text, + rawValue = "@room", type = MentionSpan.Type.USER, backgroundColor = otherBackgroundColor, textColor = otherTextColor, @@ -102,8 +106,22 @@ class MentionSpanProvider( typeface = typeface.value, ) } + permalinkData is PermalinkData.RoomLink -> { + MentionSpan( + text = text, + rawValue = permalinkData.roomIdOrAlias.toString(), + type = MentionSpan.Type.ROOM, + backgroundColor = otherBackgroundColor, + textColor = otherTextColor, + startPadding = startPaddingPx, + endPadding = endPaddingPx, + typeface = typeface.value, + ) + } else -> { MentionSpan( + text = text, + rawValue = text, type = MentionSpan.Type.ROOM, backgroundColor = otherBackgroundColor, textColor = otherTextColor, @@ -146,7 +164,7 @@ internal fun MentionSpanPreview() { eventId = null, viaParameters = persistentListOf(), ) - else -> TODO() + else -> throw AssertionError("Unexpected value $uriString") } } }, @@ -155,8 +173,8 @@ internal fun MentionSpanPreview() { provider.setup() val textColor = ElementTheme.colors.textPrimary.toArgb() - fun mentionSpanMe() = provider.getMentionSpanFor("me", "https://matrix.to/#/@me:matrix.org") - fun mentionSpanOther() = provider.getMentionSpanFor("other", "https://matrix.to/#/@other:matrix.org") + fun mentionSpanMe() = provider.getMentionSpanFor("mention", "https://matrix.to/#/@me:matrix.org") + fun mentionSpanOther() = provider.getMentionSpanFor("mention", "https://matrix.to/#/@other:matrix.org") fun mentionSpanRoom() = provider.getMentionSpanFor("room", "https://matrix.to/#/#room:matrix.org") AndroidView(factory = { context -> TextView(context).apply { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mentions/MentionSuggestion.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/ResolvedMentionSuggestion.kt similarity index 71% rename from features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mentions/MentionSuggestion.kt rename to libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/ResolvedMentionSuggestion.kt index b2977bd508..03bc48f53d 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mentions/MentionSuggestion.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/ResolvedMentionSuggestion.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 New Vector Ltd + * Copyright (c) 2024 New Vector Ltd * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -14,13 +14,13 @@ * limitations under the License. */ -package io.element.android.features.messages.impl.mentions +package io.element.android.libraries.textcomposer.mentions import androidx.compose.runtime.Immutable import io.element.android.libraries.matrix.api.room.RoomMember @Immutable -sealed interface MentionSuggestion { - data object Room : MentionSuggestion - data class Member(val roomMember: RoomMember) : MentionSuggestion +sealed interface ResolvedMentionSuggestion { + data object AtRoom : ResolvedMentionSuggestion + data class Member(val roomMember: RoomMember) : ResolvedMentionSuggestion } diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/MarkdownTextEditorState.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/MarkdownTextEditorState.kt new file mode 100644 index 0000000000..273aefa57b --- /dev/null +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/MarkdownTextEditorState.kt @@ -0,0 +1,163 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.textcomposer.model + +import android.os.Parcelable +import android.text.Spannable +import android.text.SpannableString +import android.text.SpannableStringBuilder +import android.text.Spanned +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.SaverScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.core.text.getSpans +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder +import io.element.android.libraries.matrix.api.room.Mention +import io.element.android.libraries.textcomposer.components.markdown.StableCharSequence +import io.element.android.libraries.textcomposer.mentions.MentionSpan +import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider +import io.element.android.libraries.textcomposer.mentions.ResolvedMentionSuggestion +import kotlinx.parcelize.Parcelize + +@Stable +class MarkdownTextEditorState( + initialText: String?, + initialFocus: Boolean, +) { + var text by mutableStateOf(StableCharSequence(initialText ?: "")) + var selection by mutableStateOf(0..0) + var hasFocus by mutableStateOf(initialFocus) + var requestFocusAction by mutableStateOf({}) + var lineCount by mutableIntStateOf(1) + var currentMentionSuggestion by mutableStateOf(null) + + fun insertMention( + mention: ResolvedMentionSuggestion, + mentionSpanProvider: MentionSpanProvider, + permalinkBuilder: PermalinkBuilder, + ) { + val suggestion = currentMentionSuggestion ?: return + when (mention) { + is ResolvedMentionSuggestion.AtRoom -> { + val currentText = SpannableStringBuilder(text.value()) + val replaceText = "@room" + val roomPill = mentionSpanProvider.getMentionSpanFor(replaceText, "") + currentText.replace(suggestion.start, suggestion.end, ". ") + val end = suggestion.start + 1 + currentText.setSpan(roomPill, suggestion.start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + text.update(currentText, true) + selection = IntRange(end + 1, end + 1) + } + is ResolvedMentionSuggestion.Member -> { + val currentText = SpannableStringBuilder(text.value()) + val text = mention.roomMember.displayName?.prependIndent("@") ?: mention.roomMember.userId.value + val link = permalinkBuilder.permalinkForUser(mention.roomMember.userId).getOrNull() ?: return + val mentionPill = mentionSpanProvider.getMentionSpanFor(text, link) + currentText.replace(suggestion.start, suggestion.end, ". ") + val end = suggestion.start + 1 + currentText.setSpan(mentionPill, suggestion.start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + this.text.update(currentText, true) + this.selection = IntRange(end + 1, end + 1) + } + } + } + + fun getMessageMarkdown(permalinkBuilder: PermalinkBuilder): String { + val charSequence = text.value() + return if (charSequence is Spanned) { + val mentions = charSequence.getSpans(0, charSequence.length, MentionSpan::class.java) + buildString { + append(charSequence.toString()) + if (mentions != null && mentions.isNotEmpty()) { + for (mention in mentions.reversed()) { + val start = charSequence.getSpanStart(mention) + val end = charSequence.getSpanEnd(mention) + if (mention.type == MentionSpan.Type.USER) { + if (mention.rawValue == "@room") { + replace(start, end, "@room") + } else { + val link = permalinkBuilder.permalinkForUser(UserId(mention.rawValue)).getOrNull() ?: continue + replace(start, end, "[${mention.text}]($link)") + } + } + } + } + } + } else { + charSequence.toString() + } + } + + fun getMentions(): List { + val text = SpannableString(text.value()) + val mentionSpans = text.getSpans(0, text.length) + return mentionSpans.mapNotNull { mentionSpan -> + when (mentionSpan.type) { + MentionSpan.Type.USER -> { + if (mentionSpan.rawValue == "@room") { + Mention.AtRoom + } else { + Mention.User(UserId(mentionSpan.rawValue)) + } + } + else -> null + } + } + } + + @Parcelize + data class SavedState( + val text: CharSequence, + val selectionStart: Int, + val selectionEnd: Int, + ) : Parcelable +} + +object MarkdownTextEditorStateSaver : Saver { + override fun restore(value: MarkdownTextEditorState.SavedState): MarkdownTextEditorState { + return MarkdownTextEditorState( + initialText = "", + initialFocus = false, + ).apply { + text.update(value.text, true) + selection = value.selectionStart..value.selectionEnd + } + } + + override fun SaverScope.save(value: MarkdownTextEditorState): MarkdownTextEditorState.SavedState { + return MarkdownTextEditorState.SavedState( + text = value.text.value(), + selectionStart = value.selection.first, + selectionEnd = value.selection.last, + ) + } +} + +@Composable +fun rememberMarkdownTextEditorState( + initialText: String? = null, + initialFocus: Boolean = false, +): MarkdownTextEditorState { + return rememberSaveable(saver = MarkdownTextEditorStateSaver) { MarkdownTextEditorState(initialText, initialFocus) } +} diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/TextEditorState.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/TextEditorState.kt new file mode 100644 index 0000000000..ae7a15fb65 --- /dev/null +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/TextEditorState.kt @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.textcomposer.model + +import androidx.compose.runtime.Immutable +import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder +import io.element.android.wysiwyg.compose.RichTextEditorState + +@Immutable +sealed interface TextEditorState { + data class Markdown( + val state: MarkdownTextEditorState, + ) : TextEditorState + + data class Rich( + val richTextEditorState: RichTextEditorState + ) : TextEditorState + + fun messageHtml(): String? = when (this) { + is Markdown -> null + is Rich -> richTextEditorState.messageHtml + } + + fun messageMarkdown(permalinkBuilder: PermalinkBuilder): String = when (this) { + is Markdown -> state.getMessageMarkdown(permalinkBuilder) + is Rich -> richTextEditorState.messageMarkdown + } + + fun hasFocus(): Boolean = when (this) { + is Markdown -> state.hasFocus + is Rich -> richTextEditorState.hasFocus + } + + suspend fun reset() { + when (this) { + is Markdown -> { + state.selection = IntRange.EMPTY + state.text.update("", true) + } + is Rich -> richTextEditorState.setHtml("") + } + } + + suspend fun requestFocus() { + when (this) { + is Markdown -> state.requestFocusAction() + is Rich -> richTextEditorState.requestFocus() + } + } + + val lineCount: Int get() = when (this) { + is Markdown -> state.lineCount + is Rich -> richTextEditorState.lineCount + } +} diff --git a/libraries/textcomposer/impl/src/main/res/values-ka/translations.xml b/libraries/textcomposer/impl/src/main/res/values-ka/translations.xml new file mode 100644 index 0000000000..f79c0eb518 --- /dev/null +++ b/libraries/textcomposer/impl/src/main/res/values-ka/translations.xml @@ -0,0 +1,25 @@ + + + "დაამატეთ დანართი" + "პუნქტების სიის ჩართვა" + "ფორმატირების პარამეტრები დახურვა" + "კოდის ბლოკის ჩართვა" + "შეტყობინება…" + "ბმულის შექმნა" + "ბმულის რედაქტირება" + "თამამი შრიფტის გამოყენება" + "კურსიული შრიფტის გამოყენება" + "გადახაზული ფორმატის გამოყენება" + "ხაზგასმული ფორმატის გამოყენება" + "სრული ეკრანის რეჟიმის ჩართვა" + "აბზაცი" + "კოდის შიდა ფორმატის გამოყენება" + "ბმულის დაყენება" + "დანომრილი სიის ჩართვა" + "გახსენით შედგენის ვარიანტები" + "ციტატის ჩართვა" + "ბმულის წაშლა" + "აბზაცის გარეშე" + "Ბმული" + "ჩასაწერად დააჭირეთ" + diff --git a/libraries/textcomposer/impl/src/main/res/values-pt-rBR/translations.xml b/libraries/textcomposer/impl/src/main/res/values-pt-rBR/translations.xml new file mode 100644 index 0000000000..6d11e3110c --- /dev/null +++ b/libraries/textcomposer/impl/src/main/res/values-pt-rBR/translations.xml @@ -0,0 +1,25 @@ + + + "Adicionar anexo" + "Ativar/desativar lista de pontos" + "Fechar opções de formatação" + "Ativar/desativar bloco de código" + "Mensagem…" + "Criar uma ligação" + "Editar ligação" + "Aplicar negrito" + "Aplicar itálico" + "Aplicar rasura" + "Aplicar sublinhado" + "Entrar/sair do modo de ecrã inteiro" + "Indentar" + "Aplicar código em linha" + "Definir ligação" + "Alternar lista numerada" + "Abrir opções de escrita" + "Pôr/tirar aspas" + "Remover ligação" + "Desindentar" + "Ligação" + "Segurar para gravar" + diff --git a/libraries/textcomposer/impl/src/main/res/values-ru/translations.xml b/libraries/textcomposer/impl/src/main/res/values-ru/translations.xml index 45cb63ded4..4a7fb7b854 100644 --- a/libraries/textcomposer/impl/src/main/res/values-ru/translations.xml +++ b/libraries/textcomposer/impl/src/main/res/values-ru/translations.xml @@ -4,7 +4,7 @@ "Переключить список маркеров" "Закрыть параметры форматирования" "Переключить блок кода" - "Сообщение" + "Сообщение…" "Создать ссылку" "Редактировать ссылку" "Применить жирный шрифт" diff --git a/libraries/textcomposer/impl/src/main/res/values-zh/translations.xml b/libraries/textcomposer/impl/src/main/res/values-zh/translations.xml new file mode 100644 index 0000000000..0a6b2cf6a2 --- /dev/null +++ b/libraries/textcomposer/impl/src/main/res/values-zh/translations.xml @@ -0,0 +1,25 @@ + + + "添加附件" + "切换符号列表" + "关闭格式化选项" + "切换代码块" + "消息…" + "创建链接" + "编辑链接" + "应用粗体格式" + "应用斜体格式" + "应用删除线格式" + "应用下划线格式" + "切换全屏模式" + "缩进" + "应用行内代码格式" + "设置链接" + "切换编号列表" + "打开撰写选项" + "切换引用" + "删除链接" + "取消缩进" + "链接" + "按住录制" + diff --git a/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/components/markdown/MarkdownTextInputTest.kt b/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/components/markdown/MarkdownTextInputTest.kt new file mode 100644 index 0000000000..dc2d197db8 --- /dev/null +++ b/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/components/markdown/MarkdownTextInputTest.kt @@ -0,0 +1,202 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.textcomposer.impl.components.markdown + +import android.widget.EditText +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.core.text.getSpans +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.permalink.PermalinkData +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.matrix.test.permalink.FakePermalinkBuilder +import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser +import io.element.android.libraries.matrix.test.room.aRoomMember +import io.element.android.libraries.testtags.TestTags +import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle +import io.element.android.libraries.textcomposer.components.markdown.MarkdownTextInput +import io.element.android.libraries.textcomposer.components.markdown.aMarkdownTextEditorState +import io.element.android.libraries.textcomposer.mentions.MentionSpan +import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider +import io.element.android.libraries.textcomposer.mentions.ResolvedMentionSuggestion +import io.element.android.libraries.textcomposer.model.MarkdownTextEditorState +import io.element.android.libraries.textcomposer.model.Suggestion +import io.element.android.libraries.textcomposer.model.SuggestionType +import io.element.android.tests.testutils.EnsureCalledOnceWithParam +import io.element.android.tests.testutils.EventsRecorder +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class MarkdownTextInputTest { + @get:Rule val rule = createAndroidComposeRule() + + @Test + fun `when user types onTyping is triggered with value 'true'`() = runTest { + val state = aMarkdownTextEditorState(initialFocus = true) + val onTyping = EnsureCalledOnceWithParam(expectedParam = true, result = Unit) + rule.setMarkdownTextInput(state = state, onTyping = onTyping) + rule.activityRule.scenario.onActivity { + it.findEditor().setText("Test") + } + rule.awaitIdle() + onTyping.assertSuccess() + } + + @Test + fun `when user removes text onTyping is triggered with value 'false'`() = runTest { + val state = aMarkdownTextEditorState(initialFocus = true) + val onTyping = EventsRecorder() + rule.setMarkdownTextInput(state = state, onTyping = onTyping) + rule.activityRule.scenario.onActivity { + val editText = it.findEditor() + editText.setText("Test") + editText.setText("") + editText.setText(null) + } + rule.awaitIdle() + onTyping.assertList(listOf(true, false, false)) + } + + @Test + fun `when user types something that's not a mention onSuggestionReceived is triggered with 'null'`() = runTest { + val state = aMarkdownTextEditorState(initialFocus = true) + val onSuggestionReceived = EventsRecorder() + rule.setMarkdownTextInput(state = state, onSuggestionReceived = onSuggestionReceived) + rule.activityRule.scenario.onActivity { + it.findEditor().setText("Test") + } + rule.awaitIdle() + onSuggestionReceived.assertSingle(null) + } + + @Test + fun `when user types something that's a mention onSuggestionReceived is triggered a real value`() = runTest { + val state = aMarkdownTextEditorState(initialFocus = true) + val onSuggestionReceived = EventsRecorder() + rule.setMarkdownTextInput(state = state, onSuggestionReceived = onSuggestionReceived) + rule.activityRule.scenario.onActivity { + it.findEditor().setText("@") + it.findEditor().setText("#") + it.findEditor().setText("/") + } + rule.awaitIdle() + onSuggestionReceived.assertList( + listOf( + // User mention suggestion + Suggestion(0, 1, SuggestionType.Mention, ""), + // Room suggestion + Suggestion(0, 1, SuggestionType.Room, ""), + // Slash command suggestion + Suggestion(0, 1, SuggestionType.Command, ""), + ) + ) + } + + @Test + fun `when the selection changes in the UI the state is updated`() = runTest { + val state = aMarkdownTextEditorState(initialText = "Test", initialFocus = true) + rule.setMarkdownTextInput(state = state) + rule.activityRule.scenario.onActivity { + val editor = it.findEditor() + editor.setSelection(2) + } + rule.awaitIdle() + // Selection is updated + assertThat(state.selection).isEqualTo(2..2) + } + + @Test + fun `when the selection state changes in the view is updated`() = runTest { + val state = aMarkdownTextEditorState(initialText = "Test", initialFocus = true) + rule.setMarkdownTextInput(state = state) + var editor: EditText? = null + rule.activityRule.scenario.onActivity { + editor = it.findEditor() + state.selection = 2..2 + } + rule.awaitIdle() + // Selection state is updated + assertThat(editor?.selectionStart).isEqualTo(2) + assertThat(editor?.selectionEnd).isEqualTo(2) + } + + @Test + fun `when the view focus changes the state is updated`() = runTest { + val state = aMarkdownTextEditorState(initialText = "Test", initialFocus = false) + rule.setMarkdownTextInput(state = state) + rule.activityRule.scenario.onActivity { + val editor = it.findEditor() + editor.requestFocus() + } + // Focus state is updated + assertThat(state.hasFocus).isTrue() + } + + @Test + fun `inserting a mention replaces the existing text with a span`() = runTest { + val permalinkParser = FakePermalinkParser(result = { PermalinkData.UserLink(A_SESSION_ID) }) + val permalinkBuilder = FakePermalinkBuilder(result = { Result.success("https://matrix.to/#/$A_SESSION_ID") }) + val state = aMarkdownTextEditorState(initialText = "@", initialFocus = true) + state.currentMentionSuggestion = Suggestion(0, 1, SuggestionType.Mention, "") + rule.setMarkdownTextInput(state = state) + var editor: EditText? = null + rule.activityRule.scenario.onActivity { + editor = it.findEditor() + state.insertMention( + ResolvedMentionSuggestion.Member(roomMember = aRoomMember()), + MentionSpanProvider(currentSessionId = A_SESSION_ID, permalinkParser = permalinkParser), + permalinkBuilder, + ) + } + rule.awaitIdle() + + // Text is replaced with a placeholder + assertThat(editor?.editableText.toString()).isEqualTo(". ") + // The placeholder contains a MentionSpan + val mentionSpans = editor?.editableText?.getSpans(0, 2).orEmpty() + assertThat(mentionSpans).isNotEmpty() + } + + private fun AndroidComposeTestRule.setMarkdownTextInput( + state: MarkdownTextEditorState = aMarkdownTextEditorState(), + subcomposing: Boolean = false, + onTyping: (Boolean) -> Unit = {}, + onSuggestionReceived: (Suggestion?) -> Unit = {}, + ) { + rule.setContent { + val style = ElementRichTextEditorStyle.composerStyle(hasFocus = state.hasFocus) + MarkdownTextInput( + state = state, + subcomposing = subcomposing, + onTyping = onTyping, + onReceiveSuggestion = onSuggestionReceived, + richTextEditorStyle = style, + onSelectRichContent = null, + ) + } + } + + private fun ComponentActivity.findEditor(): EditText { + return window.decorView.findViewWithTag(TestTags.plainTextEditor.value) + } +} diff --git a/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/mentions/MentionSpanProviderTest.kt b/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/mentions/MentionSpanProviderTest.kt index 2b346ceeab..7245da04c6 100644 --- a/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/mentions/MentionSpanProviderTest.kt +++ b/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/mentions/MentionSpanProviderTest.kt @@ -17,6 +17,7 @@ package io.element.android.libraries.textcomposer.impl.mentions import android.graphics.Color +import android.net.Uri import com.google.common.truth.Truth.assertThat import io.element.android.libraries.matrix.api.core.RoomAlias import io.element.android.libraries.matrix.api.core.UserId @@ -66,6 +67,14 @@ class MentionSpanProviderTest { assertThat(mentionSpan.textColor).isEqualTo(otherColor) } + @Test + fun `getting mention span for everyone in the room`() { + permalinkParser.givenResult(PermalinkData.FallbackLink(uri = Uri.EMPTY)) + val mentionSpan = mentionSpanProvider.getMentionSpanFor("@room", "#") + assertThat(mentionSpan.backgroundColor).isEqualTo(otherColor) + assertThat(mentionSpan.textColor).isEqualTo(otherColor) + } + @Test fun `getting mention span for a room should return a MentionSpan with normal colors`() { permalinkParser.givenResult( diff --git a/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/model/MarkdownTextEditorStateTest.kt b/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/model/MarkdownTextEditorStateTest.kt new file mode 100644 index 0000000000..bd2b6785ed --- /dev/null +++ b/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/model/MarkdownTextEditorStateTest.kt @@ -0,0 +1,171 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.textcomposer.impl.model + +import android.net.Uri +import androidx.core.text.buildSpannedString +import androidx.core.text.inSpans +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.permalink.PermalinkData +import io.element.android.libraries.matrix.api.room.Mention +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.matrix.test.permalink.FakePermalinkBuilder +import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser +import io.element.android.libraries.matrix.test.room.aRoomMember +import io.element.android.libraries.textcomposer.mentions.MentionSpan +import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider +import io.element.android.libraries.textcomposer.mentions.ResolvedMentionSuggestion +import io.element.android.libraries.textcomposer.model.MarkdownTextEditorState +import io.element.android.libraries.textcomposer.model.Suggestion +import io.element.android.libraries.textcomposer.model.SuggestionType +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class MarkdownTextEditorStateTest { + @Test + fun `insertMention - with no currentMentionSuggestion does nothing`() { + val state = MarkdownTextEditorState(initialText = "Hello @", initialFocus = true) + val member = aRoomMember() + val mention = ResolvedMentionSuggestion.Member(member) + val permalinkBuilder = FakePermalinkBuilder() + val mentionSpanProvider = aMentionSpanProvider() + + state.insertMention(mention, mentionSpanProvider, permalinkBuilder) + + assertThat(state.getMentions()).isEmpty() + } + + @Test + fun `insertMention - with member but failed PermalinkBuilder result`() { + val state = MarkdownTextEditorState(initialText = "Hello @", initialFocus = true).apply { + currentMentionSuggestion = Suggestion(start = 6, end = 7, type = SuggestionType.Mention, text = "") + } + val member = aRoomMember() + val mention = ResolvedMentionSuggestion.Member(member) + val permalinkParser = FakePermalinkParser(result = { PermalinkData.UserLink(member.userId) }) + val permalinkBuilder = FakePermalinkBuilder(result = { Result.failure(IllegalStateException("Failed")) }) + val mentionSpanProvider = aMentionSpanProvider(permalinkParser = permalinkParser) + + state.insertMention(mention, mentionSpanProvider, permalinkBuilder) + + val mentions = state.getMentions() + assertThat(mentions).isEmpty() + } + + @Test + fun `insertMention - with member`() { + val state = MarkdownTextEditorState(initialText = "Hello @", initialFocus = true).apply { + currentMentionSuggestion = Suggestion(start = 6, end = 7, type = SuggestionType.Mention, text = "") + } + val member = aRoomMember() + val mention = ResolvedMentionSuggestion.Member(member) + val permalinkParser = FakePermalinkParser(result = { PermalinkData.UserLink(member.userId) }) + val permalinkBuilder = FakePermalinkBuilder(result = { Result.success("https://matrix.to/#/${member.userId}") }) + val mentionSpanProvider = aMentionSpanProvider(permalinkParser = permalinkParser) + + state.insertMention(mention, mentionSpanProvider, permalinkBuilder) + + val mentions = state.getMentions() + assertThat(mentions).isNotEmpty() + assertThat((mentions.firstOrNull() as? Mention.User)?.userId).isEqualTo(member.userId) + } + + @Test + fun `insertMention - with @room`() { + val state = MarkdownTextEditorState(initialText = "Hello @", initialFocus = true).apply { + currentMentionSuggestion = Suggestion(start = 6, end = 7, type = SuggestionType.Mention, text = "") + } + val mention = ResolvedMentionSuggestion.AtRoom + val permalinkBuilder = FakePermalinkBuilder() + val permalinkParser = FakePermalinkParser(result = { PermalinkData.FallbackLink(Uri.EMPTY, false) }) + val mentionSpanProvider = aMentionSpanProvider(permalinkParser = permalinkParser) + + state.insertMention(mention, mentionSpanProvider, permalinkBuilder) + + val mentions = state.getMentions() + assertThat(mentions).isNotEmpty() + assertThat(mentions.firstOrNull()).isInstanceOf(Mention.AtRoom::class.java) + } + + @Test + fun `getMessageMarkdown - when there are no MentionSpans returns the same text`() { + val text = "No mentions here" + val state = MarkdownTextEditorState(initialText = text, initialFocus = true) + + val markdown = state.getMessageMarkdown(FakePermalinkBuilder()) + + assertThat(markdown).isEqualTo(text) + } + + @Test + fun `getMessageMarkdown - when there are MentionSpans returns the same text with links to the mentions`() { + val text = "No mentions here" + val permalinkBuilder = FakePermalinkBuilder(result = { Result.success("https://matrix.to/#/$it") }) + val state = MarkdownTextEditorState(initialText = text, initialFocus = true) + state.text.update(aMarkdownTextWithMentions(), needsDisplaying = false) + + val markdown = state.getMessageMarkdown(permalinkBuilder = permalinkBuilder) + + assertThat(markdown).isEqualTo( + "Hello [@Alice](https://matrix.to/#/@alice:matrix.org) and everyone in @room" + ) + } + + @Test + fun `getMentions - when there are no MentionSpans returns empty list of mentions`() { + val state = MarkdownTextEditorState(initialText = "Hello @", initialFocus = true) + + assertThat(state.getMentions()).isEmpty() + } + + @Test + fun `getMentions - when there are MentionSpans returns a list of mentions`() { + val state = MarkdownTextEditorState(initialText = "Hello @", initialFocus = true) + state.text.update(aMarkdownTextWithMentions(), needsDisplaying = false) + + val mentions = state.getMentions() + + assertThat(mentions).isNotEmpty() + assertThat((mentions.firstOrNull() as? Mention.User)?.userId?.value).isEqualTo("@alice:matrix.org") + assertThat(mentions.lastOrNull()).isInstanceOf(Mention.AtRoom::class.java) + } + + private fun aMentionSpanProvider( + currentSessionId: SessionId = A_SESSION_ID, + permalinkParser: FakePermalinkParser = FakePermalinkParser(), + ): MentionSpanProvider { + return MentionSpanProvider(currentSessionId, permalinkParser) + } + + private fun aMarkdownTextWithMentions(): CharSequence { + val userMentionSpan = MentionSpan("@Alice", "@alice:matrix.org", MentionSpan.Type.USER, 0, 0, 0, 0) + val atRoomMentionSpan = MentionSpan("@room", "@room", MentionSpan.Type.USER, 0, 0, 0, 0) + return buildSpannedString { + append("Hello ") + inSpans(userMentionSpan) { + append("@") + } + append(" and everyone in ") + inSpans(atRoomMentionSpan) { + append("@") + } + } + } +} diff --git a/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/TroubleshootNotificationsNode.kt b/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/TroubleshootNotificationsNode.kt index 96404d6d8c..351baff498 100644 --- a/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/TroubleshootNotificationsNode.kt +++ b/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/TroubleshootNotificationsNode.kt @@ -49,7 +49,7 @@ class TroubleshootNotificationsNode @AssistedInject constructor( val state = presenter.present() TroubleshootNotificationsView( state = state, - onBackPressed = ::onDone, + onBackClick = ::onDone, modifier = modifier, ) } diff --git a/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/TroubleshootNotificationsView.kt b/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/TroubleshootNotificationsView.kt index 8b2ba843be..26232f8d69 100644 --- a/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/TroubleshootNotificationsView.kt +++ b/libraries/troubleshoot/impl/src/main/kotlin/io/element/android/libraries/troubleshoot/impl/TroubleshootNotificationsView.kt @@ -44,7 +44,7 @@ import io.element.android.libraries.troubleshoot.api.test.NotificationTroublesho @Composable fun TroubleshootNotificationsView( state: TroubleshootNotificationsState, - onBackPressed: () -> Unit, + onBackClick: () -> Unit, modifier: Modifier = Modifier, ) { OnLifecycleEvent { _, event -> @@ -60,7 +60,7 @@ fun TroubleshootNotificationsView( PreferencePage( modifier = modifier, - onBackPressed = onBackPressed, + onBackClick = onBackClick, title = stringResource(id = R.string.troubleshoot_notifications_screen_title), ) { TroubleshootNotificationsContent(state) @@ -70,7 +70,7 @@ fun TroubleshootNotificationsView( @Composable private fun TroubleshootTestView( testState: NotificationTroubleshootTestState, - onQuickFixClicked: () -> Unit, + onQuickFixClick: () -> Unit, ) { if ((testState.status as? Status.Idle)?.visible == false) return ListItem( @@ -119,7 +119,7 @@ private fun TroubleshootTestView( trailingContent = ListItemContent.Custom { Button( text = stringResource(id = R.string.troubleshoot_notifications_screen_quick_fix_action), - onClick = onQuickFixClicked + onClick = onQuickFixClick ) } ) @@ -135,7 +135,7 @@ private fun TroubleshootNotificationsContent(state: TroubleshootNotificationsSta is AsyncAction.Failure -> { TestSuiteView( testSuiteState = state.testSuiteState, - onQuickFixClicked = { + onQuickFixClick = { state.eventSink(TroubleshootNotificationsEvents.QuickFix(it)) } ) @@ -199,13 +199,13 @@ private fun RunTestButton(state: TroubleshootNotificationsState) { @Composable private fun TestSuiteView( testSuiteState: TroubleshootTestSuiteState, - onQuickFixClicked: (Int) -> Unit, + onQuickFixClick: (Int) -> Unit, ) { testSuiteState.tests.forEachIndexed { index, testState -> TroubleshootTestView( testState = testState, - onQuickFixClicked = { - onQuickFixClicked(index) + onQuickFixClick = { + onQuickFixClick(index) }, ) } @@ -218,6 +218,6 @@ internal fun TroubleshootNotificationsViewPreview( ) = ElementPreview { TroubleshootNotificationsView( state = state, - onBackPressed = {}, + onBackClick = {}, ) } diff --git a/libraries/troubleshoot/impl/src/main/res/values-it/translations.xml b/libraries/troubleshoot/impl/src/main/res/values-it/translations.xml index 15328545ec..4c359a8140 100644 --- a/libraries/troubleshoot/impl/src/main/res/values-it/translations.xml +++ b/libraries/troubleshoot/impl/src/main/res/values-it/translations.xml @@ -1,6 +1,11 @@ "Esegui i test" + "Esegui nuovamente i test" + "Alcuni test sono falliti. Si prega di controllare i dettagli." "Esegui i test per individuare eventuali problemi nella tua configurazione che potrebbero far sì che le notifiche non si comportino come previsto." + "Prova a risolvere" + "Tutti i test sono stati superati con successo." "Risoluzione di problemi delle notifiche" + "Alcuni test richiedono la tua attenzione. Si prega di controllare i dettagli." diff --git a/libraries/troubleshoot/impl/src/main/res/values-pt-rBR/translations.xml b/libraries/troubleshoot/impl/src/main/res/values-pt-rBR/translations.xml new file mode 100644 index 0000000000..33f54d0712 --- /dev/null +++ b/libraries/troubleshoot/impl/src/main/res/values-pt-rBR/translations.xml @@ -0,0 +1,11 @@ + + + "Correr testes" + "Correr testes novamente" + "Alguns testes falharam. Por favor, verifica os detalhes." + "Corre os testes para detetar problemas com a tua configuração que possam levar a comportamentos inesperados das notificações." + "Tentar corrigir" + "Todos os testes realizados sem problemas." + "Resolver problemas com as notificações" + "Alguns testes necessitam a tua atenção. Por favor, verifica os detalhes." + diff --git a/libraries/troubleshoot/impl/src/main/res/values-ro/translations.xml b/libraries/troubleshoot/impl/src/main/res/values-ro/translations.xml new file mode 100644 index 0000000000..a10361672e --- /dev/null +++ b/libraries/troubleshoot/impl/src/main/res/values-ro/translations.xml @@ -0,0 +1,11 @@ + + + "Rulați testele" + "Rulați din nou testele" + "Unele teste au eșuat. Vă rugăm să verificați detaliile." + "Rulați testele pentru a detecta probleme în configurația dumneavoastră care poat face ca notificările să nu se funcționeze corect." + "Încercați să remediați" + "Toate testele au trecut cu succes." + "Depanați notificările" + "Unele teste necesită atenția dumneavoastră. Vă rugăm să verificați detaliile." + diff --git a/libraries/troubleshoot/impl/src/main/res/values-zh/translations.xml b/libraries/troubleshoot/impl/src/main/res/values-zh/translations.xml new file mode 100644 index 0000000000..22354b62b7 --- /dev/null +++ b/libraries/troubleshoot/impl/src/main/res/values-zh/translations.xml @@ -0,0 +1,11 @@ + + + "运行测试" + "再次运行测试" + "一些测试失败了。请查看详情。" + "运行测试以检测您的配置中可能导致通知无法按预期运行的问题。" + "尝试修复" + "所有测试均成功通过。" + "排查通知问题" + "有些测试需要你注意。请查看详情。" + diff --git a/libraries/troubleshoot/impl/src/test/kotlin/io/element/android/libraries/troubleshoot/impl/TroubleshootNotificationsViewTest.kt b/libraries/troubleshoot/impl/src/test/kotlin/io/element/android/libraries/troubleshoot/impl/TroubleshootNotificationsViewTest.kt index 5acd0e5635..683fea637d 100644 --- a/libraries/troubleshoot/impl/src/test/kotlin/io/element/android/libraries/troubleshoot/impl/TroubleshootNotificationsViewTest.kt +++ b/libraries/troubleshoot/impl/src/test/kotlin/io/element/android/libraries/troubleshoot/impl/TroubleshootNotificationsViewTest.kt @@ -45,7 +45,7 @@ class TroubleshootNotificationsViewTest { state = aTroubleshootNotificationsState( eventSink = eventsRecorder ), - onBackPressed = it, + onBackClick = it, ) rule.pressBack() } @@ -112,12 +112,12 @@ class TroubleshootNotificationsViewTest { private fun AndroidComposeTestRule.setTroubleshootNotificationsView( state: TroubleshootNotificationsState, - onBackPressed: () -> Unit = EnsureNeverCalled(), + onBackClick: () -> Unit = EnsureNeverCalled(), ) { setContent { TroubleshootNotificationsView( state = state, - onBackPressed = onBackPressed, + onBackClick = onBackClick, ) } } 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 c155ad7ca3..d4de8db182 100644 --- a/libraries/ui-strings/src/main/res/values-be/translations.xml +++ b/libraries/ui-strings/src/main/res/values-be/translations.xml @@ -36,6 +36,7 @@ "Прыняць" "Дадаць у хроніку" "Назад" + "Выклік" "Скасаваць" "Выбраць фота" "Ачысціць" @@ -74,6 +75,7 @@ "Загрузіць больш" "Кіраванне ўліковым запісам" "Кіраванне прыладамі" + "Паведамленне" "Далей" "Не" "Не зараз" @@ -119,6 +121,7 @@ "Заблакіраваныя карыстальнікі" "Бурбалкі" "Ідзе выклік (не падтрымліваецца)" + "Выклік пачаўся" "Рэзервовае капіраванне чатаў" "Аўтарскае права" "Стварэнне пакоя…" @@ -141,7 +144,7 @@ "Файл захаваны ў папку Спампоўкі" "Перасылка паведамлення" "GIF" - "Выява" + "Відарыс" "У адказ на %1$s" "Усталяваць APK" "Гэты Matrix ID не знойдзены, таму запрашэнне можа быць не атрымана." @@ -151,7 +154,7 @@ "Загрузка…" "%1$d удзельнік" - "%1$d удзельніка" + "%1$d удзельнікі" "%1$d удзельнікаў" "Паведамленне" @@ -266,6 +269,7 @@ "Разблакіраваць" "Вы зноў зможаце ўбачыць усе паведамленні." "Разблакіраваць карыстальніка" + "Чат" "Падзяліцца месцазнаходжаннем" "Падзяліцца маім месцазнаходжаннем" "Адкрыць у Apple Maps" 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 66c33b70d9..2a344bff38 100644 --- a/libraries/ui-strings/src/main/res/values-cs/translations.xml +++ b/libraries/ui-strings/src/main/res/values-cs/translations.xml @@ -36,6 +36,7 @@ "Přijmout" "Přidat na časovou osu" "Zpět" + "Hovor" "Zrušit" "Vybrat fotku" "Vymazat" @@ -74,6 +75,7 @@ "Načíst více" "Spravovat účet" "Spravovat zařízení" + "Zpráva" "Další" "Ne" "Teď ne" @@ -119,6 +121,7 @@ "Blokovaní uživatelé" "Bubliny" "Probíhá hovor (nepodporováno)" + "Hovor zahájen" "Záloha chatu" "Autorská práva" "Vytváření místnosti…" @@ -266,6 +269,7 @@ "Odblokovat" "Znovu uvidíte všechny zprávy od nich." "Odblokovat uživatele" + "Chat" "Sdílet polohu" "Sdílet moji polohu" "Otevřít v Mapách Apple" 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 af217016a2..2779bead6f 100644 --- a/libraries/ui-strings/src/main/res/values-de/translations.xml +++ b/libraries/ui-strings/src/main/res/values-de/translations.xml @@ -34,6 +34,7 @@ "Akzeptieren" "Zum Nachrichtenverlauf hinzufügen" "Zurück" + "Anruf" "Abbrechen" "Foto auswählen" "Löschen" @@ -72,6 +73,7 @@ "Mehr laden …" "Konto verwalten" "Geräte verwalten" + "Nachricht" "Weiter" "Nein" "Später" @@ -262,6 +264,7 @@ "Blockierung aufheben" "Der Nutzer kann dir wieder Nachrichten senden & alle Nachrichten des Nutzers werden wieder angezeigt." "Blockierung aufheben" + "Chat" "Standort teilen" "Meinen Standort teilen" "In Apple Maps öffnen" 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 43e6919c34..4a09b3d52d 100644 --- a/libraries/ui-strings/src/main/res/values-es/translations.xml +++ b/libraries/ui-strings/src/main/res/values-es/translations.xml @@ -49,6 +49,7 @@ "Rechazar" "Eliminar encuesta" "Desactivar" + "Descartar" "Hecho" "Editar" "Editar encuesta" @@ -57,6 +58,7 @@ "Introducir PIN" "¿Olvidaste tu contraseña?" "Reenviar" + "Volver atrás" "Invitar" "Invitar personas" "Invita a alguien a %1$s" @@ -65,6 +67,7 @@ "Unirse" "Más información" "Salir" + "Salir de la conversación" "Salir de la sala" "Cargar más" "Gestionar cuenta" @@ -83,6 +86,7 @@ "Responder en el hilo" "Informar de un error" "Reportar Contenido" + "Restablecer" "Reintentar" "Reintentar descifrado" "Guardar" @@ -110,7 +114,9 @@ "Estadísticas" "Apariencia" "Sonido" + "Usuarios bloqueados" "Burbujas" + "Llamada en curso (no admitida)" "Copia de seguridad del chat" "Derechos de autor" "Creando sala…" @@ -126,6 +132,9 @@ "Introduce tu PIN" "Error" "Todos" + "Falló" + "Favorito" + "Marcado como favorito" "Archivo" "Archivo guardado en Descargas" "Reenviar mensaje" @@ -150,6 +159,7 @@ "Silenciar" "No hay resultados" "Sin conexión" + "o" "Contraseña" "Personas" "Enlace permanente" @@ -175,6 +185,8 @@ "Sala" "Nombre de la sala" "p. ej., el nombre de tu proyecto" + "Cambios guardados" + "Guardando" "Bloqueo de pantalla" "Buscar a alguien" "Buscar resultados" @@ -217,6 +229,8 @@ "Error" "Terminado" "Atención" + "Tus cambios no se han guardado. ¿Estás seguro de que quieres volver atrás?" + "¿Guardar cambios?" "No se pudo crear el enlace permanente" "%1$s no pudo cargar el mapa. Por favor vuelve a intentarlo más tarde." "Error al cargar mensajes" 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 484d4835ae..d4c1b3126c 100644 --- a/libraries/ui-strings/src/main/res/values-fr/translations.xml +++ b/libraries/ui-strings/src/main/res/values-fr/translations.xml @@ -34,8 +34,9 @@ "Accepter" "Ajouter à la discussion" "Retour" + "Appel" "Annuler" - "Choisissez une photo" + "Choisir une photo" "Effacer" "Fermer" "Terminer la vérification" @@ -72,6 +73,7 @@ "Voir plus" "Gérer le compte" "Gérez les sessions" + "Message" "Suivant" "Non" "Pas maintenant" @@ -117,6 +119,7 @@ "Utilisateurs bloqués" "Bulles" "Appel en cours (non supporté)" + "Appel démarré" "Sauvegarde des discussions" "Droits d’auteur" "Création du salon…" 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 5e669f61f4..216829fd3e 100644 --- a/libraries/ui-strings/src/main/res/values-hu/translations.xml +++ b/libraries/ui-strings/src/main/res/values-hu/translations.xml @@ -34,6 +34,7 @@ "Elfogadás" "Hozzáadás az idővonalhoz" "Vissza" + "Hívás" "Mégse" "Fénykép kiválasztása" "Törlés" @@ -72,6 +73,7 @@ "Továbbiak betöltése" "Fiók kezelése" "Eszközök kezelése" + "Üzenet" "Következő" "Nem" "Most nem" @@ -262,6 +264,7 @@ "Letiltás feloldása" "Újra láthatja az összes üzenetét." "Felhasználó kitiltásának feloldása" + "Csevegés" "Hely megosztása" "Saját hely megosztása" "Megnyitás az Apple Mapsben" 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 3483fadb43..fd216e1e0b 100644 --- a/libraries/ui-strings/src/main/res/values-it/translations.xml +++ b/libraries/ui-strings/src/main/res/values-it/translations.xml @@ -158,12 +158,14 @@ "Moderno" "Silenzia" "Nessun risultato" + "Nessun nome della stanza" "Non in linea" "o" "Password" "Persone" "Collegamento permanente" "Autorizzazione" + "Attendere prego…" "Vuoi davvero terminare questo sondaggio?" "Sondaggio: %1$s" "Voti totali: %1$s" @@ -200,6 +202,7 @@ "Impostazioni" "Posizione condivisa" "Disconnessione" + "Qualcosa è andato storto" "Avvio della conversazione…" "Adesivo" "Operazione riuscita" @@ -212,6 +215,7 @@ "Argomento" "Di cosa parla questa stanza?" "Impossibile decrittografare" + "Non hai accesso a questo messaggio" "Non è stato possibile spedire inviti a uno o più utenti." "Impossibile inviare inviti" "Sblocca" @@ -236,6 +240,7 @@ "Caricamento dei messaggi non riuscito" "%1$s non è riuscito ad accedere alla tua posizione. Riprova più tardi." "Invio del messaggio vocale fallito." + "Messaggio non trovato" "%1$s non ha l\'autorizzazione di accedere alla tua posizione. Puoi attivare l\'accesso nelle impostazioni." "%1$s non ha l\'autorizzazione per accedere alla tua posizione. Attiva l\'accesso di seguito." "%1$s non ha l\'autorizzazione di accedere al microfono. Attiva l\'accesso per registrare un messaggio vocale." @@ -253,6 +258,7 @@ "Blocca" "Gli utenti bloccati non saranno in grado di inviarti messaggi e tutti quelli già ricevuti saranno nascosti. Puoi sbloccarli in qualsiasi momento." "Blocca utente" + "Profilo" "Sblocca" "Potrai vedere di nuovo tutti i suoi messaggi." "Sblocca utente" diff --git a/libraries/ui-strings/src/main/res/values-ka/translations.xml b/libraries/ui-strings/src/main/res/values-ka/translations.xml new file mode 100644 index 0000000000..fd0bc2c832 --- /dev/null +++ b/libraries/ui-strings/src/main/res/values-ka/translations.xml @@ -0,0 +1,221 @@ + + + "წაშლა" + + "%1$d ციფრი ჩაიწერა" + "%1$d ციფრი ჩაიწერა" + + "პაროლის დამალვა" + "მხოლოდ მოხსენიებები" + "დადუმებულია" + "პაუზა" + "PIN ველი" + "დაკვრა" + "გამოკითხვა" + "დასრულდა გამოკითხვა" + "ფაილების გაგზავნა" + "პაროლის ჩვენება" + "მომხმარებლის მენიუ" + "ხმოვანი შეტყობინების ჩაწერა." + "მიღება" + "დამატება ქრონოლოგიაში" + "უკან" + "გაუქმება" + "აირჩიეთ ფოტო" + "გასუფთავება" + "დახურვა" + "დადასტურების დასრულება" + "დადასტურება" + "გაგრძელება" + "კოპირება" + "ბმულის კოპირება" + "დააკოპირეთ შეტყობინების ბმული" + "შექმნა" + "ოთახის შექმნა" + "უარყოფა" + "გამორთვა" + "მზადაა" + "რედაქტირება" + "გამოკითხვის რედაქტირება" + "გამოკითხვის დასრულება" + "დაგავიწყდათ პაროლი?" + "გადაგზავნა" + "მოწვევა" + "ხალხის მოწვევა" + "ადამიანების დამატება %1$s" + "%1$s-ში ხალხის მოწვევა" + "მოწვევები" + "გაწევრიანება" + "შეიტყვეთ მეტი" + "დატოვება" + "ოთახის დატოვება" + "ანგარიშის მართვა" + "მოწყობილობების მართვა" + "შემდეგი" + "არა" + "ახლა არა" + "OK" + "პარამეტრები" + "გახსნა პროგრამით:" + "Სწრაფი პასუხი" + "ციტირება" + "რეაგირება" + "წაშლა" + "პასუხი" + "პასუხი თემაში" + "ხარვეზის შეტყობინება" + "კონტენტის რეპორტი" + "ხელახლა ცდა" + "გაშიფვრის ხელახლა ცდა" + "შენახვა" + "ძიება" + "გაგზავნა" + "შეტყობინების გაგზავნა" + "გაზიარება" + "ბმულის გაზიარება" + "ხელახლა შედით" + "გამოსვლა" + "მაინც გასვლა" + "გამოტოვება" + "დაწყება" + "ჩატის დაწყება" + "დადასტურების დაწყება" + "დააწკაპუნეთ რუკის ჩასატვირთად" + "ფოტოს გადაღება" + "წყაროს ნახვა" + "დიახ" + "შესახებ" + "მისაღები გამოყენების პოლიტიკა" + "გაფართოებული პარამეტრები" + "ანალიტიკა" + "აუდიო" + "ბუშტები" + "ჩატის სარეზერვო ასლი" + "საავტორო უფლება" + "ოთახის შექმნა…" + "დატოვა ოთახი" + "გაშიფვრის შეცდომა" + "დეველოპერის პარამეტრები" + "(რედაქტირებულია)" + "რედაქტირება" + "* %1$s %2$s" + "დაშიფვრა ჩართულია" + "შეიყვანეთ თქვენი PIN" + "შეცდომა" + "ყველა" + "ფაილი" + "ფაილი შენახულია ჩამოტვირთვებში" + "შეტყობინების გადაგზავნა" + "GIF" + "სურათი" + "%1$s-ს პასუხად" + "დააინსტალირეთ APK" + "ეს Matrix ID ვერ მოიძებნა, ამიტომ მოწვევა შეიძლება არ იყოს მიღებული." + "ოთახის დატოვება" + "ბმული კოპირებულია გაცვლის ბუფერში" + "იტვირთება…" + + "%1$d წევრი" + "%1$d წევრები" + + "შეტყობინება" + "შეტყობინებების ფორმა" + "მესიჯი წაშლილია" + "თანამედროვე" + "დადუმება" + "შედეგი არ არის" + "ხაზგარეშე" + "პაროლი" + "ხალხი" + "მუდმივი ბმული" + "ნებართვა" + "დარწმუნებული ხართ, რომ გსურთ ამ გამოკითხვის დასრულება?" + "გამოკითხვა: %1$s" + "სულ ხმები: %1$s" + "შედეგები გამოკითხვის დასრულების შემდეგ გამოჩნდება" + + "%d ხმა" + "%d ხმა" + + "კონფიდენციალურობის პოლიტიკა" + "რეაქცია" + "რეაქციები" + + "აღდგენის გასაღები" + + "განახლება…" + "პასუხი %1$s-ს" + "ხარვეზის შეტყობინება" + "რეპორტი გაგზავნილია" + "მდიდარი ტექსტის რედაქტორი" + "ოთახის სახელი" + "მაგ. თქვენი პროექტის სახელი" + "ეკრანის დაბლოკვა" + "ვიღაცის ძებნა" + "ძიების შედეგები" + "უსაფრთხოება" + "იგზავნება…" + "სერვერი არ არის მხარდაჭერილი" + "სერვერის ვებ-მისამართი" + "პარამეტრები" + "გაზიარებული მდებარეობა" + "ჩატის დაწყება…" + "სტიკერი" + "წარმატება" + "შეთავაზებები" + "სინქრონიზაცია" + "ტექსტი" + "მესამე პირის შენიშვნები" + "თემა" + "თემა" + "რა თემებს ეხება ეს ოთახი?" + "გაშიფვრა ვერ მოხერხდა" + "მოსაწვევები ვერ გაეგზავნა ერთ ან მეტ მომხმარებელს." + "მოწვევის (ების) გაგზავნა შეუძლებელია" + "განბლოკვა" + "დადუმების გაუქმება" + "მხარდაუჭერელი მოვლენა" + "მომხმარებლის სახელი" + "დადასტურება გაუქმდა" + "დადასტურება დასრულებულია" + "ვიდეო" + "ხმოვანი შეტყობინება" + "მოცდა…" + "დადასტურება" + "შეცდომა" + "წარმატება" + "გაფრთხილება" + "მუდმივი ბმულის შექმნა ვერ მოხერხდა" + "ვერ გამოვიდა რუკის %1$s ჩატვირთვა. გთხოვთ, მოგვიანებით სცადოთ." + "შეტყობინებების ჩატვირთვა ვერ მოხერხდა" + "%1$s ვერ მოახერხა თქვენი ადგილმდებარეობაზე წვდომა. გთხოვთ, მოგვიანებით სცადოთ." + "%1$s არ აქვს თქვენს ადგილმდებარეობაზე წვდომის ნებართვა. შეგიძლიათ ჩართოთ წვდომა პარამეტრებში." + "%1$s არ აქვს თქვენს ადგილმდებარეობაზე წვდომის ნებართვა. ჩართეთ წვდომა ქვემოთ" + "%1$s არ აქვს თქვენს მიკროფონზე წვდომის ნებართვა. ჩართეთ წვდომა ხმოვანი შეტყობინების ჩასაწერად." + "ზოგიერთი შეტყობინება არ გაიგზავნა" + "ბოდიშით, შეცდომა მოხდა" + "🔐️ შემომიერთდით %1$s" + "გაგიმარჯოს! მესაუბრე %1$s-ზე: %2$s" + "%1$s Android" + "შეცდომის შესატყობინებლად ტელეფონის შენჯღრევა" + "მედიის შერჩევა ვერ მოხერხდა, გთხოვთ, სცადოთ ხელახლა." + "მედიის ატვირთვა ვერ მოხერხდა. გთხოვთ, სცადოთ ხელახლა." + "მედიის ატვირთვა ვერ მოხერხდა, გთხოვთ, სცადოთ ხელახლა." + "მედიის ატვირთვა ვერ მოხერხდა. გთხოვთ, სცადოთ ხელახლა." + "მომხმარებლის მონაცემების მოძიება ვერ მოხერხდა" + "დაბლოკვა" + "დაბლოკილი მომხმარებლები ვერ შეძლებენ თქვენთვის შეტყობინების გაგზავნას და ყველა მათი შეტყობინება თქვენთვის დამალული იქნება. თქვენ მათი განბლოკვა ნებისმეირ დროს შეგიძლიათ." + "მომხმარებლის დაბლოკვა" + "განბლოკვა" + "თქვენ კვლავ შეძლებთ მათგან ყველა შეტყობინების ნახვას." + "Მომხმარებლის განბლოკვა" + "მდებარეობის გაზიარება" + "ჩემი მდებარეობის გაზიარება" + "Apple Maps-ში გახსნა" + "Google Maps-ში გახსნა" + "OpenStreetMap-ში გახსნა" + "ამ ადგილის გაზიარება" + "ადგილმდებარეობა" + "ვერსია: %1$s (%2$s)" + "ka" + 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 new file mode 100644 index 0000000000..f1ee60abe5 --- /dev/null +++ b/libraries/ui-strings/src/main/res/values-pt-rBR/translations.xml @@ -0,0 +1,278 @@ + + + "Eliminar" + + "%1$d dígito inserido" + "%1$d dígitos inseridos" + + "Ocultar senha" + "Saltar para o fundo" + "Apenas menções" + "Silenciado" + "Página %1$d" + "Pausar" + "Campo para PIN" + "Reproduzir" + "Sondagem" + "Sondagem concluída" + "Reagir com %1$s" + "Reagir com outros emojis" + "Lida por %1$s e %2$s" + + "Lida por %1$s e %2$d outro" + "Lida por %1$s e %2$d outros" + + "Lida por %1$s" + "Toque para mostrar tudo" + "Remover reação com %1$s" + "Enviar ficheiros" + "Mostrar senha" + "Iniciar chamada" + "Menu de utilizador" + "Gravar mensagem de voz." + "Parar gravação" + "Aceitar" + "Adicionar à cronologia" + "Voltar" + "Chamar" + "Cancelar" + "Escolher foto" + "Limpar" + "Fechar" + "Concluir verificação" + "Confirmar" + "Continuar" + "Copiar" + "Copiar ligação" + "Copiar ligação da mensagem" + "Criar" + "Criar uma sala" + "Rejeitar" + "Eliminar sondagem" + "Desativar" + "Descartar" + "Feito" + "Editar" + "Editar sondagem" + "Ativar" + "Fim da sondagem" + "Inserir PIN" + "Esqueceu-se da senha?" + "Reencaminhar" + "Voltar" + "Convidar" + "Convidar pessoas" + "Convidar amigos para %1$s" + "Convida pessoas para a %1$s" + "Convites" + "Entrar" + "Saber mais" + "Sair" + "Sair da conversa" + "Sair da sala" + "Carrega mais" + "Gerir conta" + "Gerir dispositivos" + "Enviar mensagem" + "Próximo" + "Não" + "Agora não" + "OK" + "Configurações" + "Abrir com" + "Resposta rápida" + "Citação" + "Reagir" + "Remover" + "Responder" + "Responder ao tópico" + "Comunicar problema" + "Denunciar conteúdo" + "Repor" + "Tentar novamente" + "Tentar decifragem novamente" + "Guardar" + "Pesquisar" + "Enviar" + "Enviar mensagem" + "Partilhar" + "Partilhar ligação" + "Inicia sessão novamente" + "Terminar sessão" + "Terminar mesmo assim" + "Saltar" + "Iniciar" + "Iniciar conversa" + "Iniciar verificação" + "Toque para carregar o mapa" + "Tirar foto" + "Toca para ver as opções" + "Tentar novamente" + "Ver fonte" + "Sim" + "Sobre" + "Política de utilização aceitável" + "Configurações avançadas" + "Recolha e análise de dados" + "Aparência" + "Áudio" + "Utilizadores bloqueados" + "Bolhas" + "Chamada em curso (não suportada)" + "Chamada iniciada" + "Cópia de segurança das conversas" + "Direitos de autor" + "A criar sala…" + "Saíste da sala" + "Escuro" + "Erro de decifragem" + "Opções de programador" + "Conversa direta" + "(editada)" + "A editar" + "* %1$s %2$s" + "Cifragem ativada" + "Introduz o teu PIN" + "Erro" + "Toda a gente" + "Falha" + "Marcar como favorita" + "Favoritas" + "Ficheiro" + "Ficheiro guardado nas Transferências" + "Reencaminhar mensagem" + "GIF" + "Imagem" + "Em resposta a %1$s" + "Instalar APK" + "Não foi possível encontrar este ID Matrix, portanto o convite pode não ser recebido." + "A sair da sala" + "Claro" + "Ligação copiada para a área de transferência" + "A carregar…" + + "%1$d membro" + "%1$d membros" + + "Mensagem" + "Ações de mensagem" + "Disposição das mensagens" + "Mensagem removida" + "Moderno" + "Silenciar" + "Sem resultados" + "Sala sem nome" + "Desligado" + "ou" + "Senha" + "Pessoas" + "Ligação permanente" + "Permissão" + "Por favor, aguarda…" + "Tens a certeza que queres concluir esta sondagem?" + "Sondagem: %1$s" + "Total de votos: %1$s" + "Os resultados serão apresentados após o fim da sondagem" + + "%d voto" + "%d votos" + + "Política de privacidade" + "Reação" + "Reações" + "Chave de recuperação" + "A atualizar…" + "Em resposta a %1$s" + "Comunicar falha" + "Comunicar um problema" + "Denúncia submetida" + "Editor de texto rico" + "Sala" + "Nome da sala" + "p.ex. o nome do teu projeto" + "Alterações guardadas" + "A guardar" + "Bloqueio do ecrã" + "Pesquisar por alguém" + "Resultados da pesquisa" + "Segurança" + "Vista por" + "A enviar…" + "Falha no envio" + "Enviada" + "Servidor não suportado" + "URL do servidor" + "Configurações" + "Localização partilhada" + "A terminar sessão" + "Algo correu mal" + "A iniciar conversa…" + "Autocolante" + "Sucesso" + "Sugestões" + "A sincronizar…" + "Sistema" + "Texto" + "Avisos de terceiros" + "Tópico" + "Descrição" + "Sobre o que é esta sala?" + "Incapaz de decifrar" + "Não tens acesso a esta mensagem" + "Não foi possível enviar convites a um ou mais utilizadores." + "Não foi possível enviar convite(s)" + "Desbloquear" + "Dessilenciar" + "Evento não suportado" + "Nome de utilizador" + "Verificação cancelada" + "Verificação concluída" + "Verificar o dispositivo" + "Vídeo" + "Mensagem de voz" + "A aguardar…" + "À espera desta mensagem" + "Confirmação" + "Erro" + "Sucesso" + "Aviso" + "As tuas alterações não foram guardadas. Tens a certeza que queres voltar atrás?" + "Guardar alterações?" + "Falha ao criar ligação permanente" + "%1$s não foi possível carregar o mapa. Por favor, tente novamente mais tarde." + "Falha ao carregar mensagens" + "A %1$s não conseguiu aceder à tua localização. Por favor, tenta novamente mais tarde." + "Falha ao carregar mensagem de voz." + "Mensagem não encontrada" + "A %1$s não tem permissão para aceder à tua localização. Podes ativar o acesso nas Definições." + "A %1$s não tem permissão para aceder à tua localização. Ativa o acesso abaixo." + "A %1$s não tem permissão para aceder ao teu microfone. Permite o acesso para gravar uma mensagem de voz." + "Algumas mensagens não foram enviadas" + "Ocorreu um erro, desculpa" + "🔐️ Junta-te a mim na %1$s" + "Alô! Fala comigo na %1$s: %2$s" + "%1$s Android" + "Agita o dispositivo em fúria para comunicar um problema" + "Falha ao selecionar multimédia, por favor tente novamente." + "Falha ao processar multimédia para carregamento, por favor tente novamente." + "Falhar ao carregar multimédia, por favor tente novamente." + "Falha ao processar multimédia para carregamento, por favor tente novamente." + "Não foi possível obter os detalhes de utilizador." + "Bloquear" + "Os utilizadores bloqueados não poderão enviar-te mensagens e todas as suas mensagens ficarão ocultas. Podes desbloqueá-los em qualquer altura." + "Bloquear utilizador" + "Perfil" + "Desbloquear" + "Poderás voltar a ver todas as suas mensagens." + "Desbloquear utilizador" + "Conversa" + "Partilhar localização" + "Partilhar a minha localização" + "Abrir no Apple Maps" + "Abrir no Google Maps" + "Abrir no OpenStreetMap" + "Partilhar este local" + "Localização" + "Versão: %1$s (%2$s)" + "pt" + 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 073dd20f4b..a4c0eb8bfe 100644 --- a/libraries/ui-strings/src/main/res/values-ro/translations.xml +++ b/libraries/ui-strings/src/main/res/values-ro/translations.xml @@ -36,6 +36,7 @@ "Acceptați" "Adăugați conversației" "Înapoi" + "Apel" "Anulați" "Alegeți o fotografie" "Ștergeți" @@ -51,6 +52,7 @@ "Refuzați" "Ștergeți sondajul" "Dezactivați" + "Renunţare" "Efectuat" "Editați" "Editați sondajul" @@ -59,6 +61,7 @@ "Introduceți PIN-ul" "Ați uitat parola?" "Redirecționați" + "Înapoi" "Invitați" "Invitați prieteni" "Invitați prieteni în %1$s" @@ -72,6 +75,7 @@ "Încărcați mai mult" "Administrare cont" "Gestionare dispozitive" + "Mesaj" "Următorul" "Nu" "Nu acum" @@ -86,6 +90,7 @@ "Răspundeți în fir" "Raportați o eroare" "Raportați conținutul" + "Resetare" "Reîncercați" "Reîncercați decriptarea" "Salvați" @@ -113,7 +118,9 @@ "Analitice" "Aspect" "Audio" + "Utilizatori blocați" "Baloane" + "Apel în curs (nesuportat)" "Backup conversații" "Drepturi de autor" "Se creează camera…" @@ -129,6 +136,7 @@ "Introduceți codul PIN" "Eroare" "Toți" + "Eșuat" "Favorite" "Favorită" "Fişier" @@ -155,11 +163,14 @@ "Modern" "Dezactivați sunetul" "Niciun rezultat" + "Fără nume de cameră" "Deconectat" + "sau" "Parola" "Persoane" "Permalink" "Permisiune" + "Va rugam asteptati…" "Sunteți sigur că doriți să încheiați acest sondaj?" "Sondajul %1$s" "Total voturi: %1$s" @@ -182,6 +193,8 @@ "Cameră" "Numele camerei" "de exemplu, numele proiectului dvs." + "Modificări salvate" + "Se salvează…" "Blocare ecran" "Căutați pe cineva" "Rezultatele căutării" @@ -195,6 +208,7 @@ "Setări" "Locație partajată" "Deconectare în curs" + "Ceva nu a mers bine" "Se începe conversația…" "Autocolant" "Succes" @@ -207,6 +221,7 @@ "Subiect" "Despre ce este vorba în această cameră?" "Nu s-a putut decripta" + "Nu aveți acces la acest mesaj" "Nu am putut trimite invitații unuia sau mai multor utilizatori." "Nu s-a putut trimite invitația (invitațiile)" "Deblocare" @@ -224,11 +239,14 @@ "Eroare" "Succes" "Avertisment" + "Modificările dumneavoastră nu au fost salvate. Sunteți sigur că doriți să vă întoarceți?" + "Salvați modificările?" "Crearea permalink-ului a eșuat" "%1$s nu a putut încărca harta. Vă rugăm să încercați din nou mai târziu." "Încărcarea mesajelor a eșuat" "%1$s nu a putut accesa locația dumneavoastră. Vă rugăm să încercați din nou mai târziu." "Trimiterea mesajului vocal nu a reușit." + "Mesajul nu a fost găsit" "%1$s nu are permisiuni pentru a accesa locația dumneavoastră. Puteți permite accesul în Setări." "%1$s nu are permisiuni pentru a accesa locația dumneavoastră. Permiteți accesul mai jos." "%1$s nu are permisiunea de a vă accesa microfonul. Permiteți accesul pentru a înregistra un mesaj vocal." @@ -246,9 +264,11 @@ "Blocați" "Utilizatorii blocați nu vă vor putea trimite mesaje și toate mesajele lor vor fi ascunse. Puteți anula această acțiune oricând." "Blocați utilizatorul" + "Profil" "Deblocați" "La deblocarea utilizatorului, veți putea vedea din nou toate mesajele de la acesta." "Deblocați utilizatorul" + "Chat" "Partajați locația" "Distribuiți locația mea" "Deschideți în Apple Maps" 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 0634590b92..5544635732 100644 --- a/libraries/ui-strings/src/main/res/values-ru/translations.xml +++ b/libraries/ui-strings/src/main/res/values-ru/translations.xml @@ -36,6 +36,7 @@ "Разрешить" "Добавить в хронологию" "Назад" + "Позвонить" "Отмена" "Выбрать фото" "Очистить" @@ -74,6 +75,7 @@ "Загрузить еще" "Настройки аккаунта" "Управление устройствами" + "Сообщение" "Далее" "Нет" "Не сейчас" @@ -119,6 +121,7 @@ "Заблокированные пользователи" "Пузыри" "Выполняется звонок (не поддерживается)" + "Звонок начат" "Резервная копия чатов" "Авторское право" "Создание комнаты…" @@ -161,12 +164,14 @@ "Современный" "Без звука" "Ничего не найдено" + "Нету названия комнаты" "Не в сети" "или" "Пароль" "Люди" "Постоянная ссылка" "Разрешение" + "Подождите…" "Вы действительно хотите завершить данный опрос?" "Опрос: %1$s" "Всего голосов: %1$s" @@ -219,6 +224,7 @@ "Тема" "О чем эта комната?" "Невозможно расшифровать" + "Вы не имеете доступа к этому сообщению" "Не удалось отправить приглашения одному или нескольким пользователям." "Не удалось отправить приглашение(я)" "Разблокировать" @@ -243,6 +249,7 @@ "Не удалось загрузить сообщения" "%1$s не удалось получить доступ к вашему местоположению. Пожалуйста, повторите попытку позже." "Не удалось загрузить голосовое сообщение." + "Сообщение не найдено" "У %1$s нет разрешения на доступ к вашему местоположению. Вы можете разрешить доступ в Настройках." "У %1$s нет разрешения на доступ к вашему местоположению. Разрешите доступ ниже." "%1$s не имеет разрешения на доступ к вашему микрофону. Разрешите доступ к записи голосового сообщения." @@ -264,6 +271,7 @@ "Разблокировать" "Вы снова сможете увидеть все сообщения." "Разблокировать пользователя" + "Чат" "Поделиться местоположением" "Поделиться моим местоположением" "Открыть в Apple Maps" 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 dbe16b1868..d9afe4ba65 100644 --- a/libraries/ui-strings/src/main/res/values-sk/translations.xml +++ b/libraries/ui-strings/src/main/res/values-sk/translations.xml @@ -36,6 +36,7 @@ "Prijať" "Pridať na časovú os" "Späť" + "Zavolať" "Zrušiť" "Vybrať fotku" "Vyčistiť" @@ -74,6 +75,7 @@ "Načítať viac" "Spravovať účet" "Spravovať zariadenia" + "Poslať správu" "Ďalej" "Nie" "Teraz nie" @@ -119,6 +121,7 @@ "Blokovaní používatelia" "Bubliny" "Prebieha hovor (nepodporované)" + "Hovor sa začal" "Záloha konverzácie" "Autorské práva" "Vytváranie miestnosti…" @@ -136,6 +139,7 @@ "Všetci" "Zlyhalo" "Obľúbené" + "Obľúbené" "Súbor" "Súbor bol uložený do priečinka Stiahnuté súbory" "Preposlať správu" 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 c311a0fc2e..d4003fce5e 100644 --- a/libraries/ui-strings/src/main/res/values-sv/translations.xml +++ b/libraries/ui-strings/src/main/res/values-sv/translations.xml @@ -111,6 +111,7 @@ "Analysdata" "Utseende" "Ljud" + "Blockerade användare" "Bubblor" "Chattsäkerhetskopia" "Upphovsrätt" @@ -127,7 +128,9 @@ "Ange din PIN-kod" "Fel" "Alla" + "Misslyckades" "Favorit" + "Favoriter" "Fil" "Fil sparad i Download" "Vidarebefordra meddelande" @@ -152,6 +155,7 @@ "Tysta" "Inga resultat" "Frånkopplad" + "eller" "Lösenord" "Personer" "Permalänk" diff --git a/libraries/ui-strings/src/main/res/values-zh/translations.xml b/libraries/ui-strings/src/main/res/values-zh/translations.xml new file mode 100644 index 0000000000..ded718fcec --- /dev/null +++ b/libraries/ui-strings/src/main/res/values-zh/translations.xml @@ -0,0 +1,271 @@ + + + "删除" + + "已输入 %1$d 个数字" + + "隐藏密码" + "跳转到底部" + "仅提及" + "关闭通知" + "第 %1$d 页" + "暂停" + "PIN 栏位" + "播放" + "投票" + "投票已结束" + "使用 %1$s 回应" + "使用其他表情符号回应" + "%1$s 和 %2$s 已读" + + "%1$s 及其他 %2$d 人已读" + + "%1$s 已读" + "点击以显示全部" + "撤回反应 %1$s" + "发送文件" + "显示密码" + "开始通话" + "用户菜单" + "录制语音消息。" + "停止录制" + "接受" + "添加到时间线" + "返回" + "取消" + "选择照片" + "清除" + "关闭" + "完成验证" + "确认" + "继续" + "复制" + "复制链接" + "复制消息链接" + "创建" + "创建房间" + "拒绝" + "删除投票" + "停用" + "丢弃" + "完成" + "编辑" + "编辑投票" + "启用" + "结束投票" + "输入 PIN" + "忘记密码?" + "转发" + "返回" + "邀请" + "邀请朋友" + "邀请朋友加入 %1$s" + "邀请人们加入 %1$s" + "邀请" + "加入" + "了解更多" + "离开" + "离开聊天" + "离开房间" + "载入更多" + "管理账户" + "管理设备" + "下一步" + "否" + "以后再说" + "好" + "打开设置" + "用其他方式打开" + "快速回复" + "引用" + "回应" + "移除" + "回复" + "在消息列中回复" + "报告错误" + "举报内容" + "重置" + "重试" + "重试解密" + "保存" + "搜索" + "发送" + "发送消息" + "分享" + "分享链接" + "再次登录" + "登出" + "仍然登出" + "跳过" + "开始" + "开始聊天" + "开始验证" + "点击以加载地图" + "拍摄照片" + "点按查看选项" + "再试一次" + "查看来源" + "是" + "关于" + "可接受的使用政策" + "高级设置" + "分析" + "外观" + "音频" + "已屏蔽用户" + "气泡" + "通话进行中(不支持)" + "聊天记录备份" + "版权" + "正在创建房间…" + "离开房间" + "暗色" + "解密错误" + "开发者选项" + "私聊" + "(已编辑)" + "编辑中" + "* %1$s %2$s" + "已启用加密" + "输入 PIN 码" + "错误" + "所有人" + "失败" + "收藏" + "已收藏" + "文件" + "文件已保存到“下载”" + "转发消息" + "GIF" + "图片" + "回复 %1$s" + "安装 APK" + "找不到此 Matrix ID,因此可能无法收到邀请。" + "正在离开房间" + "浅色" + "链接已复制到剪贴板" + "正在加载…" + + "%1$d个成员" + + "消息" + "消息操作" + "消息布局" + "消息已移除" + "现代" + "静音" + "没有结果" + "无房间名" + "离线" + "或" + "密码" + "人" + "固定链接" + "权限" + "请稍候……" + "你确定要结束这个投票吗?" + "投票:%1$s" + "总票数: %1$s" + "结果将在投票结束后显示" + + "%d 票" + + "隐私政策" + "回应" + "回应" + "恢复密钥" + "正在刷新…" + "正在回复 %1$s" + "报告错误" + "报告问题" + "报告已提交" + "富文本编辑器" + "房间" + "房间名称" + "例如:你的项目名称" + "保存的更改" + "正在保存" + "屏幕锁定" + "搜索某人" + "搜索结果" + "安全" + "已读" + "正在发送…" + "发送失败" + "已发送" + "服务器不支持" + "服务器 URL" + "设置" + "共享位置" + "正在登出" + "发生了一些错误" + "开始聊天…" + "贴纸" + "成功" + "建议" + "正在同步" + "系统" + "文本" + "第三方通知" + "消息列" + "话题" + "这个房间是关于什么的?" + "无法解密" + "你无权访问此消息" + "无法向一个或多个用户发送邀请。" + "无法发送邀请" + "解锁" + "解除静音" + "不支持的事件" + "用户名" + "验证已取消" + "验证完成" + "验证设备" + "视频" + "语音留言" + "等待…" + "正在等待解密密钥" + "确认" + "错误" + "成功" + "警告" + "您的更改尚未保存。确定要返回吗?" + "保存更改?" + "创建固定链接失败" + "%1$s 无法加载地图,请稍后再试。" + "加载消息失败" + "%1$s 无法访问您的位置,请稍后再试。" + "无法上传你的语音留言。" + "找不到消息" + "%1$s 没有权限访问您的位置。您可以在设置中启用位置权限。" + "%1$s 没有权限访问您的位置。在下方启用位置权限。" + "%1$s 没有权限访问您的麦克风。启用录制语音消息的权限。" + "某些信息尚未发送" + "抱歉,发生了错误" + "🔐️ 加入我 %1$s" + "嗨!请通过 %1$s 与我联系:%2$s" + "%1$s Android" + "摇一摇以报错" + "选择媒体失败,请重试。" + "处理要上传的媒体失败,请重试。" + "上传媒体失败,请重试。" + "处理要上传的媒体失败,请重试。" + "无法获取用户信息" + "封禁" + "被封禁的用户无法给你发消息,并且他们的消息会被隐藏。你可以随时解封。" + "封禁用户" + "个人资料" + "解封" + "你可以重新接收他们的消息。" + "解封用户" + "聊天" + "分享位置" + "分享我的位置" + "在 Apple Maps 中打开" + "在 Google Maps 中打开" + "在 OpenStreetMap 中打开" + "分享这个位置" + "位置" + "版本:%1$s (%2$s)" + "zh-Hans" + diff --git a/libraries/ui-strings/src/main/res/values/localazy.xml b/libraries/ui-strings/src/main/res/values/localazy.xml index e667873318..8d46b6e5b1 100644 --- a/libraries/ui-strings/src/main/res/values/localazy.xml +++ b/libraries/ui-strings/src/main/res/values/localazy.xml @@ -119,6 +119,7 @@ "Blocked users" "Bubbles" "Call in progress (unsupported)" + "Call started" "Chat backup" "Copyright" "Creating room…" diff --git a/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/impl/VoiceRecorderImplTest.kt b/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/impl/VoiceRecorderImplTest.kt index 9bb1ace7b1..022661bc11 100644 --- a/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/impl/VoiceRecorderImplTest.kt +++ b/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/impl/VoiceRecorderImplTest.kt @@ -28,7 +28,7 @@ import io.element.android.libraries.voicerecorder.impl.audio.AudioConfig import io.element.android.libraries.voicerecorder.impl.audio.SampleRate import io.element.android.libraries.voicerecorder.impl.di.VoiceRecorderModule import io.element.android.libraries.voicerecorder.test.FakeAudioLevelCalculator -import io.element.android.libraries.voicerecorder.test.FakeAudioRecorderFactory +import io.element.android.libraries.voicerecorder.test.FakeAudioReaderFactory import io.element.android.libraries.voicerecorder.test.FakeEncoder import io.element.android.libraries.voicerecorder.test.FakeFileSystem import io.element.android.libraries.voicerecorder.test.FakeVoiceFileManager @@ -136,7 +136,7 @@ class VoiceRecorderImplTest { return VoiceRecorderImpl( dispatchers = testCoroutineDispatchers(), timeSource = timeSource, - audioReaderFactory = FakeAudioRecorderFactory( + audioReaderFactory = FakeAudioReaderFactory( audio = AUDIO, ), encoder = FakeEncoder(fakeFileSystem), diff --git a/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/test/FakeAudioRecorderFactory.kt b/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/test/FakeAudioReaderFactory.kt similarity index 97% rename from libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/test/FakeAudioRecorderFactory.kt rename to libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/test/FakeAudioReaderFactory.kt index 02d8b4742c..657b8d6ee9 100644 --- a/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/test/FakeAudioRecorderFactory.kt +++ b/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/test/FakeAudioReaderFactory.kt @@ -21,7 +21,7 @@ import io.element.android.libraries.voicerecorder.impl.audio.Audio import io.element.android.libraries.voicerecorder.impl.audio.AudioConfig import io.element.android.libraries.voicerecorder.impl.audio.AudioReader -class FakeAudioRecorderFactory( +class FakeAudioReaderFactory( private val audio: List