diff --git a/.gitattributes b/.gitattributes index 0542767eff..b44f3fab1b 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1 +1,2 @@ **/snapshots/**/*.png filter=lfs diff=lfs merge=lfs -text +**/src/androidTest/assets/*.realm filter=lfs diff=lfs merge=lfs -text diff --git a/.github/ISSUE_TEMPLATE/release.yml b/.github/ISSUE_TEMPLATE/release.yml index b41188a920..4ab77af5a0 100644 --- a/.github/ISSUE_TEMPLATE/release.yml +++ b/.github/ISSUE_TEMPLATE/release.yml @@ -10,7 +10,6 @@ body: id: checklist attributes: label: Release checklist - description: For the template example, we are releasing the version 1.2.3. Replace 1.2.3 with the version in the issue body. placeholder: | If you are reading this, you have deleted the content of the release template: undo the deletion or start again. value: | @@ -24,28 +23,7 @@ body: ### Do the release - - [ ] Make sure `develop` and `main` are up to date (git pull) - - [ ] Checkout develop and create a release with gitflow, branch name `release/1.2.3` - - [ ] Check the crashes from the PlayStore - - [ ] Check the rageshake with the current dev version: https://github.com/matrix-org/element-android-rageshakes/labels/1.2.3-dev - - [ ] Run the integration test, and especially `UiAllScreensSanityTest.allScreensTest()` - - [ ] Create an account on matrix.org and do some smoke tests that the sanity test does not cover like: 1-1 call, 1-1 video call, Jitsi call for instance - - [ ] Run towncrier: `towncrier build --version v1.2.3 --draft` (remove `--draft` do write the file CHANGES.md) - - [ ] Check the file CHANGES.md consistency. It's possible to reorder items (most important changes first) or change their section if relevant. Also an opportunity to fix some typo, or rewrite things - - [ ] Add file for fastlane under ./fastlane/metadata/android/en-US/changelogs - - [ ] (optional) Push the branch and start a draft PR (will not be merged), to check that the CI is happy with all the changes. - - [ ] Finish release with gitflow, delete the draft PR (if created) - - [ ] Push `main` and the new tag `v1.2.3` to origin - - [ ] Checkout `develop` - - [ ] Increase version (versionPatch + 2) in `./vector/build.gradle` - - [ ] Change the value of SDK_VERSION in the file `./matrix-sdk-android/build.gradle` - - [ ] Commit and push `develop` - - [ ] Wait for [Buildkite](https://buildkite.com/matrix-dot-org/element-android/builds?branch=main) to build the `main` branch. - - [ ] Run the script `~/scripts/releaseElement.sh`. It will download the APKs from Buildkite check them and sign them. - - [ ] Install the APK on your phone to check that the upgrade went well (no init sync, etc.) - - [ ] Create the release on gitHub [from the tag](https://github.com/vector-im/element-android/tags), copy paste the block from the file CHANGES.md - - [ ] Add the 4 signed APKs to the GitHub release - - [ ] Ping the Android Internal room + - [ ] Run the script ./tools/release/releaseScript.sh and follow the steps. ### Once tested and validated internally @@ -82,29 +60,9 @@ body: The SDK2 and the sample app are released only when Element has been pushed to production. - - [ ] Checkout the `main` branch on Element Android project + - [ ] On the [SDK2 project](https://github.com/matrix-org/matrix-android-sdk2), run the script ./tools/releaseScript.sh and follow the instructions. - #### On the SDK2 project - - https://github.com/matrix-org/matrix-android-sdk2 - - - [ ] Create a release with GitFlow - - [ ] Update the value of VERSION_NAME in the file gradle.properties - - [ ] Update the files `./build.gradle` and `./gradle/gradle-wrapper.properties` manually, to use the latest version for the dependency. You can get inspired by the same files on Element Android project. - - [ ] Run the script `./tools/import_from_element.sh` - - [ ] Check the diff in the file `./matrix-sdk-android/build.gradle` and restore what may have been erased (in particular the line `apply plugin: "com.vanniktech.maven.publish"` and the line about the version) - - [ ] Let the script finish to build the library - - [ ] Update the file `CHANGES.md` - - [ ] Finish the release using GitFlow - - [ ] Push the branch `main`, the new tag and the branch `develop` to origin - - ##### Release on MavenCentral - - - [ ] Checkout the branch `main` - - [ ] Run the command `./gradlew publish --no-daemon --no-parallel`. You'll need some non-public element to do so - - [ ] Run the command `./gradlew closeAndReleaseRepository`. If it is working well, you can jump directly to the final step of this section. - - If `./gradlew closeAndReleaseRepository` fails (for instance, several repositories are waiting to be handled), you have to close and release the repository manually. Do the following steps: + Note: if the step `./gradlew closeAndReleaseRepository` fails (for instance, several repositories are waiting to be handled), you have to close and release the repository manually. Do the following steps: - [ ] Connect to https://s01.oss.sonatype.org - [ ] Click on Staging Repositories and check the the files have been uploaded @@ -112,15 +70,6 @@ body: - [ ] Wait (check Activity tab until step "Repository closed" is displayed) - [ ] Click on release. The staging repository will disappear - Final step - - - [ ] Check that the release is available in https://repo1.maven.org/maven2/org/matrix/android/matrix-android-sdk2/ (it can take a few minutes) - - ##### Release on GitHub - - - [ ] Create the release on GitHub from [the tag](https://github.com/matrix-org/matrix-android-sdk2/tags) - - [ ] Upload the AAR on the GitHub release - ### Android SDK2 sample https://github.com/matrix-org/matrix-android-sdk2-sample diff --git a/.github/workflows/danger.yml b/.github/workflows/danger.yml index d1c63b9f30..505f14b75c 100644 --- a/.github/workflows/danger.yml +++ b/.github/workflows/danger.yml @@ -17,8 +17,8 @@ jobs: - run: | npm install --save-dev @babel/plugin-transform-flow-strip-types - name: Danger - uses: danger/danger-js@11.1.4 + uses: danger/danger-js@11.2.0 with: - args: "--dangerfile tools/danger/dangerfile.js" + args: "--dangerfile ./tools/danger/dangerfile.js" env: DANGER_GITHUB_API_TOKEN: ${{ steps.generate_token.outputs.token }} diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index b6333c5940..a44872e0ef 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v2 + uses: actions/checkout@v3 - name: Build docs run: ./gradlew dokkaHtml diff --git a/.github/workflows/post-pr.yml b/.github/workflows/post-pr.yml index 7c3bad3b77..26a1ad2c90 100644 --- a/.github/workflows/post-pr.yml +++ b/.github/workflows/post-pr.yml @@ -16,7 +16,7 @@ env: jobs: # More info on should-i-run: - # If this fails to run (the IF doesn't complete) then the needs will not be satisfied for any of the + # If this fails to run (the IF doesn't complete) then the needs will not be satisfied for any of the # other jobs below, so none will run. # except for the notification job at the bottom which will run all the time, unless should-i-run isn't # successful, or all the other jobs have succeeded @@ -32,6 +32,7 @@ jobs: name: UI Tests (Synapse) needs: should-i-run runs-on: buildjet-4vcpu-ubuntu-2204 + timeout-minutes: 90 # We might need to increase it if the time for tests grows strategy: fail-fast: false matrix: diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 452f6da36f..f781da7a3b 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -72,9 +72,9 @@ jobs: yarn add danger-plugin-lint-report --dev - name: Danger lint if: always() - uses: danger/danger-js@11.1.4 + uses: danger/danger-js@11.2.0 with: - args: "--dangerfile tools/danger/dangerfile-lint.js" + args: "--dangerfile ./tools/danger/dangerfile-lint.js" env: DANGER_GITHUB_API_TOKEN: ${{ steps.generate_token.outputs.token }} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index fc0ce5e044..5d3905a8c1 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -14,6 +14,7 @@ jobs: tests: name: Runs all tests runs-on: buildjet-4vcpu-ubuntu-2204 + timeout-minutes: 90 # We might need to increase it if the time for tests grows strategy: matrix: api-level: [28] @@ -128,25 +129,25 @@ jobs: # Unneeded as part of the test suite above, kept around in case we want to re-enable them. # # # Build Android Tests -# build-android-tests: -# name: Build Android Tests -# runs-on: ubuntu-latest +# build-android-tests: +# name: Build Android Tests +# runs-on: ubuntu-latest # concurrency: # group: ${{ github.ref == 'refs/heads/main' && format('unit-tests-main-{0}', github.sha) || github.ref == 'refs/heads/develop' && format('unit-tests-develop-{0}', github.sha) || format('build-android-tests-{0}', github.ref) }} # cancel-in-progress: true -# steps: -# - uses: actions/checkout@v3 -# - uses: actions/setup-java@v3 -# with: -# distribution: 'adopt' -# java-version: 11 -# - uses: actions/cache@v3 -# with: -# path: | -# ~/.gradle/caches -# ~/.gradle/wrapper -# key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} -# restore-keys: | -# ${{ runner.os }}-gradle- -# - name: Build Android Tests +# steps: +# - uses: actions/checkout@v3 +# - uses: actions/setup-java@v3 +# with: +# distribution: 'adopt' +# java-version: 11 +# - uses: actions/cache@v3 +# with: +# path: | +# ~/.gradle/caches +# ~/.gradle/wrapper +# key: ${{ runner.os }}-gradle-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} +# restore-keys: | +# ${{ runner.os }}-gradle- +# - name: Build Android Tests # run: ./gradlew clean assembleAndroidTest $CI_GRADLE_ARG_PROPERTIES diff --git a/.github/workflows/triage-labelled.yml b/.github/workflows/triage-labelled.yml index 8e9cc6d76c..036bc069ac 100644 --- a/.github/workflows/triage-labelled.yml +++ b/.github/workflows/triage-labelled.yml @@ -17,7 +17,8 @@ jobs: contains(github.event.issue.labels.*.name, 'Z-IA') || contains(github.event.issue.labels.*.name, 'A-Themes-Custom') || contains(github.event.issue.labels.*.name, 'A-E2EE-Dehydration') || - contains(github.event.issue.labels.*.name, 'A-Tags') + contains(github.event.issue.labels.*.name, 'A-Tags') || + contains(github.event.issue.labels.*.name, 'A-Rich-Text-Editor') steps: - uses: actions/github-script@v5 with: @@ -79,8 +80,8 @@ jobs: headers: '{"GraphQL-Features": "projects_next_graphql"}' query: | mutation add_to_project($projectid:ID!,$contentid:ID!) { - addProjectNextItem(input:{projectId:$projectid contentId:$contentid}) { - projectNextItem { + addProjectV2ItemById(input: {projectId: $projectid contentId: $contentid}) { + item { id } } @@ -88,7 +89,7 @@ jobs: projectid: ${{ env.PROJECT_ID }} contentid: ${{ github.event.issue.node_id }} env: - PROJECT_ID: "PN_kwDOAM0swc0sUA" + PROJECT_ID: "PVT_kwDOAM0swc0sUA" GITHUB_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }} add_product_issues: @@ -103,8 +104,8 @@ jobs: headers: '{"GraphQL-Features": "projects_next_graphql"}' query: | mutation add_to_project($projectid:ID!,$contentid:ID!) { - addProjectNextItem(input:{projectId:$projectid contentId:$contentid}) { - projectNextItem { + addProjectV2ItemById(input: {projectId: $projectid contentId: $contentid}) { + item { id } } @@ -112,7 +113,7 @@ jobs: projectid: ${{ env.PROJECT_ID }} contentid: ${{ github.event.issue.node_id }} env: - PROJECT_ID: "PN_kwDOAM0swc4AAg6N" + PROJECT_ID: "PVT_kwDOAM0swc4AAg6N" GITHUB_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }} delight_issues_to_board: @@ -129,8 +130,8 @@ jobs: headers: '{"GraphQL-Features": "projects_next_graphql"}' query: | mutation add_to_project($projectid:ID!,$contentid:ID!) { - addProjectNextItem(input:{projectId:$projectid contentId:$contentid}) { - projectNextItem { + addProjectV2ItemById(input: {projectId: $projectid contentId: $contentid}) { + item { id } } @@ -138,7 +139,7 @@ jobs: projectid: ${{ env.PROJECT_ID }} contentid: ${{ github.event.issue.node_id }} env: - PROJECT_ID: "PN_kwDOAM0swc1HvQ" + PROJECT_ID: "PVT_kwDOAM0swc1HvQ" GITHUB_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }} move_voice-message_issues: @@ -154,8 +155,8 @@ jobs: headers: '{"GraphQL-Features": "projects_next_graphql"}' query: | mutation add_to_project($projectid:ID!,$contentid:ID!) { - addProjectNextItem(input:{projectId:$projectid contentId:$contentid}) { - projectNextItem { + addProjectV2ItemById(input: {projectId: $projectid contentId: $contentid}) { + item { id } } @@ -163,7 +164,7 @@ jobs: projectid: ${{ env.PROJECT_ID }} contentid: ${{ github.event.issue.node_id }} env: - PROJECT_ID: "PN_kwDOAM0swc2KCw" + PROJECT_ID: "PVT_kwDOAM0swc2KCw" GITHUB_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }} move_message_bubbles_issues: name: A-Message-Bubbles to Message bubbles board @@ -178,8 +179,8 @@ jobs: headers: '{"GraphQL-Features": "projects_next_graphql"}' query: | mutation add_to_project($projectid:ID!,$contentid:ID!) { - addProjectNextItem(input:{projectId:$projectid contentId:$contentid}) { - projectNextItem { + addProjectV2ItemById(input: {projectId: $projectid contentId: $contentid}) { + item { id } } @@ -187,7 +188,7 @@ jobs: projectid: ${{ env.PROJECT_ID }} contentid: ${{ github.event.issue.node_id }} env: - PROJECT_ID: "PN_kwDOAM0swc3m-g" + PROJECT_ID: "PVT_kwDOAM0swc3m-g" GITHUB_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }} move_ftue_issues: @@ -203,8 +204,8 @@ jobs: headers: '{"GraphQL-Features": "projects_next_graphql"}' query: | mutation add_to_project($projectid:ID!,$contentid:ID!) { - addProjectNextItem(input:{projectId:$projectid contentId:$contentid}) { - projectNextItem { + addProjectV2ItemById(input: {projectId: $projectid contentId: $contentid}) { + item { id } } @@ -212,7 +213,7 @@ jobs: projectid: ${{ env.PROJECT_ID }} contentid: ${{ github.event.issue.node_id }} env: - PROJECT_ID: "PN_kwDOAM0swc4AAqVx" + PROJECT_ID: "PVT_kwDOAM0swc4AAqVx" GITHUB_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }} move_WTF_issues: @@ -228,8 +229,8 @@ jobs: headers: '{"GraphQL-Features": "projects_next_graphql"}' query: | mutation add_to_project($projectid:ID!,$contentid:ID!) { - addProjectNextItem(input:{projectId:$projectid contentId:$contentid}) { - projectNextItem { + addProjectV2ItemById(input: {projectId: $projectid contentId: $contentid}) { + item { id } } @@ -237,7 +238,7 @@ jobs: projectid: ${{ env.PROJECT_ID }} contentid: ${{ github.event.issue.node_id }} env: - PROJECT_ID: "PN_kwDOAM0swc4AArk0" + PROJECT_ID: "PVT_kwDOAM0swc4AArk0" GITHUB_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }} move_element_x_issues: @@ -258,8 +259,8 @@ jobs: headers: '{"GraphQL-Features": "projects_next_graphql"}' query: | mutation add_to_project($projectid:ID!,$contentid:ID!) { - addProjectNextItem(input:{projectId:$projectid contentId:$contentid}) { - projectNextItem { + addProjectV2ItemById(input: {projectId: $projectid contentId: $contentid}) { + item { id } } @@ -267,7 +268,7 @@ jobs: projectid: ${{ env.PROJECT_ID }} contentid: ${{ github.event.issue.node_id }} env: - PROJECT_ID: "PN_kwDOAM0swc4ABTXY" + PROJECT_ID: "PVT_kwDOAM0swc4ABTXY" GITHUB_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }} ps_features1: diff --git a/.github/workflows/triage-move-review-requests.yml b/.github/workflows/triage-move-review-requests.yml index 61f1f114dd..f604b82873 100644 --- a/.github/workflows/triage-move-review-requests.yml +++ b/.github/workflows/triage-move-review-requests.yml @@ -60,8 +60,8 @@ jobs: headers: '{"GraphQL-Features": "projects_next_graphql"}' query: | mutation add_to_project($projectid:ID!, $contentid:ID!) { - addProjectNextItem(input:{projectId:$projectid contentId:$contentid}) { - projectNextItem { + addProjectV2ItemById(input: {projectId: $projectid contentId: $contentid}) { + item { id } } @@ -69,7 +69,7 @@ jobs: projectid: ${{ env.PROJECT_ID }} contentid: ${{ github.event.pull_request.node_id }} env: - PROJECT_ID: "PN_kwDOAM0swc0sUA" + PROJECT_ID: "PVT_kwDOAM0swc0sUA" TEAM: "design" GITHUB_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }} @@ -129,8 +129,8 @@ jobs: headers: '{"GraphQL-Features": "projects_next_graphql"}' query: | mutation add_to_project($projectid:ID!, $contentid:ID!) { - addProjectNextItem(input:{projectId:$projectid contentId:$contentid}) { - projectNextItem { + addProjectV2ItemById(input: {projectId: $projectid contentId: $contentid}) { + item { id } } @@ -138,6 +138,6 @@ jobs: projectid: ${{ env.PROJECT_ID }} contentid: ${{ github.event.pull_request.node_id }} env: - PROJECT_ID: "PN_kwDOAM0swc4AAg6N" + PROJECT_ID: "PVT_kwDOAM0swc4AAg6N" TEAM: "product" GITHUB_TOKEN: ${{ secrets.ELEMENT_BOT_TOKEN }} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e7d6648070..3750d32b5b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,6 +12,7 @@ * [Code quality](#code-quality) * [Internal tool](#internal-tool) * [ktlint](#ktlint) + * [knit](#knit) * [lint](#lint) * [Unit tests](#unit-tests) * [Tests](#tests) @@ -116,6 +117,23 @@ Note that you can run For ktlint to fix some detected errors for you (you still have to check and commit the fix of course) +#### knit + +[knit](https://github.com/Kotlin/kotlinx-knit) is a tool which checks markdown files on the project. Also it generates/updates the table of content (toc) of the markdown files. + +So everytime the toc should be updated, just run +
+./gradlew knit
+
+ +and commit the changes. + +The CI will check that markdown files are up to date by running + +
+./gradlew knitCheck
+
+ #### lint
diff --git a/ELEMENT_CHANGES.md b/ELEMENT_CHANGES.md
index 18bb2480c3..e742d79c1e 100644
--- a/ELEMENT_CHANGES.md
+++ b/ELEMENT_CHANGES.md
@@ -1,3 +1,197 @@
+Changes in Element v1.5.18 (2023-01-02)
+=======================================
+
+This release fixes a bunch of recent regressions. Most of them were not pushed to production hopefully. Current production version is 1.5.11.
+Threads are now enabled by default, and this may let the application perform an initial sync.
+Testers on the PlayStore may have experimented some issues like empty room list, or incomplete room state (room name missing, etc.), or even crashing due to initial sync not using lazy loading of room members. All those issues have been fixed, but to fix your current state, please clear cache once you get the release 1.5.18.
+
+Bugfixes 🐛
+----------
+ - Start DM will create a deadlock if user profile was never loaded ([#7870](https://github.com/vector-im/element-android/issues/7870))
+ 
+
+Changes in Element v1.5.16 (2022-12-29)
+======================================
+
+Features ✨
+----------
+ - [Rich text editor] Add support for links ([#7746](https://github.com/vector-im/element-android/issues/7746))
+ - [Poll] When a poll is ended, use /relations API to ensure poll results are correct ([#7767](https://github.com/vector-im/element-android/issues/7767))
+ - [Session manager] Security recommendations cards: whole view should be tappable ([#7795](https://github.com/vector-im/element-android/issues/7795))
+ - [Session manager] Other sessions list: header should not be sticky ([#7797](https://github.com/vector-im/element-android/issues/7797))
+
+Bugfixes 🐛
+----------
+ - Do not show typing notification of ignored users. ([#2965](https://github.com/vector-im/element-android/issues/2965))
+ - [Push Notifications, Threads] - quick reply to threaded notification now sent to thread except main timeline ([#7475](https://github.com/vector-im/element-android/issues/7475))
+ - [Session manager] Other sessions list: filter option is displayed when selection mode is enabled ([#7784](https://github.com/vector-im/element-android/issues/7784))
+ - [Session manager] Other sessions: Filter bottom sheet cut in landscape mode ([#7786](https://github.com/vector-im/element-android/issues/7786))
+ - Automatically show keyboard after learn more bottom sheet is dismissed ([#7790](https://github.com/vector-im/element-android/issues/7790))
+ - [Session Manager] Other sessions list: cannot select/deselect session by a long press when in select mode ([#7792](https://github.com/vector-im/element-android/issues/7792))
+ - Fix current session ip address visibility ([#7794](https://github.com/vector-im/element-android/issues/7794))
+ - Device Manager UI review fixes ([#7798](https://github.com/vector-im/element-android/issues/7798))
+
+SDK API changes ⚠️
+------------------
+ - [Sync] Sync Filter params are moved to MatrixConfiguration and will not be stored in session realm to avoid bug when session cache is cleared ([#7843](https://github.com/vector-im/element-android/issues/7843))
+
+Other changes
+-------------
+ - [Voice Broadcast] Replace the player timeline ([#7821](https://github.com/vector-im/element-android/issues/7821))
+ - Increase session manager test coverage ([#7836](https://github.com/vector-im/element-android/issues/7836))
+
+
+Changes in Element v1.5.14 (2022-12-20)
+=======================================
+
+Bugfixes 🐛
+----------
+- ActiveSessionHolder is not supposed to start syncing. Instead, the MainActivity does it, if necessary. Fixes a race condition when clearing cache.
+
+
+Changes in Element v1.5.13 (2022-12-19)
+=======================================
+
+Bugfixes 🐛
+----------
+- Add `largeHeap=true` in the manifest since we are seeing more crashes (OOM) when handling sync response.
+
+
+Changes in Element v1.5.12 (2022-12-15)
+=======================================
+
+Features ✨
+----------
+- [Threads] - Threads Labs Flag is enabled by default and forced to be enabled for existing users, but sill can be disabled manually ([#5503](https://github.com/vector-im/element-android/issues/5503))
+ - [Session manager] Add action to signout all the other session ([#7693](https://github.com/vector-im/element-android/issues/7693))
+ - Remind unverified sessions with a banner once a week ([#7694](https://github.com/vector-im/element-android/issues/7694))
+ - [Session manager] Add actions to rename and signout current session ([#7697](https://github.com/vector-im/element-android/issues/7697))
+ - Voice Broadcast - Update last message in the room list ([#7719](https://github.com/vector-im/element-android/issues/7719))
+ - Delete unused client information from account data ([#7754](https://github.com/vector-im/element-android/issues/7754))
+
+Bugfixes 🐛
+----------
+ - Fix bad pills color background. For light and dark theme the color is now 61708B (iso EleWeb) ([#7274](https://github.com/vector-im/element-android/issues/7274))
+ - [Notifications] Fixed a bug when push notification was automatically dismissed while app is on background ([#7643](https://github.com/vector-im/element-android/issues/7643))
+ - ANR when asking to select the notification method ([#7653](https://github.com/vector-im/element-android/issues/7653))
+ - [Rich text editor] Fix design and spacing of rich text editor ([#7658](https://github.com/vector-im/element-android/issues/7658))
+ - [Rich text editor] Fix keyboard closing after collapsing editor ([#7659](https://github.com/vector-im/element-android/issues/7659))
+ - Rich Text Editor: fix several issues related to insets:
+  * Empty space displayed at the bottom when you don't have permissions to send messages into a room.
+  * Wrong insets being kept when you exit the room screen and the keyboard is displayed, then come back to it. ([#7680](https://github.com/vector-im/element-android/issues/7680))
+ - Fix crash in message composer when room is missing ([#7683](https://github.com/vector-im/element-android/issues/7683))
+ - Fix crash when invalid homeserver url is entered. ([#7684](https://github.com/vector-im/element-android/issues/7684))
+ - Rich Text Editor: improve performance when entering reply/edit/quote mode. ([#7691](https://github.com/vector-im/element-android/issues/7691))
+ - [Rich text editor] Add error tracking for rich text editor ([#7695](https://github.com/vector-im/element-android/issues/7695))
+ - Fix E2EE set up failure whilst signing in using QR code ([#7699](https://github.com/vector-im/element-android/issues/7699))
+ - Fix usage of unknown shield in room summary ([#7710](https://github.com/vector-im/element-android/issues/7710))
+ - Fix crash when the network is not available. ([#7725](https://github.com/vector-im/element-android/issues/7725))
+ - [Session manager] Sessions without encryption support should not prompt to verify ([#7733](https://github.com/vector-im/element-android/issues/7733))
+ - Fix issue of Scan QR code button sometimes not showing when it should be available ([#7737](https://github.com/vector-im/element-android/issues/7737))
+ - Verification request is not showing when verify session popup is displayed ([#7743](https://github.com/vector-im/element-android/issues/7743))
+ - Fix crash when inviting by email. ([#7744](https://github.com/vector-im/element-android/issues/7744))
+ - Revert usage of stable fields in live location sharing and polls ([#7751](https://github.com/vector-im/element-android/issues/7751))
+ - [Poll] Poll end event is not recognized ([#7753](https://github.com/vector-im/element-android/issues/7753))
+ - [Push Notifications] When push notification for threaded message is clicked, thread timeline will be opened instead of room's main timeline ([#7770](https://github.com/vector-im/element-android/issues/7770))
+
+Other changes
+-------------
+ - [Threads] - added API to fetch threads list from the server instead of building it locally from events ([#5819](https://github.com/vector-im/element-android/issues/5819))
+ - Add Z-Labs label for rich text editor and migrate to new label naming. ([#7477](https://github.com/vector-im/element-android/issues/7477))
+ - Crypto database migration tests ([#7645](https://github.com/vector-im/element-android/issues/7645))
+ - Add tracing Id for to device messages ([#7708](https://github.com/vector-im/element-android/issues/7708))
+ - Disable nightly popup and add an entry point in the advanced settings instead. ([#7723](https://github.com/vector-im/element-android/issues/7723))
+- Save m.local_notification_settings. event in account_data ([#7596](https://github.com/vector-im/element-android/issues/7596))
+- Update notifications setting when m.local_notification_settings. event changes for current device ([#7632](https://github.com/vector-im/element-android/issues/7632))
+
+SDK API changes ⚠️
+------------------
+- Handle account data removal ([#7740](https://github.com/vector-im/element-android/issues/7740))
+
+Changes in Element 1.5.11 (2022-12-07)
+======================================
+
+Bugfixes 🐛
+----------
+ - Fix crash when the network is not available. ([#7725](https://github.com/vector-im/element-android/issues/7725))
+
+
+Changes in Element v1.5.10 (2022-11-30)
+=======================================
+
+Features ✨
+----------
+ - Add setting to allow disabling direct share ([#2725](https://github.com/vector-im/element-android/issues/2725))
+ - [Device Manager] Toggle IP address visibility ([#7546](https://github.com/vector-im/element-android/issues/7546))
+ - New implementation of the full screen mode for the Rich Text Editor. ([#7577](https://github.com/vector-im/element-android/issues/7577))
+
+Bugfixes 🐛
+----------
+ - Fix italic text is truncated when bubble mode and markdown is enabled ([#5679](https://github.com/vector-im/element-android/issues/5679))
+ - Missing translations on "replyTo" messages ([#7555](https://github.com/vector-im/element-android/issues/7555))
+ - ANR on session start when sending client info is enabled ([#7604](https://github.com/vector-im/element-android/issues/7604))
+ - Make the plain text mode layout of the RTE more compact. ([#7620](https://github.com/vector-im/element-android/issues/7620))
+ - Push notification for thread message is now shown correctly when user observes rooms main timeline ([#7634](https://github.com/vector-im/element-android/issues/7634))
+ - Voice Broadcast - Fix playback stuck in buffering mode ([#7646](https://github.com/vector-im/element-android/issues/7646))
+
+In development 🚧
+----------------
+ - Voice Broadcast - Handle redaction of the state events on the listener and recorder sides ([#7629](https://github.com/vector-im/element-android/issues/7629))
+ - Voice Broadcast - Update the buffering display in the timeline ([#7655](https://github.com/vector-im/element-android/issues/7655))
+ - Voice Broadcast - Remove voice messages related to a VB from the room attachments ([#7656](https://github.com/vector-im/element-android/issues/7656))
+
+SDK API changes ⚠️
+------------------
+ - Added support for read receipts in threads. Now user in a room can have multiple read receipts (one per thread + one in main thread + one without threadId) ([#6996](https://github.com/vector-im/element-android/issues/6996))
+ - Sync Filter now taking in account homeserver capabilities to not pass unsupported parameters.
+  Sync Filter is now configured by providing SyncFilterBuilder class instance, instead of Filter to identify Filter changes related to homeserver capabilities ([#7626](https://github.com/vector-im/element-android/issues/7626))
+
+Other changes
+-------------
+ - Remove usage of Buildkite. ([#7583](https://github.com/vector-im/element-android/issues/7583))
+ - Better validation of edits ([#7594](https://github.com/vector-im/element-android/issues/7594))
+
+
+Changes in Element v1.5.8 (2022-11-17)
+======================================
+
+Features ✨
+----------
+ - [Session manager] Multi-session signout ([#7418](https://github.com/vector-im/element-android/issues/7418))
+ - Rich text editor: add full screen mode. ([#7436](https://github.com/vector-im/element-android/issues/7436))
+ - [Rich text editor] Add plain text mode ([#7452](https://github.com/vector-im/element-android/issues/7452))
+ - Move TypingView inside the timeline items. ([#7496](https://github.com/vector-im/element-android/issues/7496))
+ - Push notifications toggle: align implementation for current session ([#7512](https://github.com/vector-im/element-android/issues/7512))
+ - Voice messages - Persist the playback position across different screens ([#7582](https://github.com/vector-im/element-android/issues/7582))
+
+Bugfixes 🐛
+----------
+ - [Voice Broadcast] Do not display the recorder view for a live broadcast started from another session ([#7431](https://github.com/vector-im/element-android/issues/7431))
+ - [Session manager] Hide push notification toggle when there is no server support ([#7457](https://github.com/vector-im/element-android/issues/7457))
+ - Fix rich text editor textfield not growing to fill parent on full screen. ([#7491](https://github.com/vector-im/element-android/issues/7491))
+ - Fix duplicated mention pills in some cases ([#7501](https://github.com/vector-im/element-android/issues/7501))
+ - Voice Broadcast - Fix duplicated voice messages in the internal playlist ([#7502](https://github.com/vector-im/element-android/issues/7502))
+ - When joining a room, the message composer is displayed once the room is loaded. ([#7509](https://github.com/vector-im/element-android/issues/7509))
+ - Voice Broadcast - Fix error on voice messages in unencrypted rooms ([#7519](https://github.com/vector-im/element-android/issues/7519))
+ - Fix description of verified sessions ([#7533](https://github.com/vector-im/element-android/issues/7533))
+
+In development 🚧
+----------------
+ - [Voice Broadcast] Improve timeline items factory and handle bad recording state display ([#7448](https://github.com/vector-im/element-android/issues/7448))
+ - [Voice Broadcast] Stop recording when opening the room after an app restart ([#7450](https://github.com/vector-im/element-android/issues/7450))
+ - [Voice Broadcast] Improve playlist fetching and player codebase ([#7478](https://github.com/vector-im/element-android/issues/7478))
+ - [Voice Broadcast] Display an error dialog if the user fails to start a voice broadcast ([#7485](https://github.com/vector-im/element-android/issues/7485))
+ - [Voice Broadcast] Add seekbar in listening tile ([#7496](https://github.com/vector-im/element-android/issues/7496))
+ - [Voice Broadcast] Improve the live indicator icon rendering in the timeline ([#7579](https://github.com/vector-im/element-android/issues/7579))
+ - Voice Broadcast - Add maximum length ([#7588](https://github.com/vector-im/element-android/issues/7588))
+
+SDK API changes ⚠️
+------------------
+ - [Metrics] Add `SpannableMetricPlugin` to support spans within transactions. ([#7514](https://github.com/vector-im/element-android/issues/7514))
+ - Fix a bug that caused messages with no formatted text to be quoted as "null". ([#7530](https://github.com/vector-im/element-android/issues/7530))
+ - If message content has no `formattedBody`, default to `body` when editing. ([#7574](https://github.com/vector-im/element-android/issues/7574))
+
+
 Changes in Element v1.5.7 (2022-11-07)
 ======================================
 
diff --git a/README.md b/README.md
index 093f690aaa..4ad5ff5e5a 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
 # Tchap Android
 
-Tchap Android v2 is an Android Matrix client. The app can be run on every Android devices with Android OS Lollipop and more (API 21).
+Tchap Android is an Android Matrix Client provided by [DINUM](https://tchap.beta.gouv.fr/). The app can be run on every Android devices with Android OS Lollipop and more (API 21).
 
 [Get it on Google Play](https://play.google.com/store/apps/details?id=fr.gouv.tchap.a)
 
@@ -33,5 +33,3 @@ Also [this documentation](./docs/_developer_onboarding.md) can hopefully help de
 Issues are triaged by community members and the Android App Team, following the [triage process](https://github.com/vector-im/element-meta/wiki/Triage-process).
 
 We use [issue labels](https://github.com/vector-im/element-meta/wiki/Issue-labelling) to sort all incoming issues.
-
->>>>>>> v1.5.2
diff --git a/TCHAP_CHANGES.md b/TCHAP_CHANGES.md
index c6212a6505..c5137340c1 100644
--- a/TCHAP_CHANGES.md
+++ b/TCHAP_CHANGES.md
@@ -1,3 +1,17 @@
+Changes in Tchap 2.7.0 (2023-02-13)
+===================================
+
+Features ✨
+----------
+ - [Keys Management] Restore "Encrypted Message Recovery" when the key backup is enabled ([#814](https://github.com/tchapgouv/tchap-android-v2/issues/814))
+ - Enable cross signin and secure storage. ([#815](https://github.com/tchapgouv/tchap-android-v2/issues/815))
+
+Improvements 🙌
+--------------
+ - Rebase against Element-Android v1.5.11 ([#825](https://github.com/tchapgouv/tchap-android-v2/issues/825))
+ - Rebase against Element-Android v1.5.18 ([#826](https://github.com/tchapgouv/tchap-android-v2/issues/826))
+
+
 Changes in Tchap 2.6.1 (2023-02-10)
 ===================================
 
diff --git a/build.gradle b/build.gradle
index c004149a4d..2a13a2fca2 100644
--- a/build.gradle
+++ b/build.gradle
@@ -24,12 +24,12 @@ buildscript {
         classpath libs.gradle.gradlePlugin
         classpath libs.gradle.kotlinPlugin
         classpath libs.gradle.hiltPlugin
-        classpath 'com.google.firebase:firebase-appdistribution-gradle:3.0.3'
+        classpath 'com.google.firebase:firebase-appdistribution-gradle:3.1.1'
         classpath 'com.google.gms:google-services:4.3.14'
-        classpath 'org.sonarsource.scanner.gradle:sonarqube-gradle-plugin:3.4.0.2513'
+        classpath 'org.sonarsource.scanner.gradle:sonarqube-gradle-plugin:3.5.0.2730'
         classpath 'com.google.android.gms:oss-licenses-plugin:0.10.5'
         classpath "com.likethesalad.android:stem-plugin:2.2.3"
-        classpath 'org.owasp:dependency-check-gradle:7.3.0'
+        classpath 'org.owasp:dependency-check-gradle:7.4.1'
         classpath "org.jetbrains.dokka:dokka-gradle-plugin:1.7.20"
         classpath "org.jetbrains.kotlinx:kotlinx-knit:0.4.0"
         classpath 'com.jakewharton:butterknife-gradle-plugin:10.2.3'
@@ -43,12 +43,12 @@ plugins {
     // ktlint Plugin
     id "org.jlleitschuh.gradle.ktlint" version "11.0.0"
     // Detekt
-    id "io.gitlab.arturbosch.detekt" version "1.21.0"
+    id "io.gitlab.arturbosch.detekt" version "1.22.0"
     // Ksp
-    id "com.google.devtools.ksp" version "1.7.20-1.0.7"
+    id "com.google.devtools.ksp" version "1.7.22-1.0.8"
 
     // Dependency Analysis
-    id 'com.autonomousapps.dependency-analysis' version "1.13.1"
+    id 'com.autonomousapps.dependency-analysis' version "1.17.0"
     // Gradle doctor
     id "com.osacky.doctor" version "0.8.1"
 }
diff --git a/coverage.gradle b/coverage.gradle
index f6ce9db551..2ce4645f28 100644
--- a/coverage.gradle
+++ b/coverage.gradle
@@ -82,7 +82,7 @@ task unitTestsWithCoverage(type: GradleBuild) {
     // the 7.1.3 android gradle plugin has a bug where enableTestCoverage generates invalid coverage
     startParameter.projectProperties.coverage = [enableTestCoverage:  false]
     // Tchap: Specify variant for every module
-    tasks = [':matrix-sdk-android:testDebugUnitTest', ':vector:testGplayBtchapWithoutvoipWithoutpinningDebugUnitTest', ':vector-app:testGplayBtchapWithoutvoipWithoutpinningDebugUnitTest', ':vector-config:testBtchapDebugUnitTest']
+    tasks = [':matrix-sdk-android:testDebugUnitTest', ':vector:testGplayBtchapWithoutvoipWithoutpinningDebugUnitTest', ':vector-app:testGplayBtchapWithoutvoipWithoutpinningDebugUnitTest', ':vector-config:testBtchapWithoutvoipDebugUnitTest']
 }
 
 task instrumentationTestsWithCoverage(type: GradleBuild) {
diff --git a/dependencies.gradle b/dependencies.gradle
index 33a2096a43..b81c1c2017 100644
--- a/dependencies.gradle
+++ b/dependencies.gradle
@@ -8,16 +8,16 @@ ext.versions = [
 
 def gradle = "7.3.1"
 // Ref: https://kotlinlang.org/releases.html
-def kotlin = "1.7.20"
+def kotlin = "1.7.22"
 def kotlinCoroutines = "1.6.4"
-def dagger = "2.44"
+def dagger = "2.44.2"
 def appDistribution = "16.0.0-beta05"
 def retrofit = "2.9.0"
 def markwon = "4.6.2"
 def moshi = "1.14.0"
 def lifecycle = "2.5.1"
 def flowBinding = "1.2.0"
-def flipper = "0.171.1"
+def flipper = "0.176.0"
 def epoxy = "5.0.0"
 def mavericks = "3.0.1"
 def glide = "4.14.2"
@@ -26,13 +26,13 @@ def jjwt = "0.11.5"
 // Temporary version to unblock #6929. Once 0.16.0 is released we should use it, and revert
 // the whole commit which set version 0.16.0-SNAPSHOT
 def vanniktechEmoji = "0.16.0-SNAPSHOT"
-def sentry = "6.6.0"
-def fragment = "1.5.4"
+def sentry = "6.9.2"
+def fragment = "1.5.5"
 // Testing
 def mockk = "1.12.3" // We need to use 1.12.3 to have mocking in androidTest until a new version is released: https://github.com/mockk/mockk/issues/819
 def espresso = "3.4.0"
 def androidxTest = "1.4.0"
-def androidxOrchestrator = "1.4.1"
+def androidxOrchestrator = "1.4.2"
 def paparazzi = "1.1.0"
 
 ext.libs = [
@@ -83,7 +83,7 @@ ext.libs = [
                 'appdistributionApi'      : "com.google.firebase:firebase-appdistribution-api-ktx:$appDistribution",
                 'appdistribution'         : "com.google.firebase:firebase-appdistribution:$appDistribution",
                 // Phone number https://github.com/google/libphonenumber
-                'phonenumber'             : "com.googlecode.libphonenumber:libphonenumber:8.12.57"
+                'phonenumber'             : "com.googlecode.libphonenumber:libphonenumber:8.13.3"
         ],
         dagger      : [
                 'dagger'                  : "com.google.dagger:dagger:$dagger",
@@ -98,7 +98,7 @@ ext.libs = [
         ],
         element     : [
                 'opusencoder'             : "io.element.android:opusencoder:1.1.0",
-                'wysiwyg'                 : "io.element.android:wysiwyg:0.2.1"
+                'wysiwyg'                 : "io.element.android:wysiwyg:0.10.0"
         ],
         squareup    : [
                 'moshi'                  : "com.squareup.moshi:moshi:$moshi",
@@ -129,7 +129,7 @@ ext.libs = [
                 'mavericksTesting'       : "com.airbnb.android:mavericks-testing:$mavericks"
         ],
         maplibre    : [
-                'androidSdk'             : "org.maplibre.gl:android-sdk:9.5.2",
+                'androidSdk'             : "org.maplibre.gl:android-sdk:9.6.0",
                 'pluginAnnotation'       : "org.maplibre.gl:android-plugin-annotation-v9:1.0.0"
         ],
         mockk      : [
diff --git a/docs/database_migration_test.md b/docs/database_migration_test.md
new file mode 100644
index 0000000000..f7844abde8
--- /dev/null
+++ b/docs/database_migration_test.md
@@ -0,0 +1,55 @@
+
+
+* [Testing database migration](#testing-database-migration)
+  * [Creating a reference database](#creating-a-reference-database)
+  * [Testing](#testing)
+
+
+
+## Testing database migration
+
+### Creating a reference database
+
+Databases are encrypted, the key to decrypt is needed to setup the test.
+A special build property must be enabled to extract it. 
+
+Set `vector.debugPrivateData=true` in `~/.gradle/gradle.properties` (to avoid committing by mistake)
+
+Launch the app in your emulator, login and use the app to fill up the database.
+
+Save the key for the tested database
+```
+RealmKeysUtils  W  Database key for alias `session_db_fe9f212a611ccf6dea1141777065ed0a`: 935a6dfa0b0fc5cce1414194ed190....
+RealmKeysUtils  W  Database key for alias `crypto_module_fe9f212a611ccf6dea1141777065ed0a`: 7b9a21a8a311e85d75b069a343.....
+```
+
+
+Use the [Device File Explorer](https://developer.android.com/studio/debug/device-file-explorer) to extrat the database file from the emulator.
+
+Go to `data/data/im.vector.app.debug/files//`
+Pick the database you want to test (name can be found in SessionRealmConfigurationFactory):
+ - crypto_store.realm for crypto
+ - disk_store.realm for session
+ - etc... 
+
+Download the file on your disk
+
+### Testing
+
+Copy the file in `src/AndroidTest/assets`
+
+see `CryptoSanityMigrationTest` or `RealmSessionStoreMigration43Test` for sample tests.
+
+There are already some databases in the assets folder.
+The existing test will properly detect schema changes, and fail with such errors if a migration is missing:
+
+```
+io.realm.exceptions.RealmMigrationNeededException: Migration is required due to the following errors:
+- Property 'CryptoMetadataEntity.foo' has been added.
+```
+
+If you want to test properly more complex database migration (dynamic transforms) ensure that the database contains
+the entity you want to migrate.
+
+You can explore the database with [realm studio](https://www.mongodb.com/docs/realm/studio/) if needed.
+
diff --git a/docs/installing_from_ci.md b/docs/installing_from_ci.md
new file mode 100644
index 0000000000..01fb4afef2
--- /dev/null
+++ b/docs/installing_from_ci.md
@@ -0,0 +1,52 @@
+## Installing from CI
+
+
+
+  * [Installing from Buildkite](#installing-from-buildkite)
+  * [Installing from GitHub](#installing-from-github)
+    * [Create a GitHub token](#create-a-github-token)
+  * [Provide artifact URL](#provide-artifact-url)
+  * [Next steps](#next-steps)
+  * [Future improvement](#future-improvement)
+
+
+
+Installing APK build by the CI is possible
+
+### Installing from Buildkite
+
+The script `./tools/install/installFromBuildkite.sh` can be used, but Builkite will be removed soon. See next section.
+
+### Installing from GitHub
+
+To install an APK built by a GitHub action, run the script `./tools/install/installFromGitHub.sh`. You will need to pass a GitHub token to do so.
+
+#### Create a GitHub token
+
+You can create a GitHub token going to your Github account, at this page: [https://github.com/settings/tokens](https://github.com/settings/tokens).
+
+You need to create a token (classic) with the scope `repo/public_repo`. So just check the corresponding checkbox.
+Validity can be long since the scope of this token is limited. You will still be able to delete the token and generate a new one.
+Click on Generate token and save the token locally.
+
+### Provide artifact URL
+
+The script will ask for an artifact URL. You can get this artifact URL by following these steps:
+
+- open the pull request
+- in the check at the bottom, click on `APK Build / Build debug APKs`
+- click on `Summary`
+- scroll to the bottom of the page
+- copy the link `vector-Fdroid-debug` if you want the F-Droid variant or `vector-Gplay-debug` if you want the Gplay variant.
+
+The copied link can be provided to the script.
+
+### Next steps
+
+The script will download the artifact, unzip it and install the correct version (regarding arch) on your device.
+
+Files will be added to the folder `./tmp/DebugApks`. Feel free to cleanup this folder from time to time, the script will not delete files.
+
+### Future improvement
+
+The script could ask the user for a Pull Request number and Gplay/Fdroid choice like it was done with Buildkite script. Using GitHub API may be possible to do that.
diff --git a/fastlane/metadata/android/az/short_description.txt b/fastlane/metadata/android/az/short_description.txt
new file mode 100644
index 0000000000..ecf3d5008c
--- /dev/null
+++ b/fastlane/metadata/android/az/short_description.txt
@@ -0,0 +1 @@
+Qrup mesajlaşma - şifrəli mesajlaşma, qrup söhbəti və video zənglər
diff --git a/fastlane/metadata/android/az/title.txt b/fastlane/metadata/android/az/title.txt
new file mode 100644
index 0000000000..4ca0ffb55b
--- /dev/null
+++ b/fastlane/metadata/android/az/title.txt
@@ -0,0 +1 @@
+Element - Təhlükəsiz Mesajlaşma
diff --git a/fastlane/metadata/android/cs-CZ/changelogs/40105060.txt b/fastlane/metadata/android/cs-CZ/changelogs/40105060.txt
new file mode 100644
index 0000000000..e966dbbd92
--- /dev/null
+++ b/fastlane/metadata/android/cs-CZ/changelogs/40105060.txt
@@ -0,0 +1,2 @@
+Hlavní změny v této verzi: nové uživatelské rozhraní pro výběr přílohy.
+Úplný seznam změn: https://github.com/vector-im/element-android/releases
diff --git a/fastlane/metadata/android/cs-CZ/changelogs/40105070.txt b/fastlane/metadata/android/cs-CZ/changelogs/40105070.txt
new file mode 100644
index 0000000000..e966dbbd92
--- /dev/null
+++ b/fastlane/metadata/android/cs-CZ/changelogs/40105070.txt
@@ -0,0 +1,2 @@
+Hlavní změny v této verzi: nové uživatelské rozhraní pro výběr přílohy.
+Úplný seznam změn: https://github.com/vector-im/element-android/releases
diff --git a/fastlane/metadata/android/cs-CZ/changelogs/40105080.txt b/fastlane/metadata/android/cs-CZ/changelogs/40105080.txt
new file mode 100644
index 0000000000..90210199a1
--- /dev/null
+++ b/fastlane/metadata/android/cs-CZ/changelogs/40105080.txt
@@ -0,0 +1,2 @@
+Hlavní změny v této verzi: opravy různých chyb a vylepšení.
+Úplný seznam změn: https://github.com/vector-im/element-android/releases
diff --git a/fastlane/metadata/android/cs-CZ/changelogs/40105100.txt b/fastlane/metadata/android/cs-CZ/changelogs/40105100.txt
new file mode 100644
index 0000000000..8c51742e06
--- /dev/null
+++ b/fastlane/metadata/android/cs-CZ/changelogs/40105100.txt
@@ -0,0 +1,2 @@
+Hlavní změny v této verzi: Nová implementace celoobrazovkového režimu pro editor formátovaného textu a opravy chyb.
+Úplný seznam změn: https://github.com/vector-im/element-android/releases
diff --git a/fastlane/metadata/android/de-DE/changelogs/40105060.txt b/fastlane/metadata/android/de-DE/changelogs/40105060.txt
new file mode 100644
index 0000000000..0b36faff1e
--- /dev/null
+++ b/fastlane/metadata/android/de-DE/changelogs/40105060.txt
@@ -0,0 +1,2 @@
+Die wichtigste Änderung in dieser Version: Neues Anhangauswahl-UI.
+Vollständiges Änderungsprotokoll: https://github.com/vector-im/element-android/releases
diff --git a/fastlane/metadata/android/de-DE/changelogs/40105070.txt b/fastlane/metadata/android/de-DE/changelogs/40105070.txt
new file mode 100644
index 0000000000..3141cea7cb
--- /dev/null
+++ b/fastlane/metadata/android/de-DE/changelogs/40105070.txt
@@ -0,0 +1,2 @@
+Die wichtigste Änderung in dieser Version: Neue Anhangauswahl-Oberfläche.
+Vollständiges Änderungsprotokoll: https://github.com/vector-im/element-android/releases
diff --git a/fastlane/metadata/android/de-DE/changelogs/40105080.txt b/fastlane/metadata/android/de-DE/changelogs/40105080.txt
new file mode 100644
index 0000000000..0422f9cd4f
--- /dev/null
+++ b/fastlane/metadata/android/de-DE/changelogs/40105080.txt
@@ -0,0 +1,2 @@
+Die wichtigsten Änderungen in dieser Version: Fehlerbehebungen und Verbesserungen.
+Vollständiges Änderungsprotokoll: https://github.com/vector-im/element-android/releases
diff --git a/fastlane/metadata/android/de-DE/changelogs/40105100.txt b/fastlane/metadata/android/de-DE/changelogs/40105100.txt
new file mode 100644
index 0000000000..de5f4d90e8
--- /dev/null
+++ b/fastlane/metadata/android/de-DE/changelogs/40105100.txt
@@ -0,0 +1,2 @@
+Die wichtigsten Änderungen in dieser Version: Der Vollbildmodus des Textverarbeitungseditors wurde neu umgesetzt und es wurden diverse Fehler behoben.
+Vollständiges Änderungsprotokoll: https://github.com/vector-im/element-android/releases
diff --git a/fastlane/metadata/android/en-US/changelogs/40105080.txt b/fastlane/metadata/android/en-US/changelogs/40105080.txt
new file mode 100644
index 0000000000..f9ca8cdd7c
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/40105080.txt
@@ -0,0 +1,2 @@
+Main changes in this version: bug fixes and improvements.
+Full changelog: https://github.com/vector-im/element-android/releases
diff --git a/fastlane/metadata/android/en-US/changelogs/40105100.txt b/fastlane/metadata/android/en-US/changelogs/40105100.txt
new file mode 100644
index 0000000000..c9e5ba5fa9
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/40105100.txt
@@ -0,0 +1,2 @@
+Main changes in this version: New implementation of the full screen mode for the Rich Text Editor and bugfixes.
+Full changelog: https://github.com/vector-im/element-android/releases
diff --git a/fastlane/metadata/android/en-US/changelogs/40105110.txt b/fastlane/metadata/android/en-US/changelogs/40105110.txt
new file mode 100644
index 0000000000..c9e5ba5fa9
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/40105110.txt
@@ -0,0 +1,2 @@
+Main changes in this version: New implementation of the full screen mode for the Rich Text Editor and bugfixes.
+Full changelog: https://github.com/vector-im/element-android/releases
diff --git a/fastlane/metadata/android/en-US/changelogs/40105120.txt b/fastlane/metadata/android/en-US/changelogs/40105120.txt
new file mode 100644
index 0000000000..91c25cf053
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/40105120.txt
@@ -0,0 +1,2 @@
+Main changes in this version: Thread are now enabled by default.
+Full changelog: https://github.com/vector-im/element-android/releases
diff --git a/fastlane/metadata/android/en-US/changelogs/40105130.txt b/fastlane/metadata/android/en-US/changelogs/40105130.txt
new file mode 100644
index 0000000000..91c25cf053
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/40105130.txt
@@ -0,0 +1,2 @@
+Main changes in this version: Thread are now enabled by default.
+Full changelog: https://github.com/vector-im/element-android/releases
diff --git a/fastlane/metadata/android/en-US/changelogs/40105140.txt b/fastlane/metadata/android/en-US/changelogs/40105140.txt
new file mode 100644
index 0000000000..91c25cf053
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/40105140.txt
@@ -0,0 +1,2 @@
+Main changes in this version: Thread are now enabled by default.
+Full changelog: https://github.com/vector-im/element-android/releases
diff --git a/fastlane/metadata/android/en-US/changelogs/40105160.txt b/fastlane/metadata/android/en-US/changelogs/40105160.txt
new file mode 100644
index 0000000000..91c25cf053
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/40105160.txt
@@ -0,0 +1,2 @@
+Main changes in this version: Thread are now enabled by default.
+Full changelog: https://github.com/vector-im/element-android/releases
diff --git a/fastlane/metadata/android/en-US/changelogs/40105180.txt b/fastlane/metadata/android/en-US/changelogs/40105180.txt
new file mode 100644
index 0000000000..91c25cf053
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/40105180.txt
@@ -0,0 +1,2 @@
+Main changes in this version: Thread are now enabled by default.
+Full changelog: https://github.com/vector-im/element-android/releases
diff --git a/fastlane/metadata/android/et/changelogs/40105060.txt b/fastlane/metadata/android/et/changelogs/40105060.txt
new file mode 100644
index 0000000000..d5606e24b3
--- /dev/null
+++ b/fastlane/metadata/android/et/changelogs/40105060.txt
@@ -0,0 +1,2 @@
+Põhilised muutused selles versioonis: uus liides manuste lisamiseks.
+Kogu ingliskeelne muudatuste logi: https://github.com/vector-im/element-android/releases
diff --git a/fastlane/metadata/android/et/changelogs/40105070.txt b/fastlane/metadata/android/et/changelogs/40105070.txt
new file mode 100644
index 0000000000..061e09814d
--- /dev/null
+++ b/fastlane/metadata/android/et/changelogs/40105070.txt
@@ -0,0 +1,2 @@
+Põhilised muutused selles versioonis: uus liides manuste valimiseks.
+Kogu ingliskeelne muudatuste logi: https://github.com/vector-im/element-android/releases
diff --git a/fastlane/metadata/android/et/changelogs/40105080.txt b/fastlane/metadata/android/et/changelogs/40105080.txt
new file mode 100644
index 0000000000..37b9a2cfe5
--- /dev/null
+++ b/fastlane/metadata/android/et/changelogs/40105080.txt
@@ -0,0 +1,2 @@
+Põhilised muutused selles versioonis: erinevate vigade parandused ja kohendused.
+Kogu ingliskeelne muudatuste logi: https://github.com/vector-im/element-android/releases
diff --git a/fastlane/metadata/android/et/changelogs/40105100.txt b/fastlane/metadata/android/et/changelogs/40105100.txt
new file mode 100644
index 0000000000..f6212db01b
--- /dev/null
+++ b/fastlane/metadata/android/et/changelogs/40105100.txt
@@ -0,0 +1,2 @@
+Põhilised muutused selles versioonis: tekstitoimeti täisekraanivaade ja erinevate vigade parandused.
+Kogu ingliskeelne muudatuste logi: https://github.com/vector-im/element-android/releases
diff --git a/fastlane/metadata/android/fa/changelogs/40105060.txt b/fastlane/metadata/android/fa/changelogs/40105060.txt
new file mode 100644
index 0000000000..b677c05c89
--- /dev/null
+++ b/fastlane/metadata/android/fa/changelogs/40105060.txt
@@ -0,0 +1,2 @@
+تغییرات عمده در این نگارش: رابط کاربری جدید برای گزینش پیوست.
+گزارش دگرگونی کامل: https://github.com/vector-im/element-android/releases
diff --git a/fastlane/metadata/android/fa/changelogs/40105070.txt b/fastlane/metadata/android/fa/changelogs/40105070.txt
new file mode 100644
index 0000000000..b677c05c89
--- /dev/null
+++ b/fastlane/metadata/android/fa/changelogs/40105070.txt
@@ -0,0 +1,2 @@
+تغییرات عمده در این نگارش: رابط کاربری جدید برای گزینش پیوست.
+گزارش دگرگونی کامل: https://github.com/vector-im/element-android/releases
diff --git a/fastlane/metadata/android/fa/changelogs/40105080.txt b/fastlane/metadata/android/fa/changelogs/40105080.txt
new file mode 100644
index 0000000000..91385addde
--- /dev/null
+++ b/fastlane/metadata/android/fa/changelogs/40105080.txt
@@ -0,0 +1,2 @@
+تغییرات عمده در این نگارش: رفع اشکال‌ها و بهبود.
+گزارش دگرگونی کامل: https://github.com/vector-im/element-android/releases
diff --git a/fastlane/metadata/android/fr-FR/changelogs/40105060.txt b/fastlane/metadata/android/fr-FR/changelogs/40105060.txt
new file mode 100644
index 0000000000..b33f290d0d
--- /dev/null
+++ b/fastlane/metadata/android/fr-FR/changelogs/40105060.txt
@@ -0,0 +1,2 @@
+Principaux changements pour cette version : nouvelle interface de sélection d’une pièce jointe.
+Intégralité des changements : https://github.com/vector-im/element-android/releases
diff --git a/fastlane/metadata/android/fr-FR/changelogs/40105070.txt b/fastlane/metadata/android/fr-FR/changelogs/40105070.txt
new file mode 100644
index 0000000000..b33f290d0d
--- /dev/null
+++ b/fastlane/metadata/android/fr-FR/changelogs/40105070.txt
@@ -0,0 +1,2 @@
+Principaux changements pour cette version : nouvelle interface de sélection d’une pièce jointe.
+Intégralité des changements : https://github.com/vector-im/element-android/releases
diff --git a/fastlane/metadata/android/fr-FR/changelogs/40105080.txt b/fastlane/metadata/android/fr-FR/changelogs/40105080.txt
new file mode 100644
index 0000000000..d33197c270
--- /dev/null
+++ b/fastlane/metadata/android/fr-FR/changelogs/40105080.txt
@@ -0,0 +1,2 @@
+Principaux changements pour cette version : corrections de bugs et améliorations.
+Intégralité des changements : https://github.com/vector-im/element-android/releases
diff --git a/fastlane/metadata/android/id/changelogs/40105060.txt b/fastlane/metadata/android/id/changelogs/40105060.txt
new file mode 100644
index 0000000000..32fb87563e
--- /dev/null
+++ b/fastlane/metadata/android/id/changelogs/40105060.txt
@@ -0,0 +1,2 @@
+Perubahan utama dalam versi ini: Antarmuka baru untuk memilih sebuah lampiran.
+Catatan perubahan lanjutan: https://github.com/vector-im/element-android/releases
diff --git a/fastlane/metadata/android/id/changelogs/40105070.txt b/fastlane/metadata/android/id/changelogs/40105070.txt
new file mode 100644
index 0000000000..32fb87563e
--- /dev/null
+++ b/fastlane/metadata/android/id/changelogs/40105070.txt
@@ -0,0 +1,2 @@
+Perubahan utama dalam versi ini: Antarmuka baru untuk memilih sebuah lampiran.
+Catatan perubahan lanjutan: https://github.com/vector-im/element-android/releases
diff --git a/fastlane/metadata/android/id/changelogs/40105080.txt b/fastlane/metadata/android/id/changelogs/40105080.txt
new file mode 100644
index 0000000000..8384716bbc
--- /dev/null
+++ b/fastlane/metadata/android/id/changelogs/40105080.txt
@@ -0,0 +1,2 @@
+Perubahan utama dalam versi ini: perbaikan kutu dan fitur
+Catatan perubahan lanjutan: https://github.com/vector-im/element-android/releases
diff --git a/fastlane/metadata/android/id/changelogs/40105100.txt b/fastlane/metadata/android/id/changelogs/40105100.txt
new file mode 100644
index 0000000000..0c7d2f5262
--- /dev/null
+++ b/fastlane/metadata/android/id/changelogs/40105100.txt
@@ -0,0 +1,2 @@
+Perubahan utama dalam versi ini: Penerapan baru mode layar penuh untuk Penyunting Teks Kaya dan perbaikan kutu.
+Catatan perubahan lanjutan: https://github.com/vector-im/element-android/releases
diff --git a/fastlane/metadata/android/it-IT/changelogs/40105060.txt b/fastlane/metadata/android/it-IT/changelogs/40105060.txt
new file mode 100644
index 0000000000..34d299b774
--- /dev/null
+++ b/fastlane/metadata/android/it-IT/changelogs/40105060.txt
@@ -0,0 +1,2 @@
+Modifiche principali in questa versione: nuova interfaccia utente per selezionare un allegato!
+Cronologia completa: https://github.com/vector-im/element-android/releases
diff --git a/fastlane/metadata/android/it-IT/changelogs/40105070.txt b/fastlane/metadata/android/it-IT/changelogs/40105070.txt
new file mode 100644
index 0000000000..ec4d944d72
--- /dev/null
+++ b/fastlane/metadata/android/it-IT/changelogs/40105070.txt
@@ -0,0 +1,2 @@
+Modifiche principali in questa versione: nuova interfaccia utente per selezionare un allegato.
+Cronologia completa: https://github.com/vector-im/element-android/releases
diff --git a/fastlane/metadata/android/it-IT/changelogs/40105080.txt b/fastlane/metadata/android/it-IT/changelogs/40105080.txt
new file mode 100644
index 0000000000..a3d49ca1b7
--- /dev/null
+++ b/fastlane/metadata/android/it-IT/changelogs/40105080.txt
@@ -0,0 +1,2 @@
+Modifiche principali in questa versione: correzione di errori e miglioramenti.
+Cronologia completa: https://github.com/vector-im/element-android/releases
diff --git a/fastlane/metadata/android/pt-BR/changelogs/40105060.txt b/fastlane/metadata/android/pt-BR/changelogs/40105060.txt
new file mode 100644
index 0000000000..108a8a88b4
--- /dev/null
+++ b/fastlane/metadata/android/pt-BR/changelogs/40105060.txt
@@ -0,0 +1,2 @@
+Principais mudanças nesta versão: novo UI para selecionar um anexo.
+Changelog completo: https://github.com/vector-im/element-android/releases
diff --git a/fastlane/metadata/android/pt-BR/changelogs/40105070.txt b/fastlane/metadata/android/pt-BR/changelogs/40105070.txt
new file mode 100644
index 0000000000..108a8a88b4
--- /dev/null
+++ b/fastlane/metadata/android/pt-BR/changelogs/40105070.txt
@@ -0,0 +1,2 @@
+Principais mudanças nesta versão: novo UI para selecionar um anexo.
+Changelog completo: https://github.com/vector-im/element-android/releases
diff --git a/fastlane/metadata/android/pt-BR/changelogs/40105080.txt b/fastlane/metadata/android/pt-BR/changelogs/40105080.txt
new file mode 100644
index 0000000000..6e85b9ba6a
--- /dev/null
+++ b/fastlane/metadata/android/pt-BR/changelogs/40105080.txt
@@ -0,0 +1,2 @@
+Principais mudanças nesta versão: consertos de bugs e melhorias.
+Changelog completo: https://github.com/vector-im/element-android/releases
diff --git a/fastlane/metadata/android/ru-RU/changelogs/40104260.txt b/fastlane/metadata/android/ru-RU/changelogs/40104260.txt
new file mode 100644
index 0000000000..b023e07b3d
--- /dev/null
+++ b/fastlane/metadata/android/ru-RU/changelogs/40104260.txt
@@ -0,0 +1,2 @@
+Основные изменения в этой версии: Использование UnifiedPush и разрешение пользователям получать push-оповещения без FCM.
+Полный список изменений: https://github.com/vector-im/element-android/releases
diff --git a/fastlane/metadata/android/ru-RU/changelogs/40104270.txt b/fastlane/metadata/android/ru-RU/changelogs/40104270.txt
new file mode 100644
index 0000000000..ff4e5cdf15
--- /dev/null
+++ b/fastlane/metadata/android/ru-RU/changelogs/40104270.txt
@@ -0,0 +1,2 @@
+Основные изменения в этой версии: Исправления различных багов и улучшения стабильности работы.
+Полный список изменений: https://github.com/vector-im/element-android/releases
diff --git a/fastlane/metadata/android/ru-RU/changelogs/40104280.txt b/fastlane/metadata/android/ru-RU/changelogs/40104280.txt
new file mode 100644
index 0000000000..ff4e5cdf15
--- /dev/null
+++ b/fastlane/metadata/android/ru-RU/changelogs/40104280.txt
@@ -0,0 +1,2 @@
+Основные изменения в этой версии: Исправления различных багов и улучшения стабильности работы.
+Полный список изменений: https://github.com/vector-im/element-android/releases
diff --git a/fastlane/metadata/android/ru-RU/changelogs/40104300.txt b/fastlane/metadata/android/ru-RU/changelogs/40104300.txt
new file mode 100644
index 0000000000..aec45e0348
--- /dev/null
+++ b/fastlane/metadata/android/ru-RU/changelogs/40104300.txt
@@ -0,0 +1,2 @@
+Основные изменения в этой версии: Улучшены вход и регистрация
+Полный список изменений: https://github.com/vector-im/element-android/releases
diff --git a/fastlane/metadata/android/ru-RU/changelogs/40104310.txt b/fastlane/metadata/android/ru-RU/changelogs/40104310.txt
new file mode 100644
index 0000000000..aec45e0348
--- /dev/null
+++ b/fastlane/metadata/android/ru-RU/changelogs/40104310.txt
@@ -0,0 +1,2 @@
+Основные изменения в этой версии: Улучшены вход и регистрация
+Полный список изменений: https://github.com/vector-im/element-android/releases
diff --git a/fastlane/metadata/android/ru-RU/changelogs/40104320.txt b/fastlane/metadata/android/ru-RU/changelogs/40104320.txt
new file mode 100644
index 0000000000..d6c614f22b
--- /dev/null
+++ b/fastlane/metadata/android/ru-RU/changelogs/40104320.txt
@@ -0,0 +1,2 @@
+Основные изменения в этой версии: Исправления различных багов и улучшения стабильности работы
+Полный список изменений: https://github.com/vector-im/element-android/releases
diff --git a/fastlane/metadata/android/ru-RU/changelogs/40105000.txt b/fastlane/metadata/android/ru-RU/changelogs/40105000.txt
new file mode 100644
index 0000000000..93ea0aff68
--- /dev/null
+++ b/fastlane/metadata/android/ru-RU/changelogs/40105000.txt
@@ -0,0 +1,2 @@
+Основные изменения в этой версии: отложённые личные сообщения включены по умолчанию
+Полный список изменений: https://github.com/vector-im/element-android/releases
diff --git a/fastlane/metadata/android/ru-RU/changelogs/40105060.txt b/fastlane/metadata/android/ru-RU/changelogs/40105060.txt
new file mode 100644
index 0000000000..234d265dd8
--- /dev/null
+++ b/fastlane/metadata/android/ru-RU/changelogs/40105060.txt
@@ -0,0 +1,2 @@
+Основные изменения в этой версии: новый интерфейс для выбора прикреплённых файлов
+Полный список изменений: https://github.com/vector-im/element-android/releases
diff --git a/fastlane/metadata/android/ru-RU/changelogs/40105070.txt b/fastlane/metadata/android/ru-RU/changelogs/40105070.txt
new file mode 100644
index 0000000000..234d265dd8
--- /dev/null
+++ b/fastlane/metadata/android/ru-RU/changelogs/40105070.txt
@@ -0,0 +1,2 @@
+Основные изменения в этой версии: новый интерфейс для выбора прикреплённых файлов
+Полный список изменений: https://github.com/vector-im/element-android/releases
diff --git a/fastlane/metadata/android/sk/changelogs/40105060.txt b/fastlane/metadata/android/sk/changelogs/40105060.txt
new file mode 100644
index 0000000000..0d1d4965ca
--- /dev/null
+++ b/fastlane/metadata/android/sk/changelogs/40105060.txt
@@ -0,0 +1,2 @@
+Hlavné zmeny v tejto verzii: nové používateľské rozhranie na výber príloh.
+Úplný zoznam zmien: https://github.com/vector-im/element-android/releases
diff --git a/fastlane/metadata/android/sk/changelogs/40105070.txt b/fastlane/metadata/android/sk/changelogs/40105070.txt
new file mode 100644
index 0000000000..0d1d4965ca
--- /dev/null
+++ b/fastlane/metadata/android/sk/changelogs/40105070.txt
@@ -0,0 +1,2 @@
+Hlavné zmeny v tejto verzii: nové používateľské rozhranie na výber príloh.
+Úplný zoznam zmien: https://github.com/vector-im/element-android/releases
diff --git a/fastlane/metadata/android/sk/changelogs/40105080.txt b/fastlane/metadata/android/sk/changelogs/40105080.txt
new file mode 100644
index 0000000000..56daa3b4b7
--- /dev/null
+++ b/fastlane/metadata/android/sk/changelogs/40105080.txt
@@ -0,0 +1,2 @@
+Hlavné zmeny v tejto verzii: opravy chýb a vylepšenia.
+Úplný zoznam zmien: https://github.com/vector-im/element-android/releases
diff --git a/fastlane/metadata/android/sk/changelogs/40105100.txt b/fastlane/metadata/android/sk/changelogs/40105100.txt
new file mode 100644
index 0000000000..c286f155d4
--- /dev/null
+++ b/fastlane/metadata/android/sk/changelogs/40105100.txt
@@ -0,0 +1,2 @@
+Hlavné zmeny v tejto verzii: Nová implementácia celo-obrazovkového režimu pre Rozšírený textový editor a opravy chýb.
+Úplný zoznam zmien: https://github.com/vector-im/element-android/releases
diff --git a/fastlane/metadata/android/sq/changelogs/40104120.txt b/fastlane/metadata/android/sq/changelogs/40104120.txt
new file mode 100644
index 0000000000..f93220235b
--- /dev/null
+++ b/fastlane/metadata/android/sq/changelogs/40104120.txt
@@ -0,0 +1,2 @@
+Ndryshimet kryesore në këtë version: U lejon përdoruesve të shfaqen si jo në linjë dhe shton një lojtës audio për bashkëngjitje audio
+Regjistër i plotë ndryshimesh: https://github.com/vector-im/element-android/releases
diff --git a/fastlane/metadata/android/sq/changelogs/40104130.txt b/fastlane/metadata/android/sq/changelogs/40104130.txt
new file mode 100644
index 0000000000..f93220235b
--- /dev/null
+++ b/fastlane/metadata/android/sq/changelogs/40104130.txt
@@ -0,0 +1,2 @@
+Ndryshimet kryesore në këtë version: U lejon përdoruesve të shfaqen si jo në linjë dhe shton një lojtës audio për bashkëngjitje audio
+Regjistër i plotë ndryshimesh: https://github.com/vector-im/element-android/releases
diff --git a/fastlane/metadata/android/sq/changelogs/40104140.txt b/fastlane/metadata/android/sq/changelogs/40104140.txt
new file mode 100644
index 0000000000..c8b2eb09ab
--- /dev/null
+++ b/fastlane/metadata/android/sq/changelogs/40104140.txt
@@ -0,0 +1,2 @@
+Ndryshimet kryesore në këtë version: Përmirësim i administrimit të përdoruesve të shpërfillur. Ndreqje të metash dhe përmirësime të ndryshme qëndrueshmërie.
+Regjistër i plotë ndryshimesh: https://github.com/vector-im/element-android/releases
diff --git a/fastlane/metadata/android/sq/changelogs/40104160.txt b/fastlane/metadata/android/sq/changelogs/40104160.txt
new file mode 100644
index 0000000000..987197f0f6
--- /dev/null
+++ b/fastlane/metadata/android/sq/changelogs/40104160.txt
@@ -0,0 +1,2 @@
+Ndryshimet kryesore në këtë version: Administrim më i mirë i mesazheve të fshehtëzuar. Ndreqje të metash dhe përmirësime të ndryshme qëndrueshmërie.
+Regjistër i plotë ndryshimesh: https://github.com/vector-im/element-android/releases
diff --git a/fastlane/metadata/android/sq/changelogs/40104180.txt b/fastlane/metadata/android/sq/changelogs/40104180.txt
new file mode 100644
index 0000000000..87f801d1f4
--- /dev/null
+++ b/fastlane/metadata/android/sq/changelogs/40104180.txt
@@ -0,0 +1,2 @@
+Ndryshimet kryesore në këtë version: Ndreqje të metash dhe përmirësime të ndryshme qëndrueshmërie.
+Regjistër i plotë ndryshimesh: https://github.com/vector-im/element-android/releases
diff --git a/fastlane/metadata/android/sq/changelogs/40104190.txt b/fastlane/metadata/android/sq/changelogs/40104190.txt
new file mode 100644
index 0000000000..87f801d1f4
--- /dev/null
+++ b/fastlane/metadata/android/sq/changelogs/40104190.txt
@@ -0,0 +1,2 @@
+Ndryshimet kryesore në këtë version: Ndreqje të metash dhe përmirësime të ndryshme qëndrueshmërie.
+Regjistër i plotë ndryshimesh: https://github.com/vector-im/element-android/releases
diff --git a/fastlane/metadata/android/sq/changelogs/40104200.txt b/fastlane/metadata/android/sq/changelogs/40104200.txt
new file mode 100644
index 0000000000..87f801d1f4
--- /dev/null
+++ b/fastlane/metadata/android/sq/changelogs/40104200.txt
@@ -0,0 +1,2 @@
+Ndryshimet kryesore në këtë version: Ndreqje të metash dhe përmirësime të ndryshme qëndrueshmërie.
+Regjistër i plotë ndryshimesh: https://github.com/vector-im/element-android/releases
diff --git a/fastlane/metadata/android/sq/changelogs/40104220.txt b/fastlane/metadata/android/sq/changelogs/40104220.txt
new file mode 100644
index 0000000000..87f801d1f4
--- /dev/null
+++ b/fastlane/metadata/android/sq/changelogs/40104220.txt
@@ -0,0 +1,2 @@
+Ndryshimet kryesore në këtë version: Ndreqje të metash dhe përmirësime të ndryshme qëndrueshmërie.
+Regjistër i plotë ndryshimesh: https://github.com/vector-im/element-android/releases
diff --git a/fastlane/metadata/android/sq/changelogs/40104230.txt b/fastlane/metadata/android/sq/changelogs/40104230.txt
new file mode 100644
index 0000000000..87f801d1f4
--- /dev/null
+++ b/fastlane/metadata/android/sq/changelogs/40104230.txt
@@ -0,0 +1,2 @@
+Ndryshimet kryesore në këtë version: Ndreqje të metash dhe përmirësime të ndryshme qëndrueshmërie.
+Regjistër i plotë ndryshimesh: https://github.com/vector-im/element-android/releases
diff --git a/fastlane/metadata/android/sq/changelogs/40104240.txt b/fastlane/metadata/android/sq/changelogs/40104240.txt
new file mode 100644
index 0000000000..87f801d1f4
--- /dev/null
+++ b/fastlane/metadata/android/sq/changelogs/40104240.txt
@@ -0,0 +1,2 @@
+Ndryshimet kryesore në këtë version: Ndreqje të metash dhe përmirësime të ndryshme qëndrueshmërie.
+Regjistër i plotë ndryshimesh: https://github.com/vector-im/element-android/releases
diff --git a/fastlane/metadata/android/sq/changelogs/40104250.txt b/fastlane/metadata/android/sq/changelogs/40104250.txt
new file mode 100644
index 0000000000..87f801d1f4
--- /dev/null
+++ b/fastlane/metadata/android/sq/changelogs/40104250.txt
@@ -0,0 +1,2 @@
+Ndryshimet kryesore në këtë version: Ndreqje të metash dhe përmirësime të ndryshme qëndrueshmërie.
+Regjistër i plotë ndryshimesh: https://github.com/vector-im/element-android/releases
diff --git a/fastlane/metadata/android/sq/changelogs/40104260.txt b/fastlane/metadata/android/sq/changelogs/40104260.txt
new file mode 100644
index 0000000000..c5ffad38c9
--- /dev/null
+++ b/fastlane/metadata/android/sq/changelogs/40104260.txt
@@ -0,0 +1,2 @@
+Ndryshimet kryesore në këtë version: Përdorim i UnifiedPush dhe lejim i përdoruesve të kenë push pa FCM.
+Regjistër i plotë ndryshimesh: https://github.com/vector-im/element-android/releases
diff --git a/fastlane/metadata/android/sq/changelogs/40104270.txt b/fastlane/metadata/android/sq/changelogs/40104270.txt
new file mode 100644
index 0000000000..87f801d1f4
--- /dev/null
+++ b/fastlane/metadata/android/sq/changelogs/40104270.txt
@@ -0,0 +1,2 @@
+Ndryshimet kryesore në këtë version: Ndreqje të metash dhe përmirësime të ndryshme qëndrueshmërie.
+Regjistër i plotë ndryshimesh: https://github.com/vector-im/element-android/releases
diff --git a/fastlane/metadata/android/sq/changelogs/40104280.txt b/fastlane/metadata/android/sq/changelogs/40104280.txt
new file mode 100644
index 0000000000..87f801d1f4
--- /dev/null
+++ b/fastlane/metadata/android/sq/changelogs/40104280.txt
@@ -0,0 +1,2 @@
+Ndryshimet kryesore në këtë version: Ndreqje të metash dhe përmirësime të ndryshme qëndrueshmërie.
+Regjistër i plotë ndryshimesh: https://github.com/vector-im/element-android/releases
diff --git a/fastlane/metadata/android/sq/changelogs/40104300.txt b/fastlane/metadata/android/sq/changelogs/40104300.txt
new file mode 100644
index 0000000000..6c1be8f556
--- /dev/null
+++ b/fastlane/metadata/android/sq/changelogs/40104300.txt
@@ -0,0 +1,2 @@
+Ndryshimet kryesore në këtë version: Bërje e mundur hapash të përmirësuar hyrje dhe dalje nga llogaria.
+Regjistër i plotë ndryshimesh: https://github.com/vector-im/element-android/releases
diff --git a/fastlane/metadata/android/sq/changelogs/40104310.txt b/fastlane/metadata/android/sq/changelogs/40104310.txt
new file mode 100644
index 0000000000..6c1be8f556
--- /dev/null
+++ b/fastlane/metadata/android/sq/changelogs/40104310.txt
@@ -0,0 +1,2 @@
+Ndryshimet kryesore në këtë version: Bërje e mundur hapash të përmirësuar hyrje dhe dalje nga llogaria.
+Regjistër i plotë ndryshimesh: https://github.com/vector-im/element-android/releases
diff --git a/fastlane/metadata/android/sq/changelogs/40104320.txt b/fastlane/metadata/android/sq/changelogs/40104320.txt
new file mode 100644
index 0000000000..87f801d1f4
--- /dev/null
+++ b/fastlane/metadata/android/sq/changelogs/40104320.txt
@@ -0,0 +1,2 @@
+Ndryshimet kryesore në këtë version: Ndreqje të metash dhe përmirësime të ndryshme qëndrueshmërie.
+Regjistër i plotë ndryshimesh: https://github.com/vector-im/element-android/releases
diff --git a/fastlane/metadata/android/sq/changelogs/40104340.txt b/fastlane/metadata/android/sq/changelogs/40104340.txt
new file mode 100644
index 0000000000..87f801d1f4
--- /dev/null
+++ b/fastlane/metadata/android/sq/changelogs/40104340.txt
@@ -0,0 +1,2 @@
+Ndryshimet kryesore në këtë version: Ndreqje të metash dhe përmirësime të ndryshme qëndrueshmërie.
+Regjistër i plotë ndryshimesh: https://github.com/vector-im/element-android/releases
diff --git a/fastlane/metadata/android/sq/changelogs/40104360.txt b/fastlane/metadata/android/sq/changelogs/40104360.txt
new file mode 100644
index 0000000000..ef9251a497
--- /dev/null
+++ b/fastlane/metadata/android/sq/changelogs/40104360.txt
@@ -0,0 +1,3 @@
+Skema e re e Aplikacionit mund të aktivizohet që nga rregullimet Labs. Ju lutemi, provojeni!
+Ndreqje problemesh me njoftim që mungon dhe njëkohësim i gjatë shtues.
+Regjistër i plotë ndryshimesh: https://github.com/vector-im/element-android/releases
diff --git a/fastlane/metadata/android/sq/changelogs/40105000.txt b/fastlane/metadata/android/sq/changelogs/40105000.txt
new file mode 100644
index 0000000000..2ee2ded823
--- /dev/null
+++ b/fastlane/metadata/android/sq/changelogs/40105000.txt
@@ -0,0 +1,2 @@
+Ndryshimet kryesore në këtë version: Hedhje poshtë MD e aktivizuar, si parazgjedhje.
+Regjistër i plotë ndryshimesh: https://github.com/vector-im/element-android/releases
diff --git a/fastlane/metadata/android/sq/changelogs/40105020.txt b/fastlane/metadata/android/sq/changelogs/40105020.txt
new file mode 100644
index 0000000000..26647d519f
--- /dev/null
+++ b/fastlane/metadata/android/sq/changelogs/40105020.txt
@@ -0,0 +1,2 @@
+Ndryshimet kryesore në këtë version: Skema e re e aplikacionit e aktivizuar, si parazgjedhje!
+Regjistër i plotë ndryshimesh: https://github.com/vector-im/element-android/releases
diff --git a/fastlane/metadata/android/sq/changelogs/40105040.txt b/fastlane/metadata/android/sq/changelogs/40105040.txt
new file mode 100644
index 0000000000..4e38434f89
--- /dev/null
+++ b/fastlane/metadata/android/sq/changelogs/40105040.txt
@@ -0,0 +1,2 @@
+Ndryshimet kryesore në këtë version: Veçori të reja nën rregullimet Labs: hartues teksti të pasur, administrim i ri pajisjesh, transmetim zanor. Ende nën zhvillim aktivt!
+Regjistër i plotë ndryshimesh: https://github.com/vector-im/element-android/releases
diff --git a/fastlane/metadata/android/sq/changelogs/40105060.txt b/fastlane/metadata/android/sq/changelogs/40105060.txt
new file mode 100644
index 0000000000..eb300bafed
--- /dev/null
+++ b/fastlane/metadata/android/sq/changelogs/40105060.txt
@@ -0,0 +1,2 @@
+Ndryshimet kryesore në këtë version: ndërfaqe e re UI për përzgjedhjen e një bashkëngjitjeje!
+Regjistër i plotë ndryshimesh: https://github.com/vector-im/element-android/releases
diff --git a/fastlane/metadata/android/sq/changelogs/40105070.txt b/fastlane/metadata/android/sq/changelogs/40105070.txt
new file mode 100644
index 0000000000..f4beb912a5
--- /dev/null
+++ b/fastlane/metadata/android/sq/changelogs/40105070.txt
@@ -0,0 +1,2 @@
+Ndryshimet kryesore në këtë verson: ndërfaqe UI e re për përzgjedhje të një bashkëngjitjeje.
+Regjistër i plotë ndryshimesh: https://github.com/vector-im/element-android/releases
diff --git a/fastlane/metadata/android/sq/changelogs/40105080.txt b/fastlane/metadata/android/sq/changelogs/40105080.txt
new file mode 100644
index 0000000000..b059e86cbd
--- /dev/null
+++ b/fastlane/metadata/android/sq/changelogs/40105080.txt
@@ -0,0 +1,2 @@
+Ndryshimet kryesore në këtë version: ndreqje të metash dhe përmirësime.
+Regjistër i plotë ndryshimesh: https://github.com/vector-im/element-android/releases
diff --git a/fastlane/metadata/android/sv-SE/changelogs/40105060.txt b/fastlane/metadata/android/sv-SE/changelogs/40105060.txt
new file mode 100644
index 0000000000..d64984fcfb
--- /dev/null
+++ b/fastlane/metadata/android/sv-SE/changelogs/40105060.txt
@@ -0,0 +1,2 @@
+Huvudsakliga ändringar i den här versionen: nytt gränssnitt för val av bilaga.
+Full ändringslogg: https://github.com/vector-im/element-android/releases
diff --git a/fastlane/metadata/android/sv-SE/changelogs/40105070.txt b/fastlane/metadata/android/sv-SE/changelogs/40105070.txt
new file mode 100644
index 0000000000..d64984fcfb
--- /dev/null
+++ b/fastlane/metadata/android/sv-SE/changelogs/40105070.txt
@@ -0,0 +1,2 @@
+Huvudsakliga ändringar i den här versionen: nytt gränssnitt för val av bilaga.
+Full ändringslogg: https://github.com/vector-im/element-android/releases
diff --git a/fastlane/metadata/android/sv-SE/changelogs/40105080.txt b/fastlane/metadata/android/sv-SE/changelogs/40105080.txt
new file mode 100644
index 0000000000..cee589ed35
--- /dev/null
+++ b/fastlane/metadata/android/sv-SE/changelogs/40105080.txt
@@ -0,0 +1,2 @@
+Huvudsakliga ändringar i den här versionen: buggfixar och förbättringar.
+Full ändringslogg: https://github.com/vector-im/element-android/releases
diff --git a/fastlane/metadata/android/uk/changelogs/40105040.txt b/fastlane/metadata/android/uk/changelogs/40105040.txt
index bbc005f84c..b3327f68ab 100644
--- a/fastlane/metadata/android/uk/changelogs/40105040.txt
+++ b/fastlane/metadata/android/uk/changelogs/40105040.txt
@@ -1,2 +1,2 @@
-Основні зміни в цій версії: Нові можливості в налаштуваннях лабораторії: Текстовий редактор, нове керування пристроями, голосові повідомлення. Досі в активній розробці!
+Основні зміни в цій версії: Нові можливості в налаштуваннях лабораторії: Текстовий редактор, нове керування пристроями, голосові трансляції. Досі в активній розробці!
 Список усіх змін: https://github.com/vector-im/element-android/releases
diff --git a/fastlane/metadata/android/uk/changelogs/40105060.txt b/fastlane/metadata/android/uk/changelogs/40105060.txt
new file mode 100644
index 0000000000..4be635901f
--- /dev/null
+++ b/fastlane/metadata/android/uk/changelogs/40105060.txt
@@ -0,0 +1,2 @@
+Основні зміни в цій версії: новий інтерфейс для вибору вкладення.
+Перелік усіх змін: https://github.com/vector-im/element-android/releases
diff --git a/fastlane/metadata/android/uk/changelogs/40105070.txt b/fastlane/metadata/android/uk/changelogs/40105070.txt
new file mode 100644
index 0000000000..65254059c5
--- /dev/null
+++ b/fastlane/metadata/android/uk/changelogs/40105070.txt
@@ -0,0 +1,2 @@
+Основні зміни в цій версії: новий інтерфейс для вибору вкладень.
+Перелік усіх змін: https://github.com/vector-im/element-android/releases
diff --git a/fastlane/metadata/android/uk/changelogs/40105080.txt b/fastlane/metadata/android/uk/changelogs/40105080.txt
new file mode 100644
index 0000000000..e6f6384a5f
--- /dev/null
+++ b/fastlane/metadata/android/uk/changelogs/40105080.txt
@@ -0,0 +1,2 @@
+Основні зміни в цій версії: усування вад і вдосконалення.
+Перелік усіх змін: https://github.com/vector-im/element-android/releases
diff --git a/fastlane/metadata/android/uk/changelogs/40105100.txt b/fastlane/metadata/android/uk/changelogs/40105100.txt
new file mode 100644
index 0000000000..6bb3ab95c7
--- /dev/null
+++ b/fastlane/metadata/android/uk/changelogs/40105100.txt
@@ -0,0 +1,2 @@
+Основні зміни в цій версії: Нова реалізація повноекранного режиму для редактора розширеного тексту та виправлення помилок.
+Перелік усіх змін: https://github.com/vector-im/element-android/releases
diff --git a/fastlane/metadata/android/zh-TW/changelogs/40105060.txt b/fastlane/metadata/android/zh-TW/changelogs/40105060.txt
new file mode 100644
index 0000000000..56667ccfc0
--- /dev/null
+++ b/fastlane/metadata/android/zh-TW/changelogs/40105060.txt
@@ -0,0 +1,2 @@
+此版本中的主要變動:選取附件的新使用者介面。
+完整的變更紀錄:https://github.com/vector-im/element-android/releases
diff --git a/fastlane/metadata/android/zh-TW/changelogs/40105070.txt b/fastlane/metadata/android/zh-TW/changelogs/40105070.txt
new file mode 100644
index 0000000000..56667ccfc0
--- /dev/null
+++ b/fastlane/metadata/android/zh-TW/changelogs/40105070.txt
@@ -0,0 +1,2 @@
+此版本中的主要變動:選取附件的新使用者介面。
+完整的變更紀錄:https://github.com/vector-im/element-android/releases
diff --git a/fastlane/metadata/android/zh-TW/changelogs/40105080.txt b/fastlane/metadata/android/zh-TW/changelogs/40105080.txt
new file mode 100644
index 0000000000..2a368ec8be
--- /dev/null
+++ b/fastlane/metadata/android/zh-TW/changelogs/40105080.txt
@@ -0,0 +1,2 @@
+此版本中的主要變動:臭蟲修復與改善。
+完整的變更紀錄:https://github.com/vector-im/element-android/releases
diff --git a/fastlane/metadata/android/zh-TW/changelogs/40105100.txt b/fastlane/metadata/android/zh-TW/changelogs/40105100.txt
new file mode 100644
index 0000000000..20341b84fe
--- /dev/null
+++ b/fastlane/metadata/android/zh-TW/changelogs/40105100.txt
@@ -0,0 +1,2 @@
+此版本中的主要變動:格式化文字編輯器的全螢幕模式新實作與臭蟲修復。
+完整的變更紀錄:https://github.com/vector-im/element-android/releases
diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar
index 249e5832f0..943f0cbfa7 100644
Binary files a/gradle/wrapper/gradle-wrapper.jar and b/gradle/wrapper/gradle-wrapper.jar differ
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index f7189a776c..bc073f6761 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,6 +1,7 @@
 distributionBase=GRADLE_USER_HOME
 distributionPath=wrapper/dists
-distributionSha256Sum=db9c8211ed63f61f60292c69e80d89196f9eb36665e369e7f00ac4cc841c2219
-distributionUrl=https\://services.gradle.org/distributions/gradle-7.5.1-all.zip
+distributionSha256Sum=312eb12875e1747e05c2f81a4789902d7e4ec5defbd1eefeaccc08acf096505d
+distributionUrl=https\://services.gradle.org/distributions/gradle-7.6-all.zip
+networkTimeout=10000
 zipStoreBase=GRADLE_USER_HOME
 zipStorePath=wrapper/dists
diff --git a/gradlew b/gradlew
index a69d9cb6c2..65dcd68d65 100755
--- a/gradlew
+++ b/gradlew
@@ -55,7 +55,7 @@
 #       Darwin, MinGW, and NonStop.
 #
 #   (3) This script is generated from the Groovy template
-#       https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
+#       https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
 #       within the Gradle project.
 #
 #       You can find Gradle at https://github.com/gradle/gradle/.
@@ -80,10 +80,10 @@ do
     esac
 done
 
-APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
-
-APP_NAME="Gradle"
+# This is normally unused
+# shellcheck disable=SC2034
 APP_BASE_NAME=${0##*/}
+APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit
 
 # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
 DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
@@ -143,12 +143,16 @@ fi
 if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
     case $MAX_FD in #(
       max*)
+        # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
+        # shellcheck disable=SC3045 
         MAX_FD=$( ulimit -H -n ) ||
             warn "Could not query maximum file descriptor limit"
     esac
     case $MAX_FD in  #(
       '' | soft) :;; #(
       *)
+        # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
+        # shellcheck disable=SC3045 
         ulimit -n "$MAX_FD" ||
             warn "Could not set maximum file descriptor limit to $MAX_FD"
     esac
diff --git a/gradlew.bat b/gradlew.bat
index 53a6b238d4..6689b85bee 100644
--- a/gradlew.bat
+++ b/gradlew.bat
@@ -26,6 +26,7 @@ if "%OS%"=="Windows_NT" setlocal
 
 set DIRNAME=%~dp0
 if "%DIRNAME%"=="" set DIRNAME=.
+@rem This is normally unused
 set APP_BASE_NAME=%~n0
 set APP_HOME=%DIRNAME%
 
diff --git a/library/attachment-viewer/src/main/java/im/vector/lib/attachmentviewer/VideoViewHolder.kt b/library/attachment-viewer/src/main/java/im/vector/lib/attachmentviewer/VideoViewHolder.kt
index 92d28d26c9..07c7b4588f 100644
--- a/library/attachment-viewer/src/main/java/im/vector/lib/attachmentviewer/VideoViewHolder.kt
+++ b/library/attachment-viewer/src/main/java/im/vector/lib/attachmentviewer/VideoViewHolder.kt
@@ -103,14 +103,12 @@ class VideoViewHolder constructor(itemView: View) :
         views.videoView.setOnPreparedListener {
             stopTimer()
             countUpTimer = CountUpTimer(100).also {
-                it.tickListener = object : CountUpTimer.TickListener {
-                    override fun onTick(milliseconds: Long) {
-                        val duration = views.videoView.duration
-                        val progress = views.videoView.currentPosition
-                        val isPlaying = views.videoView.isPlaying
-//                        Log.v("FOO", "isPlaying $isPlaying $progress/$duration")
-                        eventListener?.get()?.onEvent(AttachmentEvents.VideoEvent(isPlaying, progress, duration))
-                    }
+                it.tickListener = CountUpTimer.TickListener {
+                    val duration = views.videoView.duration
+                    val progress = views.videoView.currentPosition
+                    val isPlaying = views.videoView.isPlaying
+                    //                        Log.v("FOO", "isPlaying $isPlaying $progress/$duration")
+                    eventListener?.get()?.onEvent(AttachmentEvents.VideoEvent(isPlaying, progress, duration))
                 }
                 it.resume()
             }
diff --git a/library/core-utils/src/main/java/im/vector/lib/core/utils/timer/CountUpTimer.kt b/library/core-utils/src/main/java/im/vector/lib/core/utils/timer/CountUpTimer.kt
index e9d311fe03..a4fd8bb4e1 100644
--- a/library/core-utils/src/main/java/im/vector/lib/core/utils/timer/CountUpTimer.kt
+++ b/library/core-utils/src/main/java/im/vector/lib/core/utils/timer/CountUpTimer.kt
@@ -66,7 +66,7 @@ class CountUpTimer(private val intervalInMs: Long = 1_000) {
         coroutineScope.cancel()
     }
 
-    interface TickListener {
+    fun interface TickListener {
         fun onTick(milliseconds: Long)
     }
 }
diff --git a/library/ui-strings/src/main/res/values-ar/strings.xml b/library/ui-strings/src/main/res/values-ar/strings.xml
index 70b9a33ab5..a49ecc3d08 100644
--- a/library/ui-strings/src/main/res/values-ar/strings.xml
+++ b/library/ui-strings/src/main/res/values-ar/strings.xml
@@ -1167,4 +1167,12 @@
     البريد الإلكتروني
     كلمة السر الجديدة
     التالي
-
+    
+        صفر
+        واحد
+        اثنان
+        قليلة
+        كثيرة
+        اخرى
+    
+
\ No newline at end of file
diff --git a/library/ui-strings/src/main/res/values-az/strings.xml b/library/ui-strings/src/main/res/values-az/strings.xml
index 044ecf900c..6fe322bdd0 100644
--- a/library/ui-strings/src/main/res/values-az/strings.xml
+++ b/library/ui-strings/src/main/res/values-az/strings.xml
@@ -20,7 +20,7 @@
     %s səsli zəng etdi.
     %s zəngə cavab verdi.
     %s zəng başa çatdı.
-    "%1$s gələcək otaq tarixçəsini %2$s-ə  görünən etdi"
+    %1$s gələcək otaq tarixçəsini %2$s-ə görünən etdi
     bütün otaq üzvləri, dəvət olunduğu andan.
     bütün otaq üzvləri, qoşulduğu andan.
     bütün otaq üzvləri.
@@ -48,8 +48,9 @@
 \nKriptografiyanın idxalı
     İlkin sinxronizasiya:
 \nOtaqlar idxalı
-    İlkin sinxronizasiya:
-\nOtaqlara daxil olmaq
+    İlkin sinxronizasiya: 
+\nSöhbətləriniz yüklənilir
+\nƏgər çoxlu otaqlara qoşulmusunuzsa, bu, bir az vaxt apara bilər
     İlkin sinxronizasiya:
 \nDəvət olunmuş otaqların idxalı
     İlkin sinxronizasiya:
@@ -133,4 +134,6 @@
     Otağa qoşulmaq üçün %1$s-a dəvət göndərdiniz
     %s, bu otaq üçün server ACL-lərini dəyişdi.
     • %s ilə uyğunlaşan serverlərə icazə verildi.
+    Siz %1$s üçün otağa qoşulmaq dəvətin ləğv etdiniz
+    %1$s-ı dəvət etdiniz
 
\ No newline at end of file
diff --git a/library/ui-strings/src/main/res/values-ca/strings.xml b/library/ui-strings/src/main/res/values-ca/strings.xml
index ce786fb87d..7e3e019ee5 100644
--- a/library/ui-strings/src/main/res/values-ca/strings.xml
+++ b/library/ui-strings/src/main/res/values-ca/strings.xml
@@ -2836,4 +2836,7 @@
     Adjunts
     Adhesius
     Galeria
+    Format de text
+    Enrere 30 segons
+    Avança 30 segons
 
\ No newline at end of file
diff --git a/library/ui-strings/src/main/res/values-cs/strings.xml b/library/ui-strings/src/main/res/values-cs/strings.xml
index 53599adce2..67cc3353aa 100644
--- a/library/ui-strings/src/main/res/values-cs/strings.xml
+++ b/library/ui-strings/src/main/res/values-cs/strings.xml
@@ -2789,7 +2789,7 @@
     Pravost této šifrované zprávy nelze v tomto zařízení zaručit.
     Požadujte, aby klávesnice neaktualizovala žádné personalizované údaje, jako je historie psaní a slovník, na základě toho, co jste napsali v konverzacích. Upozorňujeme, že některé klávesnice nemusí toto nastavení respektovat.
     Inkognito klávesnice
-    Přidá znaky (╯°□°)╯︵ ┻━┻ před zprávy ve formátu obyčejného textu
+    Přidá znaky (╯°□°)╯︵ ┻━┻ před zprávy ve formátu prostého textu
     Hlasové vysílání
     Otevřít nástroje pro vývojáře
     🔒 V nastavení zabezpečení jste povolili šifrování pouze do ověřených relací pro všechny místnosti.
@@ -2824,8 +2824,8 @@
     ${app_name} potřebuje oprávnění k zobrazování oznámení. Oznámení mohou zobrazovat vaše zprávy, pozvánky atd.
 \n
 \nPro zobrazování oznámení povolte přístup na dalších vyskakovacích oknech.
-    Vyzkoušejte rozšířený textový editor (textový režim již brzy)
-    Povolit rozšířený textový editor
+    Vyzkoušejte editor formátovaného textu (režim prostého textu již brzy)
+    Povolit editor formátovaného textu
     Ujistěte se, že znáte původ tohoto kódu. Propojením zařízení poskytnete někomu plný přístup ke svému účtu.
     Potvrdit
     Zkuste to znovu
@@ -2868,7 +2868,7 @@
     Druhé zařízení je již přihlášeno.
     Při nastavování zabezpečeného zasílání zpráv se vyskytl problém se zabezpečením. Může být napadena jedna z následujících věcí: váš domovský server; vaše internetové připojení; vaše zařízení;
     Žádost se nezdařila.
-    Ukládání do vyrovnávací paměti
+    Ukládání do vyrovnávací paměti…
     Pozastavit hlasové vysílání
     Přehrát nebo obnovit hlasové vysílání
     Ukončit záznam hlasového vysílání
@@ -2891,4 +2891,37 @@
         %1$d vybrané
         %1$d vybraných
     
+    Přepnutí režimu celé obrazovky
+    Formátování textu
+    Již nahráváte hlasové vysílání. Ukončete prosím aktuální hlasové vysílání a zahajte nové.
+    Hlasové vysílání už nahrává někdo jiný. Počkejte, až jeho hlasové vysílání skončí, a zahajte nové.
+    Nemáte potřebná oprávnění k zahájení hlasového vysílání v této místnosti. Obraťte se na správce místnosti, aby vám zvýšil oprávnění.
+    Nelze zahájit nové hlasové vysílání
+    Přetočení o 30 sekund zpět
+    Přetočení o 30 sekund dopředu
+    Ověřené relace jsou všude tam, kde tento účet používáte po zadání přístupové fráze nebo po potvrzení své totožnosti jinou ověřenou relací.
+\n
+\nTo znamená, že máte všechny klíče potřebné k odemknutí zašifrovaných zpráv a potvrzení ostatním uživatelům, že této relaci důvěřujete.
+    
+        Odhlásit se z %1$d relace
+        Odhlásit se ze %1$d relací
+        Odhlásit se z %1$d relací
+    
+    Odhlásit se
+    zbývá %1$s
+    vytvořil hlasování.
+    poslal nálepku.
+    poslal video.
+    poslal obrázek.
+    poslal hlasovou zprávu.
+    poslal zvukový soubor.
+    odeslal soubor.
+    V odpovědi na
+    Skrýt IP adresu
+    Zobrazit IP adresu
+    Citace
+    Odpovídám na %s
+    Úpravy
+    Zobrazit poslední chaty v nabídce sdílení systému
+    Povolit přímé sdílení
 
\ No newline at end of file
diff --git a/library/ui-strings/src/main/res/values-de/strings.xml b/library/ui-strings/src/main/res/values-de/strings.xml
index 409fc564f4..809ee477fc 100644
--- a/library/ui-strings/src/main/res/values-de/strings.xml
+++ b/library/ui-strings/src/main/res/values-de/strings.xml
@@ -2232,13 +2232,13 @@
         %1$s weitere Optionen benötigt
     
     Frage darf nicht leer sein
-    ABSTIMMUNG ERSTELLEN
+    Umfrage erstellen
     NEUE OPTION
     Option %1$d
     Optionen hinzufügen
     Frage oder Thema
     Abstimmungsthema oder Frage
-    Abstimmung erstellen
+    Umfrage erstellen
     Umfrage
     Auffindungseinstellungen öffnen
     Sitzung abgemeldet!
@@ -2306,21 +2306,21 @@
     Richtlinie deines Identitäts-Servers
     Richtlinie deines Heim-Servers
     Richtlinie von ${app_name}
-    Abstimmung erstellen
+    Umfrage erstellen
     Kontakte öffnen
     Sticker verschicken
     Datei hochladen
     Verschicke Fotos und Videos
     Kamera öffnen
-    Willst du diese Abstimmung wirklich entfernen\? Du wirst sie nicht wiederherstellen können.
-    Abstimmung entfernen
-    Abstimmung beendet
+    Willst du diese Umfrage wirklich entfernen\? Du wirst sie nicht wiederherstellen können.
+    Umfrage entfernen
+    Umfrage beendet
     Stimme abgegeben
-    Abstimmung beenden
+    Umfrage beenden
     Dies verhindert, dass andere Personen abstimmen können, und zeigt die Endergebnisse der Umfrage an.
-    Diese Abstimmung beenden\?
+    Diese Umfrage beenden\?
     Gewinneroption
-    Abstimmung beenden
+    Umfrage beenden
     
         Endgültiges Ergebnis basiert auf %1$d Stimme
         Endgültiges Ergebnis basiert auf %1$d Stimmen
@@ -2333,11 +2333,11 @@
     ${app_name} konnte nicht auf deinen Standort zugreifen
     Standort
     Die Ergebnisse werden erst sichtbar, sobald du die Umfrage beendest
-    Abgeschlossene Abstimmung
+    Versteckte Umfrage
     Abstimmende können die Ergebnisse nach Stimmabgabe sehen
-    Laufende Abstimmung
-    Abstimmungsart
-    Abstimmung bearbeiten
+    Offene Umfrage
+    Umfragetyp
+    Umfrage bearbeiten
     Keine Stimmen abgegeben
     Konto erstellen
     Kommunikation für dein Team.
@@ -2527,7 +2527,7 @@
     Server-Richtlinien
     Folge den Anweisungen, die an %s gesendet wurden
     E-Mail bestätigen
-    Ergebnisse werden nach Abschluss der Abstimmung sichtbar sein
+    Ergebnisse werden nach Abschluss der Umfrage sichtbar sein
     Prüfe deine E-Mails.
     Passwort zurücksetzen
     Gib mindestens 8 Zeichen ein.
@@ -2815,7 +2815,7 @@
     Die Anfrage ist fehlgeschlagen.
     Abspielen oder fortsetzen der Sprachübertragung
     Fortsetzen der Sprachübertragung
-    Puffere
+    Puffere …
     Pausiere Sprachübertragung
     Stoppe Aufzeichnung der Sprachübertragung
     Pausiere Aufzeichnung der Sprachübertragung
@@ -2835,4 +2835,36 @@
         %1$d ausgewählt
         %1$d ausgewählt
     
+    Du hast nicht die nötigen Berechtigungen, um eine Sprachübertragung in diesem Raum zu starten. Kontaktiere einen Raumadministrator, um deine Berechtigungen anzupassen.
+    Sprachübertragung kann nicht gestartet werden
+    Vollbildmodus umschalten
+    Textformatierung
+    Du zeichnest bereits eine Sprachübertragung auf. Bitte beende die laufende Übertragung, um eine neue zu beginnen.
+    Jemand anderes nimmt bereits eine Sprachübertragung auf. Warte auf das Ende der Übertragung, bevor du eine neue startest.
+    30 Sekunden vorspulen
+    30 Sekunden zurückspulen
+    Auf verifizierte Sitzungen kannst du überall mit deinem Konto zugreifen, wenn du deine Passphrase eingegeben oder Element mit einer anderen Sitzung verifiziert hast.
+\n
+\nDies bedeutet, dass du alle Schlüssel zum Entsperren deiner verschlüsselten Nachrichten hast und anderen bestätigst, dieser Sitzung zu vertrauen.
+    
+        Von %1$d Sitzung abmelden
+        Von %1$d Sitzungen abmelden
+    
+    Abmelden
+    %1$s übrig
+    Zitieren
+    Bearbeiten
+    erstellte eine Umfrage.
+    sandte einen Sticker.
+    sandte ein Video.
+    sandte ein Bild.
+    sandte eine Sprachnachricht.
+    sandte eine Audiodatei.
+    sandte eine Datei.
+    Als Antwort auf
+    %s antworten
+    IP-Adresse ausblenden
+    IP-Adresse anzeigen
+    Kürzliche Unterhaltungen im Teilen-Menü des Systems anzeigen
+    Direktes Teilen aktivieren
 
\ No newline at end of file
diff --git a/library/ui-strings/src/main/res/values-es/strings.xml b/library/ui-strings/src/main/res/values-es/strings.xml
index f73c4952c6..3d10997233 100644
--- a/library/ui-strings/src/main/res/values-es/strings.xml
+++ b/library/ui-strings/src/main/res/values-es/strings.xml
@@ -2649,4 +2649,10 @@
     Crear sala
     Iniciar conversación
     Todas las conversaciones
-
+    Seleccionar todo
+    De acuerdo
+    
+        %1$d seleccionado
+        %1$d seleccionados
+    
+
\ No newline at end of file
diff --git a/library/ui-strings/src/main/res/values-et/strings.xml b/library/ui-strings/src/main/res/values-et/strings.xml
index 9bfbbe8eeb..96d9650ceb 100644
--- a/library/ui-strings/src/main/res/values-et/strings.xml
+++ b/library/ui-strings/src/main/res/values-et/strings.xml
@@ -2805,7 +2805,7 @@
     Teine seade on juba võrku loginud.
     Turvalise sõnumivahetuse ülesseadmisel tekkis turvaviga. Üks kolmest võib olla sattunud vale osapoole kontrolli alla: sinu koduserver, sinu internetiühendus või sinu seade;
     Päring ei õnnestunud.
-    Andmed on puhverdamisel
+    Andmed on puhverdamisel…
     Alusta või jätka ringhäälingukõne esitamist
     Lõpeta ringhäälingukõne salvestamine
     Peata ringhäälingukõne salvestamine
@@ -2827,4 +2827,36 @@
         %1$d valitud
         %1$d valitud
     
+    Lülita täisekraanivaade sisse/välja
+    Tekstivorming
+    Sa juba salvestad ringhäälingukõnet. Uue alustamiseks palun lõpeta eelmine salvestus.
+    Keegi juba salvestab ringhäälingukõnet. Uue ringhäälingukõne salvestamiseks palun oota, kuni see teine ringhäälingukõne on lõppenud.
+    Sul pole piisavalt õigusi selles jututoas ringhäälingukõne algatamiseks. Õiguste lisamiseks palun võta ühendust jututoa haldajaga.
+    Uue ringhäälingukõne alustamine pole võimalik
+    Keri tagasi 30 sekundi kaupa
+    Keri edasi 30 sekundi kaupa
+    Verifitseeritud sessioonideks loetakse Element\'is või mõnes muus Matrix\'i rakenduses selliseid sessioone, kus sa kas oled sisestanud oma salafraasi või tuvastanud end mõne teise oma verifitseeritud sessiooni abil.
+\n
+\nSee tähendab, et selles sessioonis on ka kõik vajalikud võtmed krüptitud sõnumite lugemiseks ja teistele kasutajatele kinnitamiseks, et sa usaldad seda sessiooni.
+    
+        Logi välja %1$d\'st sessioonist
+        Logi välja %1$d\'st sessioonist
+    
+    Logi välja
+    jäänud %1$s
+    Muudan sõnumit
+    Vastan sõnumile %s
+    Tsiteerides
+    Näita IP-aadressi
+    Peida IP-aadress
+    Vastuseks kasutajale
+    saatis faili.
+    saatis helifaili.
+    saatis häälsõnumi.
+    saatis pildi.
+    saatis video.
+    saatis kleepsu.
+    koostas küsitluse.
+    Kasuta otsejagamist
+    Näita viimaseid vestlusi süsteemses jagamisvaates
 
\ No newline at end of file
diff --git a/library/ui-strings/src/main/res/values-fa/strings.xml b/library/ui-strings/src/main/res/values-fa/strings.xml
index f2701519e7..a3a74df10f 100644
--- a/library/ui-strings/src/main/res/values-fa/strings.xml
+++ b/library/ui-strings/src/main/res/values-fa/strings.xml
@@ -943,7 +943,7 @@
 \n
 \nپیام‌هایتان با قفل‌هایی امن شده‌اند و فقط شما و گیرندگان دیگر، کلیدهای یکتا را برای قفل‌گشاییشان دارید.
     امنیت
-    بثیش‌تر بدانید
+    بیش‌تر بدانید
     بیش‌تر
     کنش‌های مدیر
     تنظمیات اتاق
@@ -2783,7 +2783,7 @@
     نظرسنجی‌ها
     پیوست‌ها
     برچسب‌ها
-    میانگیری
+    میانگیری…
     زنده
     تأیید
     ۳
@@ -2802,4 +2802,51 @@
         ۱ گزیده
         %1$d گزیده
     
+    اجازه‌های لازم برای آغاز پخش صوتی در این اتاق را ندارید. برای ارتقای اجازه‌هایتان با یک مدیر اتاق تماس بگیرید.
+    فرد دیگری در حال ضبط یک پخش صوتی است. برای آغاز یک پخش جدید، منتظر پایان پخشش بمانید.
+    با بررسی افزاره‌های وارد شده‌تان باید کد زیر را ببینید. تأیید کنید که این کد با آن افزاره مطابق است:
+    دارید یک پخش صوتی ضبط می‌کنید. لطفاً برای آغاز یک پخش جدید، به پخش کنونی پایان دهید.
+    ⚠ افزاره‌های تأییدنشده‌ای در این اتاق وجود دارند. آن‌ها قادر به رمزگشایی پیام‌هایی که فرستاده‌اید نیستند.
+    استفاده از دوربین روی این افزاره برای پویش کد QR نشان داده شده روی افزارهٔ دیگرتان:
+    ضبط نام کارخواه، نگارش و نشانی برای بازشناسی آسان‌تر نشست‌ها در مدیر نشست.
+    🔒 رمزگذاری به نشست‌های تأیید شده را فقط برای تمامی اتاق‌ها در تنظیمات امنیت به کار انداخته‌اید.
+    
+        خارج شدن از نشست‌های قدیمی (۱ روز یا بیش‌تر) که دیگر استفاده نمی‌کنید را در نظر داشته باشید.
+        خارج شدن از نشست‌های قدیمی (%1$d روز یا بیش‌تر) که دیگر استفاده نمی‌کنید را در نظر داشته باشید.
+    
+    توانایی ضبط و فرستادن پخش صدا در خط زمانی اتاق.
+    پویش کد QR زیر با افزاره‌ای که خارج شده.
+    استفاده از افزارهٔ وارد شده‌تان برای پویش کد QR زیر:
+    چیزی اشتباه پیش رفت. لطفاً اتّصال شبکه‌تان را بررسی و دوباره تلاش کنید.
+    ${app_name} برای نمایش آگاهی‌ها نیازمند اجازه است.
+\nلطفاً اجازه را اعطا کنید.
+    نمی‌توان پخش صدایی جدید را آغاز کرد
+    تغییر حالت تمام‌صفحه
+    ۳۰ ثانیه پیش‌روی
+    ۳۰ ثانیه پس‌روی
+    قالب‌بندی متن
+    خروج
+    
+        خروج از ۱ نشست
+        خروج از %1$d نشست
+    
+    %1$s مانده
+    پیام صوتی‌ای فرستاد.
+    پروندهٔ صوتی‌ای فرستاد.
+    نظرسنجی‌ای ایجاد کرد.
+    عکس‌برگردانی فرستاد.
+    ویدیویی فرستاد.
+    تصویری فرستاد.
+    پرونده‌ای فرستاد.
+    در پاسخ به
+    نهفتن نشانی آی‌پی
+    نمایش نشانی آی‌پی
+    نقل کردن
+    پاسخ دادن به %s
+    ویرایش کردن
+    می‌توانید با یک رمز QR از این افزاره برای ورود به افزاره‌ای همراه یا روی وب استفاده کنید. دو راه برای این کار وجود دارد:
+    مشکلی امنیتی در برپایی پیام‌رسانی امن وجود داشت. ممکن است یکی از موارد زیر دستکاری شده باشند: کارساز خانیگیتان؛ اتّصال اینترنتیتان؛ افزاره(های)تان؛
+    لطفاً مطمئن شوید که مبدأ این کد را می‌دانید. با پیوند دادن افزاره‌ها، دسترسی کامل را به حسابتان می‌دهید.
+    نمایش گپ‌های اخیر در فهرست هم رسانی سامانه
+    به کار انداختن هم‌رسانی مستقیم
 
\ No newline at end of file
diff --git a/library/ui-strings/src/main/res/values-fr/strings.xml b/library/ui-strings/src/main/res/values-fr/strings.xml
index 3fae312560..44ad36a869 100644
--- a/library/ui-strings/src/main/res/values-fr/strings.xml
+++ b/library/ui-strings/src/main/res/values-fr/strings.xml
@@ -682,9 +682,10 @@
     La phrase secrète est trop faible
     Veuillez supprimer la phrase secrète si vous voulez que ${app_name} génère une clé de récupération.
     Ne perdez jamais vos messages chiffrés
-    Les messages dans les salons chiffrés sont sécurisés avec un chiffrement de bout en bout. Seuls vous et le(s) destinataire(s) avez les clés pour lire ces messages.
-\n
-\nSauvegardez vos clés de façon sécurisée pour éviter de les perdre.
+    Protection afin d\'éviter de perdre l\'accès aux messages et données chiffrés.
+
+    Générez une clé de sécurité à stocker dans un endroit sûr comme un gestionnaire de mot de passe ou un coffre.
+     
     Définir la phrase secrète
     Terminé
     Sauvegarder la clé de récupération
@@ -743,7 +744,7 @@
     Voulez-vous vraiment vous déconnecter ?
     Récupération des messages chiffrés
     Veuillez renseigner un nom d’utilisateur.
-    Commencer à utiliser la sauvegarde de clés
+    Utiliser une clé de sauvegarde Tchap 
     (Avancé)
     Exporter les clés manuellement
     Protégez votre sauvegarde avec une phrase secrète.
@@ -754,7 +755,7 @@
     Ou protégez votre sauvegarde avec une clé de récupération, en la conservant en lieu sûr.
     (Avancé) Configurer une clé de récupération
     Bien joué !
-    Vos clés sont en cours de sauvegarde.
+    Stockez votre clé dans un endroit sûr. Elle peut être utilisée pour dévérouiller vos messages et données chiffrés.
     Votre clé de récupération est une mesure de précaution. Vous pouvez l’utiliser pour restaurer l’accès à vos messages chiffrés si vous oubliez votre phrase secrète.
 \nConservez votre clé de récupération en lieu sûr, comme un gestionnaire de mots de passe (ou un coffre-fort)
     Conservez votre clé de récupération dans un endroit sûr, comme un gestionnaire de mots de passe (ou un coffre-fort)
@@ -2814,7 +2815,7 @@
     Vous pouvez utiliser cet appareil pour connecter un appareil mobile ou un client web avec un QR code. Il y a deux façons de le faire :
     Se connecter avec un QR code
     Scanner le QR code
-    Mise en mémoire tampon
+    Mise en mémoire tampon…
     Mettre en pause la diffusion audio
     Lire ou continuer la diffusion audio
     Arrêter l’enregistrement de la diffusion audio
@@ -2836,4 +2837,36 @@
         %1$d sélectionné
         %1$d sélectionnés
     
+    Basculer en mode plein écran
+    Formatage de texte
+    Vous êtes déjà en train de réaliser une diffusion audio. Veuillez terminer votre diffusion audio actuelle pour en démarrer une nouvelle.
+    Une autre personne est déjà en train de réaliser une diffusion audio. Attendez que sa diffusion audio soit terminée pour en démarrer une nouvelle.
+    Vous n’avez pas les permissions requises pour démarrer une nouvelle diffusion audio dans ce salon. Contactez un administrateur du salon pour mettre-à-jour vos permissions.
+    Impossible de commencer une nouvelle diffusion audio
+    Avance rapide de 30 secondes
+    Retour rapide de 30 secondes
+    Les sessions vérifiées sont toutes celles qui utilisent ce compte après avoir saisie la phrase de sécurité ou confirmé votre identité à l’aide d’une autre session vérifiée.
+\n
+\nCela veut dire qu’elles disposent de toutes les clés nécessaires pour lire les messages chiffrés, et confirment aux autres utilisateurs que vous faites confiance à cette session.
+    
+        Déconnecter %1$d session
+        Déconnecter %1$d sessions
+    
+    Déconnecter
+    %1$s restant
+    a créé un sondage.
+    a envoyé un autocollant.
+    a envoyé une vidéo.
+    a envoyé une image.
+    envoyer un message vocal.
+    a envoyé un fichier audio.
+    a envoyé un fichier.
+    En réponse à
+    Masquer l’adresse IP
+    Afficher l’adresse IP
+    Citation de
+    Réponse à %s
+    Modification
+    Affiche les conversations récentes dans le menu de partage du système
+    Activer le partage direct
 
diff --git a/library/ui-strings/src/main/res/values-hu/strings.xml b/library/ui-strings/src/main/res/values-hu/strings.xml
index 21ea6aab14..1dd2134b90 100644
--- a/library/ui-strings/src/main/res/values-hu/strings.xml
+++ b/library/ui-strings/src/main/res/values-hu/strings.xml
@@ -2836,4 +2836,34 @@ A Visszaállítási Kulcsot tartsd biztonságos helyen, mint pl. egy jelszókeze
         %1$d kiválasztva
         %1$d kiválasztva
     
+    Teljes képernyő váltás
+    Mindenhol ellenőrzött munkamenetek vannak ahol ezt a fiókot használva megadtad a jelmondatodat vagy egy másik már hitelesített munkamenetből megerősítetted az identitásodat.
+\n
+\nEz azt jelenti, hogy a titkosított üzenetek visszafejtéséhez rendelkezel a kulcsokkal és megerősíted a többiek felé, hogy megbízol a munkamenetben.
+    
+        Kijelentkezés %1$d munkamenetből
+        Kijelentkezés %1$d munkamenetből
+    
+    Kijelentkezés
+    Szöveg formázás
+    Egy hang közvetítés már folyamatban van. Először fejezze be a jelenlegi közvetítést egy új indításához.
+    Valaki már elindított egy hang közvetítést. Várd meg a közvetítés végét az új indításához.
+    Nincs jogosultságod hang közvetítést indítani ebben a szobában. Vedd fel a kapcsolatot a szoba adminisztrátorával a szükséges jogosultság megszerzéséhez.
+    Az új hang közvetítés nem indítható el
+    30 másodperccel előre
+    30 másodperccel vissza
+    visszavan: %1$s
+    szavazás elkészítve.
+    matrica elküldve.
+    videót küldött.
+    kép elküldve.
+    hang üzenet elküldve.
+    hangfájl elküldve.
+    fájl elküldve.
+    Válaszolva erre
+    IP címek elrejtése
+    IP címek megjelenítése
+    Idézet
+    Válasz erre: %s
+    Szerkesztés
 
\ No newline at end of file
diff --git a/library/ui-strings/src/main/res/values-in/strings.xml b/library/ui-strings/src/main/res/values-in/strings.xml
index 6ed423bb70..da4c474689 100644
--- a/library/ui-strings/src/main/res/values-in/strings.xml
+++ b/library/ui-strings/src/main/res/values-in/strings.xml
@@ -2762,7 +2762,7 @@ Di masa mendatang proses verifikasi ini akan dimutakhirkan.
     Permintaan gagal.
     Memungkinkan untuk merekam dan mengirim siaran suara dalam linimasa ruangan.
     Aktifkan siaran suara (dalam pengembangan aktif)
-    Memuat
+    Memuat…
     Jeda siaran suara
     Mainkan atau lanjutkan siaran suara
     Hentikan rekaman siaran suara
@@ -2783,4 +2783,35 @@ Di masa mendatang proses verifikasi ini akan dimutakhirkan.
     
         %1$d dipilih
     
+    Ubah mode layar penuh
+    Format teks
+    Anda sedang merekam sebuah siaran suara. Mohon akhiri siaran suara Anda saat ini untuk memulai yang baru.
+    Orang lain sedang merekam sebuah siaran suara. Tunggu untuk siaran suara berakhir untuk memulai yang baru.
+    Anda tidak memiliki izin yang dibutuhkan untuk memulai sebuah siaran suara di ruangan ini. Hubungi sebuah administrator ruangan untuk meningkatkan izin Anda.
+    Tidak dapat memulai siaran suara baru
+    Maju cepat 30 detik
+    Mundur cepat 30 detik
+    Sesi terverifikasi ada di mana pun Anda menggunakan Element setelah memasukkan frasa sandi atau mengonfirmasi identitas Anda dengan sesi terverifikasi lainnya.
+\n
+\nIni berarti Anda memiliki semua kunci yang diperlukan untuk membuka kunci pesan terenkripsi dan mengonfirmasi kepada pengguna lain bahwa Anda memercayai sesi ini.
+    
+        Keluarkan %1$d sesi
+    
+    Keluarkan
+    %1$s tersisa
+    membuat pemungutan suara.
+    mengirim stiker.
+    mengirim video.
+    mengirim gambar.
+    mengirim file.
+    mengirim file audio.
+    mengirim pesan suara.
+    Membalas ke
+    Sembunyikan alamat IP
+    Mengutip
+    Mengedit
+    Tampilkan alamat IP
+    Membalas ke %s
+    Tampilkan obrolan terkini dalam menu pembagian sistem
+    Aktifkan pembagian langsung
 
\ No newline at end of file
diff --git a/library/ui-strings/src/main/res/values-it/strings.xml b/library/ui-strings/src/main/res/values-it/strings.xml
index 5cc509e8b9..d6a7858ebc 100644
--- a/library/ui-strings/src/main/res/values-it/strings.xml
+++ b/library/ui-strings/src/main/res/values-it/strings.xml
@@ -2805,7 +2805,7 @@
     L\'altro dispositivo ha già fatto l\'accesso.
     Si è verificato un problema di sicurezza configurando i messaggi sicuri. Una delle seguenti cose potrebbe essere compromessa: il tuo homeserver; la/e connessione/i internet; il/i dispositivo/i;
     La richiesta è fallita.
-    Buffering
+    Buffer…
     Sospendi trasmissione vocale
     Avvia o riprendi trasmissione vocale
     Ferma registrazione trasmissione vocale
@@ -2827,4 +2827,36 @@
         %1$d selezionato
         %1$d selezionati
     
+    Attiva/disattiva schermo intero
+    Le sessioni verificate sono ovunque usi questo account dopo l\'inserimento della password o la conferma della tua identità con un\'altra sessione verificata.
+\n
+\nCiò significa che hai tutte le chiavi necessarie per sbloccare i tuoi messaggi cifrati e per confermare agli altri utenti che ti fidi di questa sessione.
+    
+        Disconnetti da %1$d sessione
+        Disconnetti da %1$d sessioni
+    
+    Disconnetti
+    Formattazione testo
+    Stai già registrando una trasmissione vocale. Termina quella in corso per iniziarne una nuova.
+    Qualcun altro sta già registrando una trasmissione vocale. Aspetta che finisca prima di iniziarne una nuova.
+    Non hai l\'autorizzazione necessaria per iniziare una trasmissione vocale in questa stanza. Contatta un amministratore della stanza per aggiornare le tue autorizzazioni.
+    Impossibile iniziare una nuova trasmissione vocale
+    Manda avanti di 30 secondi
+    Manda indietro di 30 secondi
+    %1$s rimasti
+    creato un sondaggio.
+    inviato un adesivo.
+    inviato un video.
+    inviata un\'immagine.
+    inviato un messaggio vocale.
+    inviato un file audio.
+    inviato un file.
+    In risposta a
+    Nascondi indirizzo IP
+    Mostra indirizzo IP
+    Citazione
+    Risposta a %s
+    Modifica
+    Mostra chat recenti nel menu di condivisione di sistema
+    Attiva condivisione diretta
 
\ No newline at end of file
diff --git a/library/ui-strings/src/main/res/values-ja/strings.xml b/library/ui-strings/src/main/res/values-ja/strings.xml
index 37c0bca52f..11ab6ee857 100644
--- a/library/ui-strings/src/main/res/values-ja/strings.xml
+++ b/library/ui-strings/src/main/res/values-ja/strings.xml
@@ -2459,4 +2459,18 @@
     ルームを作成
     チャットを開始
     全ての会話
+    ${app_name}にようこそ、
+\n%s。
+    認証済のセッション
+    QRコードでサインイン
+    新しいセッションマネージャーを有効にする
+    QRコードでサインイン
+    3
+    2
+    1
+    リクエストが失敗しました。
+    QRコードをスキャン
+    QRコードをスキャン
+    QRコードをスキャン
+    QRコードが不正です。
 
\ No newline at end of file
diff --git a/library/ui-strings/src/main/res/values-pl/strings.xml b/library/ui-strings/src/main/res/values-pl/strings.xml
index ab9c367824..1c01c82189 100644
--- a/library/ui-strings/src/main/res/values-pl/strings.xml
+++ b/library/ui-strings/src/main/res/values-pl/strings.xml
@@ -2715,7 +2715,7 @@
     Preferencje interfejsu
     Przeglądaj pokoje
     Utwórz pokój
-    Zacznij rozmawiać
+    Rozpocznij czat
     Wszystkie rozmowy
     Nie zweryfikowano · Ostatnia aktywność %1$s
     Zweryfikowano · Ostatnia aktywność %1$s
@@ -2743,4 +2743,4 @@
     %s
 \nwygląda nieco pusto.
     Brak przestrzeni.
-
+
\ No newline at end of file
diff --git a/library/ui-strings/src/main/res/values-pt-rBR/strings.xml b/library/ui-strings/src/main/res/values-pt-rBR/strings.xml
index 2ec5f394bd..8129a234fb 100644
--- a/library/ui-strings/src/main/res/values-pt-rBR/strings.xml
+++ b/library/ui-strings/src/main/res/values-pt-rBR/strings.xml
@@ -2723,7 +2723,7 @@
     Sessões não-verificadas
     Sessões inativas são sessões que você não tem usado em algum tempo, mas elas continuam a receber chaves de encriptação.
 \n
-\nRemover sessões inativas melhora segurança e performance, e torna-o mais fácil para você identificar se uma nova sessão é suspeita.
+\nRemover sessões inativas melhora segurança e performance, e torna mais fácil para você identificar se uma nova sessão é suspeita.
     Sessões inativas
     Por favor esteja ciente que nomes de sessões também são visíveis a pessoas com quem você se comunica.
     Nomes de sessões personalizadas podem ajudar você a reconhecer seus dispositivos mais facilmente.
@@ -2836,4 +2836,34 @@
         %1$d selecionada(o)
         %1$d selecionadas(os)
     
+    Alguma outra pessoa já está gravando um broadcast de voz. Espere que o broadcast de voz dela termine para começar um novo.
+    Alternar modo de tela cheia
+    Formatação de texto
+    Você já está gravando um broadcast de voz. Por favor termine seu broadcast de voz atual para começar um novo.
+    Você não tem as permissões requeridas para começar um broadcast de voz nesta sala. Contacte um/uma administrador(a) para fazer upgrade de suas permissões.
+    Não dá pra começar um novo broadcast de voz
+    Avançar rápido 30 segundos
+    Retroceder 30 segundos
+    Sessões verificadas são onde quer que você esteja usando esta conta depois de entrar sua frasepasse ou confirmar sua identidade com uma outra sessão verificada.
+\n
+\nIsto significa que você tem todas as chaves necessárias para destrancar suas mensagens encriptadas e confirmar a outras(os) usuárias(os) que você confia nesta sessão.
+    
+        Fazer signout de %1$d sessão
+        Fazer signout de %1$d sessões
+    
+    Fazer signout
+    %1$s restando
+    criou uma sondagem.
+    enviou um sticker.
+    enviou um vídeo.
+    enviou uma imagem.
+    enviou uma mensagem de voz.
+    enviou um arquivo de áudio.
+    enviou um arquivo.
+    Em resposta a
+    Esconder endereço de IP
+    Mostrar endereço de IP
+    Citando
+    Respondendo a %s
+    Editando
 
\ No newline at end of file
diff --git a/library/ui-strings/src/main/res/values-ru/strings.xml b/library/ui-strings/src/main/res/values-ru/strings.xml
index fb819efb69..39d1c8de2b 100644
--- a/library/ui-strings/src/main/res/values-ru/strings.xml
+++ b/library/ui-strings/src/main/res/values-ru/strings.xml
@@ -930,7 +930,7 @@
     Безопасность
     Правила push-уведомлений
     ID приложения:
-    push_key:
+    Ключ Push:
     Отображаемое название приложения:
     Отображаемое название сессии:
     Url:
@@ -1000,10 +1000,10 @@
     Не удалось подключиться к серверу обнаружения
     Пожалуйста, введите URL сервера обнаружения
     Сервер обнаружения не имеет условий использования
-    Параметры обнаружения появятся после добавления электронной почты.
+    Параметры обнаружения появятся после добавления адреса электронной почты.
     Параметры поиска появятся после добавления номера телефона.
     Отключение от сервера обнаружения будет означать, что другие пользователи не смогут обнаружить вас, и вы не сможете приглашать других по электронной почте или по телефону.
-    Мы отправили вам электронное письмо с подтверждением на %s, проверьте вашу электронную почту и нажмите на ссылку для подтверждения
+    Мы отправили вам электронное письмо на %s, проверьте вашу электронную почту и нажмите на ссылку для подтверждения
     Выбранный сервер обнаружения не имеет условий использования. Продолжайте, только если вы доверяете его владельцу
     Текстовое сообщение отправлено %s. Введите код проверки, который он содержит.
     В настоящее время вы делитесь адресами электронной почты или телефонными номерами на сервере обнаружения %1$s. Вам нужно повторно подключиться к %2$s, чтобы прекратить делиться ими.
@@ -1102,7 +1102,7 @@
     Предупреждение!
     Смена пароля приведёт к сбросу всех сквозных ключей шифрования во всех ваших сессиях, что сделает зашифрованную историю разговоров нечитаемой. Настройте резервное копирование ключей или экспортируйте ключи от комнаты из другой сессии, прежде чем сбрасывать пароль.
     Продолжить
-    Данный электронный ящик не связан ни с одним аккаунтом
+    Данный адрес электронной почты не связан ни с одним аккаунтом
     Проверьте свою почту
     Письмо с подтверждением было отправлено на %1$s.
     Нажмите на ссылку, чтобы подтвердить свой новый пароль. Как только вы перейдете по ссылке нажмите ниже.
@@ -1241,7 +1241,7 @@
     %1$s: %2$s %3$s
     Удалённые сообщения
     Показывать заглушку на месте удалённых сообщений
-    Мы отправили письмо для подтверждения на %s, проверьте почту и нажмите на ссылку для подтверждения
+    Мы отправили письмо на %s, пожалуйста проверьте почту и нажмите на ссылку для подтверждения
     Код подтверждения неверный.
     Попробуйте снова после принятия условий обслуживания на вашем домашнем сервере.
     Похоже, сервер долгое время не отвечает, что может быть вызвано плохим соединением или ошибкой на сервере. Попробуйте снова через некоторое время.
@@ -1351,7 +1351,7 @@
     Введите адрес сервера, который вы хотите использовать
     На ваш почтовый ящик будет отправлено письмо для подтверждения установки нового пароля.
     Я подтвердил свою электронную почту
-    Установите адрес электронной почты для восстановления вашей учетной записи. Позже вы можете дополнительно разрешить людям, которых вы знаете, обнаружить вас по электронной почте.
+    Укажите адрес электронной почты для восстановления вашей учетной записи. Потом вы сможете, при желании, разрешить людям, которых вы знаете, обнаружить вас по адресу электронной почты.
     Введенный код неверен. Пожалуйста, проверьте.
     Войти с Matrix ID
     Войти с Matrix ID
@@ -1482,7 +1482,7 @@
     Незаверенная
     Эта сессия является доверенной для безопасного обмена сообщениями, так как %1$s (%2$s) проверил(а) его:
     %1$s (%2$s) вошел(ла), используя новую сессию:
-    Пока этот пользователь не доверяет этой сессии, сообщения, отправленные в обе стороны, помечаются предупреждениями. Кроме того, вы можете подтвердить сессию вручную.
+    Пока этот пользователь не доверяет этой сессии, сообщения, отправленные в обе стороны, помечаются предупреждениями. Вы также можете подтвердить эту сессию вручную.
     Начать перекрестную подпись
     Сбросить ключи
     Почти готово! Показывает ли %s галочку\?
@@ -1606,7 +1606,7 @@
     Эта операция невозможна. Домашний сервер устарел.
     Пожалуйста, настройте сначала сервер идентификации.
     Пожалуйста, примите сначала условия сервера идентификации в настройках.
-    Для вашей приватности, ${app_name} поддерживает отправку адреса электронной почты и номера телефона только в хэшированном виде.
+    Для вашей приватности, ${app_name} поддерживает отправку адреса электронной почты и номеров телефонов только в хэшированном виде.
     Привязка не удалась.
     Текущая взаимосвязь с этим идентификатором отсутствует.
     Ваш домашний сервер (%1$s) предлагает использовать %2$s для вашего сервера обнаружения
@@ -1793,7 +1793,7 @@
     Добавить изображение из
     Тема
     Название комнаты
-    Вы дали свое согласие на отправку электронных писем и телефонных номеров на этот сервер обнаружения для обнаружения других пользователей из ваших контактов.
+    Вы дали свое согласие на отправку адресов электронных почт и телефонных номеров на этот сервер идентификации для обнаружения других пользователей из ваших контактов.
     Добавить по QR-коду
     Разрешить доступ к вашим контактам.
     Чтобы отсканировать QR-код, вам нужно разрешить доступ к камере.
@@ -2396,7 +2396,7 @@
     Местоположение
     Вы согласны отправить эту информацию\?
     Чтобы обнаружить существующие контакты, необходимо отправить контактную информацию (электронную почту и номера телефонов) на сервер обнаружения. Мы хешируем ваши данные перед отправкой для обеспечения конфиденциальности.
-    Отправить электронные адреса и номера телефонов %s
+    Отправить адреса электронных почт и номера телефонов %s
     Ваши контакты приватны. Чтобы обнаружить пользователей из ваших контактов, нам необходимо ваше разрешение на отправку контактной информации на ваш сервер обнаружения.
     Системные настройки
     Версии
@@ -2805,4 +2805,162 @@
     Местоположение
     Камера
     Контакт
+    ${app_name} нуждается в разрешении для отображения оповещений.
+\nПожалуйста, дайте разрешение.
+    
+        %1$s и %2$d другой
+        %1$s и %2$d другие
+        %1$s и %2$d других
+        %1$s и %2$d других
+    
+    ${app_name} нуждается в резрешении для отображения оповещений. Оповещения могут показывать ваши сообщения, приглашения и тому подобное.
+\n
+\nПожалуйста разрешите доступ при следующем всплывающем сообщении, чтобы иметь возможность видеть оповещения.
+    Здесь будут появляться новые запросы и приглашения.
+    Приглашения
+    Попробуйте расширенный текстовый редактор (режим набора обычного текста скоро появится)
+    Создавать личные сообщения только при отправке первого сообщения
+    Включить отложенные личные сообщения
+    Отменить выбор всего
+    Выбрать всё
+    Свернуть дочерние элементы %s
+    Развернуть дочерние элементы %s
+    
+        Выбрано %1$d
+        Выбрано %1$d
+        Выбрано %1$d
+        Выбрано %1$d
+    
+    Войти в полноэкранный режим
+    Применить форматирование подчёркиванием
+    Применить форматирование перечёркиванием
+    Применить форматирование курсивом
+    Применить форматирование жирным
+    Пожалуйста удостоверьтесь в том, что вы знаете откуда этот код. При соединении устройств, вы даёте кому-то полный доступ к вашей учётной записи.
+    Подтвердить
+    Попробовать снова
+    Не сходится\?
+    Вход
+    Соединение с устройством
+    Сканировать QR-код
+    Входите с мобильного устройства\?
+    Показать QR-код на этом устройстве
+    Выберите «Сканировать QR-код»
+    Начните с экрана входа
+    Выберите «Войти при помощи QR-кода»
+    Начните с экрана входа
+    Выберите «Показать QR-код»
+    Зайдите в Настройки -> Безопасность и Приватность
+    Откройте приложение с другого устройства
+    Домашний сервер не поддерживает вход при помощи QR-кода.
+    Вход был отменён с другого устройства.
+    Этот QR-код не работает.
+    Другое устройство должно войти в учётную запись.
+    Другое устройство уже выполнило вход.
+    Во время установки безопасной переписки возникла проблема с безопасностью. Одно из следующего является скомпроментированным: Ваш домашний сервер; Ваше интернет-соединение; Ваше устройство;
+    Запрос не выполнен.
+    Запрос был отклонён на другом устройстве.
+    Соединение не было выполнено за нужное время.
+    Соединение с этим устройством не поддерживается.
+    Неудачное соединение
+    Проверьте устройство, с которого вы вошли в учётную запись. На его экране должен появиться код снизу. Подтвердите, что код снизу такой же, как и на том устройстве:
+    Безопасное соединение установлено
+    Сканируйте QR-код снизу при помощи устройства, с которого вы вышли с учётной записи.
+    Используйте устройство, с которого вы вошли в учётную запись, чтобы сканировать QR-код снизу:
+    Войти при помощи QR-кода
+    Используйте камеру на этом устройстве, чтобы сканировать QR-код, отображённый на вашем другом устройстве:
+    Сканировать QR-код
+    3
+    2
+    1
+    Нажмите слева сверху, чтобы увидеть опцию отзыва.
+    Чтобы упростить ${app_name}, вкладки теперь опциональные. Управляйте ими при помощи меню справа сверху.
+    Универсальное безопасное приложение для переписок с командами, друзьями и организациями. Создайте переписку или присоеденитесь к уже существующей, чтобы начать.
+    Пространства — новый способ групировать комнаты и людей. Добавьте существующую комнату или создайте новую, используя кнопку слева снизу.
+    Возможность записывать и отправлять голосовые трансляции в ленту комнаты.
+    Получите лучший надзор и контроль над всеми вашими сессиями.
+    Подтверждённые сессии есть везде, где вы используете эту учётную запись, после введения вашего пароля или подтверждения вашей личности при помощи другой подтверждённой сессии.
+\n
+\nЭто значит, что у вас есть все нужные ключи, чтобы разблокировать зашифрованные сообщения и даёте другим пользователям знать, что вы доверяете этой сессии.
+    Подтверждённые сессии вошли при помощи ваших учётных данных и были подтверждены, либо при помощи вашего безопасного пароля, либо при помощи подтверждения с другого устройства.
+\n
+\nЭто значит, что на них находятся ключи шифрования для ваших предыдущих сообщений и дают другим пользователям знать, что эти сессии действительно принадлежат вам.
+    Неподтверждённые сессии — это сессии, которые вошли при помощи ваших учётных данных, но не были подтверждены.
+\n
+\nВы должны удостовериться, что узнаёте эти сессии, так как они могут быть несанкционированным входом в вашу учётную запись.
+    Вы можете использовать это устройство для входа с телефона или веб-устройства при помощи QR-кода. Для этого есть два способа:
+    Войти при помощи QR-кода
+    Собственные названия сессий помогут вам легче распознать свои девайсы.
+    
+        Выйти из %1$d сессии
+        Выйти из %1$d сессий
+        Выйти из %1$d сессий
+        Выйти из %1$d сессий
+    
+    Выйти
+    Выбрать сессии
+    Фильтр
+    
+        Неактивен %1$d+ день (%2$s)
+        Неактивен %1$d+ дней (%2$s)
+        Неактивен %1$d+ дня (%2$s)
+        Неактивен %1$d+ дня (%2$s)
+    
+    Подтвердите текущую сессию, чтобы посмотреть её состояние подтверждения.
+    Неизвестное состояние проверки
+    Автоматически принимать виджеты Element Call и давать доступ к микрофону/камере
+    Включить ярлыки разрешений Element Call
+    Форматирование текста
+    Начать новую голосовую трансляцию
+    Вам необходимо иметь нужные разрешения, чтобы делиться местоположением в реальном времени в этой комнате.
+    У вас нет разрешения делиться местоположением в реальном времени
+    При приглашении кого-то в зашифрованную комнату, которая делится историей, зашифрованная история будет видимой.
+    Вы уже записываете голосовую трансляцию. Пожалуйста закончите текущую голосовую трансляцию, чтобы начать новую.
+    Кто-то другой уже записывает голосовую трансляцию. Подождите пока их голосовая трансляция закончится, чтобы начать новую.
+    У вас нет необходимых разрешений для начала голосовой трансляции в этой комнате. Свяжитесь с администратором комнаты, чтобы получить разрешения.
+    Не получилось начать новую голосовую трансляцию
+    Перемотать вперёд на 30 секунд
+    Перемотать назад на 30 секунд
+    Буферизация
+    Приостановить голосовую трансляцию
+    Проиграть или продолжить голосовую трансляцию
+    Остановить запись голосовой трансляции
+    Приостановить запись голосовой трансляции
+    Продолжить запись голосовой трансляции
+    Прямая трансляция
+    Подлинность этого зашифрованного сообщения не может быть гарантирована на этом устройстве.
+    Сканировать QR-код
+    Отправьте ваше первое сообщение, чтобы пригласить %s в переписку
+    Этот QR-код выглядит неправильно. Пожалуйста, попробуйте подтвердить другим способом.
+    Вы не сможете получить доступ к истории зашифрованных сообщений. Сбросьте вашу защищённую резевную копию и ключи подтверждения, чтобы начать заново.
+    Сброс пароля
+    Выберите новый пароль
+    %s пришлёт вам ссылку для подтверждения
+    %s нуждается в подтверждении вашей учётной записи
+    %s нуждается в подтверждении вашей учётной записи
+    Связаться
+    Element Matrix Services (EMS) — надёжная хостинговая служба для быстрой и безопасной связи в режиме реального времени. Узнайте больше на <a href=\"${ftue_ems_url}\">element.io/ems</a>
+    Открыть список пространств
+    Включено:
+    Что-то пошло не так. Пожалуйста, проверьте соединение и попробуйте ещё раз.
+    Открыть экран инструментов для разработчика
+    Простите, эта комната не была найдена.
+\nПожалуйста, попробуйте снова позже.%s
+    ⚠ В этой комнате есть неподтверждённые устройства, они не смогут расшифровывать сообщения, отправленные вами.
+    Дать разрешение
+    Другие пользователи могут найти вас по %s
+    Осталось %1$s
+    создал опрос.
+    отправил наклейку.
+    отправил видео.
+    отправил изображение.
+    отправил голосовое сообщение.
+    отправил аудиофайл.
+    отправил файл.
+    В ответ на
+    Скрыть IP-адрес
+    Показать IP-адрес
+    Цитируя
+    В ответ на %s
+    Редактирование
 
\ No newline at end of file
diff --git a/library/ui-strings/src/main/res/values-sk/strings.xml b/library/ui-strings/src/main/res/values-sk/strings.xml
index bf57233b37..f59073c5db 100644
--- a/library/ui-strings/src/main/res/values-sk/strings.xml
+++ b/library/ui-strings/src/main/res/values-sk/strings.xml
@@ -2653,7 +2653,7 @@
     V záujme čo najlepšieho zabezpečenia, overte svoje relácie a odhláste sa z každej relácie, ktorú už nepoznáte alebo nepoužívate.
     Iné relácie
     Relácie
-    Otvoriť zoznam priestorov
+    Zoznam priestorov
     Vytvoriť novú konverzáciu alebo miestnosť
     Ľudia
     Obľúbené
@@ -2765,7 +2765,7 @@
     Zapnúť nové usporiadanie
     Ostatní používatelia v priamych správach a miestnostiach, do ktorých sa pripojíte, si môžu pozrieť úplný zoznam vašich relácií.
 \n
-\nTo im poskytuje istotu, že sa s vami naozaj rozprávajú, ale zároveň to znamená, že vidia názov relácie, ktorý sem zadáte.
+\nTo im poskytuje istotu, že sa komunikujú naozaj s vami, ale zároveň to znamená, že vidia názov relácie, ktorý sem zadáte.
     Premenovanie relácií
     Overené relácie, do ktorých ste sa prihlásili pomocou svojich prihlasovacích údajov a ktoré boli následne overené buď pomocou vašej bezpečnostnej prístupovej frázy, alebo krížovým overením.
 \n
@@ -2868,7 +2868,7 @@
     Žiadosť zlyhala.
     Možnosť nahrávania a odosielania hlasového vysielania v časovej osi miestnosti.
     Zapnúť hlasové vysielanie (v štádiu aktívneho vývoja)
-    Načítavanie do vyrovnávacej pamäte
+    Načítavanie do vyrovnávacej pamäte…
     Pozastaviť hlasové vysielanie
     Prehrať alebo pokračovať v nahrávaní hlasového vysielania
     Zastaviť nahrávanie hlasového vysielania
@@ -2891,4 +2891,37 @@
         %1$d vybraté
         %1$d vybraných
     
+    Prepnutie režimu na celú obrazovku
+    Formátovanie textu
+    Už nahrávate hlasové vysielanie. Ukončite aktuálne hlasové vysielanie a spustite nové.
+    Niekto iný už nahráva hlasové vysielanie. Počkajte, kým sa skončí jeho hlasové vysielanie, a potom spustite nové.
+    Nemáte požadované oprávnenia na spustenie hlasového vysielania v tejto miestnosti. Obráťte sa na správcu miestnosti, aby vám rozšíril oprávnenia.
+    Nie je možné spustiť nové hlasové vysielanie
+    Rýchle posunutie dozadu o 30 sekúnd
+    Rýchle posunutie dopredu o 30 sekúnd
+    Overené relácie sú všade tam, kde používate toto konto po zadaní svojho prístupového hesla alebo po potvrdení svojej totožnosti inou overenou reláciou.
+\n
+\nTo znamená, že máte všetky kľúče potrebné na odomknutie zašifrovaných správ a potvrdenie pre ostatných používateľov, že tejto relácii dôverujete.
+    
+        Odhlásiť sa z %1$d relácie
+        Odhlásiť sa z %1$d relácií
+        Odhlásiť sa z %1$d relácií
+    
+    Odhlásiť sa
+    Ostáva %1$s
+    Cituje
+    vytvoril/a anketu.
+    poslal/a nálepku.
+    poslal/a video.
+    poslal/a obrázok.
+    poslal/a zvukovú správu.
+    poslal/a zvukový súbor.
+    poslal súbor.
+    V odpovedi na
+    Skryť IP adresu
+    Zobraziť IP adresu
+    Odpoveď na %s
+    Úprava
+    Zobraziť posledné konverzácie v systémovej ponuke zdieľania
+    Povoliť priame zdieľanie
 
\ No newline at end of file
diff --git a/library/ui-strings/src/main/res/values-sq/strings.xml b/library/ui-strings/src/main/res/values-sq/strings.xml
index a6af0a4921..58214e8a22 100644
--- a/library/ui-strings/src/main/res/values-sq/strings.xml
+++ b/library/ui-strings/src/main/res/values-sq/strings.xml
@@ -601,9 +601,7 @@
     Formatojini mesazhet duke përdorur sintaksën Markdown përpara se të dërgohen. Kjo lejon formatim të thelluar, f.v., përdorimi i yllthit për ta shfaqur tekstin me të pjerrëta.
     Nuk prek ftesat, heqjet dhe dëbimet.
     ${app_name}-i grumbullon të dhëna analitike anonime që të na lejojë ta përmirësojmë aplikacionin.
-    Të shfaqen krejt mesazhet prej %s\?
-\n
-\nKini parasysh që ky veprim do të sjellë rinisjen e aplikacionit dhe mund të hajë ca kohë.
+    Të shfaqen krejt mesazhet prej %s\?
     Nis kamerën e sistemit, në vend se skenën e kamerës vetjake.
     Shfaq veprimin
     On/Off sintakse Markdown
@@ -897,10 +895,10 @@
     S’u arrit të dërgohej sugjerimi (%s)
     Shfaq te rrjedha kohore akte të fshehura
     Përgjegjës integrimesh
-    app_id:
-    push_key:
-    app_display_name:
-    emër_sesioni:
+    ID Aplikacioni:
+    
+    Emër Aplikacioni Në Ekran:
+    Emër Sesioni Në Ekran:
     Mesazhe të Drejtpërdrejtë
     Po pritet…
     Po fshehtëzohet miniatura…
@@ -949,11 +947,11 @@
     Po përdorni %1$s për të zbuluar dhe për të qenë i zbulueshëm nga kontakte ekzistues që njihni.
     S’po përdorni ndonjë shërbyes identitetesh. Që të zbuloni dhe të jini i zbulueshëm nga kontakte ekzistuese që njihni, formësoni një të tillë më poshtë.
     Adresa email të zbulueshme
-    Mundësitë rreth zbulimesh do të shfaqen sapo të keni shtuar një email.
+    Mundësitë e zbulimit do të shfaqen sapo të keni shtuar një adresë email.
     Mundësi zbulimesh do të shfaqen sapo të keni shtuar një numër telefoni.
     Shkëputja prej shërbyesit tuaj të identiteteve do të thotë se s’do të jeni i zbulueshëm prej përdoruesish të tjerë dhe s’do të jeni në gjendje të ftoni të tjerë me email ose telefon.
     Numra telefoni të zbulueshëm
-    Ju dërguam një email ripohimi te %s, hapeni dhe klikoni mbi lidhjen e ripohimit
+    Ju dërguam një email te %s, hapeni dhe klikoni mbi lidhjen e ripohimit
     Jepni një URL shërbyesi identitetesh
     S’u lidh dot te shërbyes identitetesh
     Ju lutemi, jepni URL-në e shërbyesit të identiteteve
@@ -1080,7 +1078,7 @@
     Aplikacioni s’është në gjendje të krijojë llogari në këtë shërbyes Home.
 \n
 \nDoni të regjistroheni duke përdorur një klient web\?
-    Ky emai s’është përshoqëruar me ndonjë llogari.
+    Kjo adresë email s’është e përshoqëruar me ndonjë llogari.
     Ricaktoni fjalëkalimin në %1$s
     Te mesazhet tuaj do të dërgohet një email verifikimi, për të ripohuar caktimin e fjalëkalimit tuaj të ri.
     Pasuesi
@@ -1089,7 +1087,7 @@
     Kujdes!
     Ndryshimi i fjalëkalimit tuaj do të sjellë zerim të çfarëdo kyçesh fshehtëzimi skaj-më-skaj në krejt sesionet tuaj, duke e bërë të palexueshëm historikun e bisedave të fshehtëzuara. Ujdisni një Kopjeruajtje Kyçesh ose eksportoni kyçet e dhomës tuaj prej një tjetër sesioni, përpara se të ricaktoni fjalëkalimin tuaj.
     Vazhdo
-    Ky email s’është i lidhur me ndonjë llogari
+    Kjo adresë email s’është e lidhur me ndonjë llogari
     Kontrolloni te mesazhet tuaj të marrë
     Një email verifikimi u dërgua te %1$s.
     Prekni mbi lidhjen që të ripohohet fjalëkalimi juaj i ri. Pasi të keni ndjekur lidhjen që përmban, klikoni më poshtë.
@@ -1103,7 +1101,7 @@
 \n
 \nTë ndalet procesi i ndryshimit të fjalëkalimit\?
     Caktoni adresë email
-    Caktoni një email për rimarrje të llogarisë tuaj. Më vonë, mundeni të lejoni persona që njihni t’ju zbulojnë përmes email-it tuaj.
+    Caktoni një adresë email për rimarrje të llogarisë tuaj. Më vonë, mundeni të lejoni persona që njihni t’ju zbulojnë përmes kësaj adrese.
     Email
     Email (në daçi)
     Pasuesi
@@ -1445,7 +1443,7 @@
     Mesazhi u fshi
     Shfaq mesazhe të hequr
     Shfaq një vendmbajtëse për mesazhe të hequr
-    Ju dërguam një email ripohimi te %s, ju lutemi, së pari, shihni email-in tuaj dhe klikoni mbi lidhjen e ripohimit
+    Ju dërguam një email te %s, ju lutemi, së pari, shihni email-in tuaj dhe klikoni mbi lidhjen e ripohimit
     Kodi i verifikimit s’është i saktë.
     MEDIA
     S’ka media në këtë dhomë
@@ -1518,9 +1516,7 @@
 \n
 \nKëtë veprim mund ta zhbëni në çfarëdo kohe, te rregullimet e përgjithshme.
     Hiqe shpërfilljen e përdoruesit
-    Heqja e shpërfilljes së këtij përdoruesi do të shfaqë sërish krejt mesazhet prej tij.
-\n
-\nKini parasysh se ky veprim do të sjellë rinisjen e aplikacionit dhe do të hajë ca kohë.
+    Heqja e shpërfilljes së këtij përdoruesi do të shfaqë sërish krejt mesazhet prej tij.
     Anuloje ftesën
     Jeni i sigurt se doni të anulohet ftesa për këtë përdorues\?
     Përzëre përdoruesin
@@ -1534,7 +1530,7 @@
     Heqja e dëbimit përdoruesit do t’i lejojë të marrë pjesë sërish në dhomë.
     Te llogaria juaj s’është shtuar ndonjë numër telefoni
     Adresa email
-    Te llogaria juaj s’është shtuar ndonjë email
+    Te llogaria juaj s’është shtuar ndonjë adresë email
     Numra telefoni
     Të hiqet %s\?
     Sigurohuni që keni klikuar te lidhja në email-in që ju kemi dërguar.
@@ -1552,7 +1548,7 @@
     Integrimet janë të çaktivizuara
     Që të bëhet kjo, aktivizoni “Lejo integrime”, te Rregullimet.
     Email-e dhe numra telefonash
-    Administroni email-e dhe numra telefonash të lidhur me llogarinë tuaj Matrix
+    Administroni adresa email dhe numra telefonash të lidhur me llogarinë tuaj Matrix
     
         %d përdorues i dëbuar
         %d përdorues të dëbuar
@@ -1605,7 +1601,7 @@
     Kjo llogari është çaktivizuar.
     S’u ruajt dot kartelë media
     Ripohoni identitetin tuaj duke verifikuar këto kredenciale hyrjeje, duke i akorduar hyrje te mesazhe të fshehtëzuar.
-    Për privatësinë tuaj, ${app_name}-i mbulon vetëm dërgim email-esh dhe numrash telefoni përdoruesi të koduar.
+    Për privatësinë tuaj, ${app_name}-i mbulon vetëm dërgim adresash email dhe numrash telefoni përdoruesi të koduar.
     Caktoni rol
     Rol
     Hapni fjalosje
@@ -1769,7 +1765,7 @@
     %1$d nga %2$d
     Jepe pranimin
     Shfuqizoje pranimin tim
-    Keni dhënë pranimin tuaj për të dërguar email-e dhe numra telefonash te ky shërbyes identitetesh që të zbulojë përdorues të tjerë prej kontakteve tuaj.
+    Keni dhënë pranimin tuaj për të dërguar adresa email-e dhe numra telefonash te ky shërbyes identitetesh që të zbulojë përdorues të tjerë prej kontakteve tuaj.
     Dërgo email-e dhe numra telefonash
     Sugjerime
     Përdorues të Ditur
@@ -2135,7 +2131,7 @@
     Përmendje dhe Fjalëkyçe
     Njoftime Parazgjedhje
     %s te Rregullimet, që të merrni ftesa drejt e në ${app_name}.
-    Lidheni këtë email me llogarinë tuaj
+    Lidheni këtë adresë email me llogarinë tuaj
     Kjo ftesë për te kjo hapësirë u dërgua te %s që s’është i përshoqëruar me llogarinë tuaj
     Kjo ftesë për te kjo dhomë qe dërguar për %s që s’është i përshoqëruar me llogarinë tuaj
     Krejt dhomat ku gjendeni do të shfaqen te Home.
@@ -2203,7 +2199,7 @@
     Hyrje në hapësirë
     Kush mund të hyjë\?
     Aktivizo njoftime me email për %s
-    Që të merrni email me njoftim, ju lutemi, përshoqërojini llogarisë tuaj Matrix një email
+    Që të merrni email me njoftim, ju lutemi, përshoqërojini llogarisë tuaj Matrix një adresë email
     Njoftim me email
     Të përmirësojë hapësirën
     Të ndryshojë emrin e hapësirës
@@ -2249,8 +2245,8 @@
     Pyetje ose temë pyetësori
     Krijoni Pyetësor
     A pranoni të dërgohen këto hollësi\?
-    Për të zbuluar kontakte ekzistuese, duhet të dërgoni hollësi kontakti (email-e dhe numra telefonash) te shërbyesi juaj i identiteteve. Para dërgimit, i fshehtëzojmë të dhënat tuaja, për privatësi.
-    Dërgo email-e dhe numra telefonash te %s
+    Për të zbuluar kontakte ekzistuese, duhet të dërgoni hollësi kontakti (adresa email dhe numra telefonash) te shërbyesi juaj i identiteteve. Para dërgimit, i fshehtëzojmë të dhënat tuaja, për privatësi.
+    Dërgo adresa email dhe numra telefonash te %s
     Kontaktet tuaja janë private. Për të zbuluar përdorues prej kontakteve tuaja, na duhet leja juaj për të dërguar hollësi kontakti te shërbyesi juaj i identiteteve.
     Është bërë dalja nga sesioni!
     U dol nga dhoma!
@@ -2355,7 +2351,7 @@
     Bashkësi
     Ekipe
     Shokë dhe familje
-    Do t’ju ndihmojmë të lidheni.
+    Do t’ju ndihmojmë të lidheni
     Me kë do të bisedoni më shumë\?
     Po e shihni tashmë këtë rrjedhë!
     Shiheni në Dhomë
@@ -2411,15 +2407,15 @@
     Shërbyesi Home s’pranon emër përdorues vetëm me shifra.
     Anashkalojeni këtë hap
     Ruajeni dhe vazhdoni
-    Parapëlqimet tuaja u ruajtën.
+    Kaloni te rregullimet, kur të doni, që të përditësoni profilin tuaj
     Kaq qe!
     Shkojmë
-    Këtë mund ta ndryshoni kurdo.
+    Erdh koha t’i jepet surrat emrit
     Shtoni një foto profili
     Këtë mund ta ndryshoni më vonë
     Emër Në Ekran
     Zgjidhni një emër për në ekran
-    Llogaria juaj %s u krijua.
+    Llogaria juaj %s u krijua
     Përgëzime!
     Shpjemëni në shtëpi
     Personalizoni profil
@@ -2450,4 +2446,411 @@
     Prani
     Mësoni më tepër
     Provojeni
-
+    Aktivizo shkurtore lejesh për Thirrje Element
+    S’u gjet metodë tjetër veç njëkohësimit në prapaskenë.
+    ${app_name}-it i duhet një fshehtinë e pastër, për të qenë i përditësuar, për arsyen vijuese:
+\n%s
+\n
+\nKini parasysh se ky veprim do të sjellë rinisjen e aplikacionit dhe mund të dojë ca kohë.
+    Regjistro emrin, versionin dhe URL-në e klientit, për të dalluar më kollaj sesionet te përgjegjës sesionesh.
+    Veprimtaria e fundit më %1$s
+    Apliko format me të nënvizuara
+    Apliko format me të hequravije
+    Apliko format me të pjerrta
+    Apliko format me të trasha
+    Ju lutemi, sigurohuni se e dini origjinën e këtij kodi. Duke lidhur pajisje, do t’i jepni dikujt hyrje të plotë në llogarinë tuaj.
+    Ripohojeni
+    Riprovoni
+    Pa përputhje\?
+    Po bëhet hyrja juaj
+    Po lidhet me pajisjen
+    Skanoni kodin QR
+    Po bëhet hyrja te një pajisje celulare\?
+    Shfaq kod QR te kjo pajisje
+    Përzgjidhni “Skanoni kod QR”
+    Filloja në skenën e hyrjes
+    Përzgjidhni “Hyni me kod QR”
+    Filloja në skenën e hyrjes
+    Përzgjidhni “Shfaq kod QR”
+    Kaloni te Rregullime -> Siguri & Privatësi
+    Hapeni aplikacionin në pajisjen tuaj tjetër
+    Hyrja u anulua në pajisjen tuaj tjetër.
+    Ai kod QR është i pavlefshëm.
+    Duhet bërë hyrja te pajisja tjetër.
+    Nga pajisja tjetër është bërë tashmë hyrja.
+    Kërkesa dështoi.
+    Kërkesa u hodh poshtë në pajisjen tjetër.
+    Lidhja me këtë pajisje nuk mbulohet.
+    Lidhje e pasuksesshme
+    U vendos lidhje e siguruar
+    Hyni me kod QR
+    Skanoni kodin QR
+    3
+    2
+    1
+    Provojeni
+    Prekeni djathtas në krye që të shihni mundësinë për dhënie përshtypjesh.
+    Jepni Përshtypje
+    Hyni në Hapësirat tuaja (poshtë djathtas) më shpejt dhe më kollaj se kurrë më parë.
+    Hyni Në Hapësira
+    Që të thjeshtohet ${app_name} juaj, skedat tanimë janë opsionale. Administrojini duke përdorur menunë djathtas në krye.
+    Mirë se vini te një pamje e re!
+    Ky është vendi ku do të shfaqen mesazhet tuaj të palexuar, kur të ketë të tillë.
+    S’ka gjë për ta raportuar.
+    Aplikacioni “all-in-one” i fjalosjeve të siguruara, për ekipe, shokë dhe ente. Që t’ia filloni, krijoni një fjalosje, ose hyni në një dhomë ekzistuese.
+    Mirë se vini te ${app_name},
+\n%s.
+    Hapësirat janë një mënyrë e re për të grupuar dhoma dhe persona. Shtoni një dhomë ekzistuese, ose krijoni një të re, duke përdorur butonin poshtë djathtas.
+    %s
+\nduket paksa si i zbrazët.
+    Jini në gjendje të incizoni dhe dërgoni transmetim zanor në rrjedhën kohore të dhomës.
+    Aktivizoni transmetim zanor (nën zhvillim aktiv)
+    Aktivizo regjistrim hollësish klienti
+    Shihini më qartë dhe kontrolloni më mirë krejt sesionet tuaj.
+    Aktivizo përgjegjës të ri sesionesh
+    Përdorues të tjerë në mesazhe të drejtpërdrejtë dhe dhoma ku hyni janë në gjendje të shohin një listë të plotë të sesioneve tuaj.
+\n
+\nKjo u jep atyre besim se po flasin vërtet me ju, por do të thotë gjithashtu që mund shohin emrin e sesionit që jepni këtu.
+    Riemërtim sesionesh
+    Sesionet e verifikuar përfaqësojnë sesione ku është bërë hyrja dhe janë verifikuar, ose duke përdorur togfjalëshin tuaj të sigurt, ose me verifikim.
+\n
+\nKjo do të thotë se zotërojnë kyçe fshehtëzimi për mesazhe tuajt të mëparshëm dhe u ripohojnë përdoruesve të tjerë, me të cilët po komunikoni, se këto sesione ju takojnë juve.
+    Sesione të verifikuar
+    Sesionet e paverifikuar janë sesione në të cilët është bërë hyrja me kredencialet tuaja, por pa u bërë verifikim.
+\n
+\nDuhet të jeni posaçërisht të qartë se i njihni këto sesione, ngaqë mund të përbëjnë përdorim të paautorizuar të llogarisë tuaj.
+    Sesione të paverifikuar
+    Sesioni joaktive janë sesione që keni ca kohë që s’i përdorni, por që vazhdojnë të marrin kyçe fshehtëzimi.
+\n
+\nHeqja e sesioneve joaktive përmirëson sigurinë dhe punimin dhe e bën më të lehtë për ju të pikasni nëse një sesion i ri është i dyshimtë.
+    Sesione joaktive
+    Mund të përdorni këtë pajisje për të bërë hyrjen në një pajisje celulare apo web me një kod QR. Për ta bërë këtë ka dy mënyra:
+    Hyni me Kod QR
+    Ju lutemi, kini parasysh se emrat e sesioneve janë të dukshëm edhe për personat me të cilët komunikoni.
+    Emra vetjakë sesionesh mund t’ju ndihmojnë të njihni më kollaj pajisjet tuaja.
+    Emër sesioni
+    Riemërtoni sesionin
+    Adresë IP
+    Sistem operativ
+    Model
+    Shfletues
+    URL
+    Version
+    Ëmër
+    Aplikacion
+    Veprimtaria e fundit
+    Emër sesioni
+    Merrni njoftime push për këtë sesion.
+    Njoftime Push
+    Hollësi aplikacioni, pajisjeje dhe veprimtarie.
+    Hollësi sesioni
+    Dilni nga ky sesion
+    Përzgjidhni sesione
+    Spastroje Filtrin
+    S’u gjetën sesione joaktive.
+    S’u gjetën seanca të paverifikuara.
+    S’u gjetën sesione të verifikuara.
+    
+        Shihni mundësinë e daljes nga sesione të vjetër (%1$d ditë ose më tepër) të cilët s’i përdorni më.
+        Shihni mundësinë e daljes nga sesione të vjetër (%1$d ditë ose më tepër) të cilët s’i përdorni më.
+    
+    Joaktive
+    Verifikoni sesionet tuaj, për shkëmbim më të sigurt mesazhesh, ose dilni prej atyre që nuk i njihni, apo përdorni më.
+    Të paverifikuar
+    Për sigurinë më të mirë, dilni nga çfarëdo sesioni që nuk e njihni apo përdorni më.
+    Të verifikuar
+    Filtroji
+    
+        Joaktiv për %1$d ditë, ose më gjatë
+        Joaktiv për %1$d ditë, ose më gjatë
+    
+    Jo aktiv
+    Jo gati për shkëmbim të sigurt mesazhesh
+    E paverifikuar
+    Gati për shkëmbim të sigurt mesazhesh
+    E verifikuar
+    Krejt sesionet
+    Filtroji
+    Pajisje
+    Sesion
+    Sesioni i Tanishëm
+    
+        Shihni mundësinë e daljes nga sesione të vjetër (%1$d ditë ose më tepër) të cilët s’i përdorni më.
+        Shihni mundësinë e daljes nga sesione të vjetër (%1$d ditë ose më tepër) të cilët s’i përdorni më.
+    
+    Sesione joaktive
+    Verifikojini, ose dilni nga sesione të paverifikuar.
+    Sesione të paverifikuar
+    Përmirësoni sigurinë e llogarisë tuaj duke ndjekur këto rekomandime.
+    Rekomandime sigurie
+    
+        Joaktiv për %1$d+ ditë (%2$s)
+        Joaktiv për %1$d+ ditë (%2$s)
+    
+    I paverifikuar · Sesioni juaj i tanishëm
+    I paverifikuar · Veprimtari së fundi më %1$s
+    I verifikuar · Veprimtaria e fundit më %1$s
+    Shihni Krejt (%1$d)
+    Shihni Hollësitë
+    Verifiko Sesion
+    Verifikoni sesionin tuaj të tanishëm, që të shfaqni gjendjen e verifikimit të këtij sesioni.
+    Për sigurinë dhe besueshmërinë më të mirë, verifikojeni, ose dilni nga ky sesion.
+    Verifikoni sesionin tuaj të tanishëm, për shkëmbim më të sigurt të mesazheve.
+    Ky sesion është gati për shkëmbim të sigurt mesazhesh.
+    Sesioni juaj i tanishëm është gati për shkëmbim të sigurt mesazhesh.
+    Gjendje e panjohur verifikimi
+    Sesion i paverifikuar
+    Sesion i verifikuar
+    Lloj i panjohur pajisjeje
+    Desktop
+    Web
+    Celular
+    Për sigurinë më të mirë, verifikoni sesionet tuaja dhe dilni nga çfarëdo sesioni që s’e njihni, ose s’e përdorni më.
+    Sesione të tjera
+    
+        U hoq %d mesazh
+        U hoqë %d mesazhe
+    
+    Aktivizoni tregim vendndodhjeje
+    Ju lutemi, kini parasysh: kjo është një veçori në zhvillim, që përdor një sendërtim të përkohshëm. Kjo do të thotë se s’do të jeni në gjendje të fshini historikun e vendndodhjeve tuaja dhe përdoruesit e përparuar do të jenë në gjendje të shohin historikun e vendndodhjeve tuaja, edhe pasi të keni ndalur dhënien “live” për këtë dhomë të vendndodhjes tuaj.
+    Tregim “live” vendndodhjeje
+    Kanal i tanishëm: %s
+    Kanal
+    S’gjendet pikëmbarimi.
+    Pikëmbarim i tanishëm: %s
+    Pikëmbarim
+    Hëpërhë po përdoret %s.
+    Metodë
+    
+        U gjet %d metodë.
+        U gjetën %d metoda.
+    
+    S’u gjet metodë tjetër veç Google Play Service.
+    Metoda të gatshme
+    Metodë njoftimi
+    Njëkohësim në prapaskenë
+    Shërbime Google
+    Zgjidhni si të merren njoftime
+    Tregimi i ekranit është në punë e sipër
+    Tregim Ekrani ${app_name}
+    Kontakt
+    Kamerë
+    Vendndodhje
+    Pyetësorë
+    Transmetim zanor
+    Bashkëngjitje
+    Ngjitës
+    Fototekë
+    Nisni një transmetim zanor
+    Vendndodhje drejtpërsëdrejti
+    Jepe vendndodhjen
+    Që të mund të ndani drejtpërsëdrejti vendndodhje me të tjerë në këtë dhomë, lypset të keni lejet e duhura.
+    S’keni leje të tregoni vendndodhje drejtpërsëdrejti
+    Përditësuar %1$s më parë
+    Sendërtim i përkohshëm: vendndodhjet mbeten në historikun e dhomës
+    Aktivizo Tregim Vendndodhjeje “Live”
+    Vendndodhje Drejtpërsëdrejti ${app_name}
+    Edhe %1$s
+    “Live” deri më %1$s
+    Shihni vendndodhje “live”
+    Tregimi “live” i vendndodhjes përfundoi
+    Po ngarkohet vendndodhje “live”…
+    S’arrihet të ngarkohet hartë
+\nKy shërbyes Home mund të mos jetë formësuar të shfaqë harta.
+    Përfundimet do të jenë të dukshme pasi të ketë përfunduar pyetësori
+    Kur bëhet ftesë në një dhomë të fshehtëzuar që ka historik ndarjesh me të tjerët, historiku i fshehtëzuar do të jetë i dukshëm.
+    
+    Ndal transmetim zanor
+    Luani ose vazhdoni luajtje transmetimi zanor
+    Ndal incizim transmetimi zanor
+    Ndal incizim transmetimi zanor
+    Vazhdo incizim transmetimi zanor
+    Drejtpërdrejt
+    Shfaq hollësitë më të reja të përdoruesit
+    Disa përfundime mund të jenë të fshehura, ngaqë janë private dhe ju duhet një ftesë për to.
+    S’u gjetën përfundime
+    Mos braktis ndonjë
+    Braktisi krejt
+    Gjëra në këtë hapësirë
+    I zënë
+    Hap rregullimet
+    S’u aktivizua dot mirëfilltësim biometrik.
+    Mirëfilltësimi biometrik qe çaktivizuar ngaqë tani së fundi është shtuar një metodë e re mirëfilltësimi biometrik. Mund ta riaktivizoni që nga Rregullimet.
+    S’mund të garantohet mirëfilltësia e këtij mesazhi të fshehtëzuar në këtë pajisje.
+    Tastierë inkonjito
+    Dërgoni mesazhin tuaj të parë për të ftuar në fjalosje %s
+    Mesazhet në këtë fjalosje do të jenë të fshehtëzuar skaj-më-skaj.
+    S’do të jeni në gjendje të shihni historikun e mesazheve të fshehtëzuara. Që t’ia rifilloni nga e para, ricaktoni kyçet tuaja për Kopjeruajtje të Sigurt Mesazhesh dhe kyçe verifikimi.
+    S’arrihet të verifikohet kjo pajisje
+    Sesione
+    Tregoi vendndodhjen e vet drejtpërsëdrejti
+    E paraprin një mesazh tekst i thjeshtë me (╯°□°)╯︵ ┻━┻
+    S’hapet dot kjo lidhje: bashkësitë janë zëvendësuar nga hapësirat
+    Skanoni kodin QR
+    Emër përdoruesi / Email / Telefon
+    Jeni qenie njerëzore\?
+    Ndiqni udhëzimet e dërguara te %s
+    Ricaktim fjalëkalimi
+    Harrova fjalëkalimin
+    Ridërgo email
+    S’morët email\?
+    Ndiqni udhëzimet e dërguara te %s
+    Verifikoni email-in tuaj
+    Ridërgomëni kodin
+    Te %s u dërgua një kod
+    Ripohoni numrin e telefonit tuaj
+    Dil nga krejt pajisjet
+    Ricaktoni fjalëkalimin
+    Sigurohuni të jetë 8 ose më shumë shenja.
+    Zgjidhni një fjalëkalim të ri
+    Fjalëkalim i Ri
+    Kontrolloni email-in tuaj.
+    %s do t’ju dërgojë një lidhje verifikimi
+    Kod ripohimi
+    Numër Telefoni
+    %s lyp verifikimin e llogarisë tuaj
+    Jepni numrin e telefonit tuaj
+    Email
+    %s lyp verifikimin e llogarisë tuaj
+    Jepni email-in tuaj
+    Ju lutemi, lexoni kushte dhe rregulla të %s
+    Rregulla shërbyesi
+    Lidhuni
+    Element Matrix Services (EMS) është një shërbim strehimi i fuqishëm dhe i besueshëm, për komunikim të shpejtë, të sigurt dhe të atypëratyshëm. Shihni më tepër se si, teelement.io/ems
+    Doni të strehoni shërbyesin tuaj\?
+    URL Shërbyesi
+    Cila është adresa e shërbyesit tuaj\?
+    Cila është adresa e shërbyesit tuaj\? Kjo është si një shtëpi për krejt të dhënat tuaja
+    Përzgjidhni shërbyesin tuaj
+    Mirë se u kthyet!
+    Përpunojeni
+    Ose
+    Ku gjenden bisedat tuaja
+    Ku do të gjenden bisedat tuaja
+    Duhet të jetë 8 ose më shumë shenja
+    Të tjerët mund t’ju zbulojnë %s
+    Krijoni llogarinë tuaj
+    Transmetim Zanor
+    Hap listë hapësirash
+    Krijoni një bisedë ose dhomë të re
+    Ricaktoni metodë njoftimesh
+    Të aktivizuara:
+    Etiketë profili:
+    ID sesioni:
+    Jepi
+    Po përditësohen të dhënat tuaja…
+    Diç shkoi ters. Ju lutemi, kontrolloni lidhjen tuaj në rrjet dhe riprovoni.
+    Persona
+    Të parapëlqyera
+    Të palexuara
+    Krejt
+    Kopjeruajtja ka një nënshkrim të vlefshëm prej këtij përdoruesi.
+    Hap skenën e mjeteve të zhvilluesit
+    Na ndjeni, kjo dhomë s’u gjet.
+\nJu lutemi, riprovoni më vonë.%s
+    Përdor parazgjedhje sistemi
+    Zgjidheni dorazi
+    Caktoje vetvetiu
+    Zgjidhni madhësi shkronjash
+    ⚠ Në këtë dhomë ka pajisje të paverifikuara, ato s’do të jenë në gjendje të shfshehtëzojnë mesazhet që dërgoni.
+    Mos dërgo kurrë prej këtij sesioni mesazhe të fshehtëzuar te sesione të paverifikuar në këtë dhomë.
+    Figurat e animuara vetëluaji
+    S’u arrit të regjistrohej token pikëmbarimi te shërbyesi Home:
+\n%1$s
+    Pikëmbarim i regjistruar me sukses te shërbyesi Home.
+    Regjistrim Pikëmbarimi
+    Akordojini Leje
+    ${app_name} lyp lejen për shfaqje njoftimesh.
+\nJu lutemi, akordoni lejen.
+    
+        %1$s dhe %2$d tjetër
+        %1$s dhe %2$d të tjerë
+    
+    %1$s dhe %2$s
+    ${app_name} lyp leje të shfaqë njoftime. Njoftimet mund të shfaqin mesazhet tuaja, ftesa tuajat, etj.
+\n
+\nJu lutemi, lejoni përdorimin e tyre te flluska pasuese, që të jeni në gjendje të shihni njoftime.
+    Email jo i verifikuar, kontrolloni te Të marrët tuaj
+    Reshtni tregimin e ekranit tuaj
+    Tregojuani ekranin të tjerëve
+    Ky është vendi ku do të gjenden kërkesat dhe ftesat tuaja të reja.
+    S’ka gjë të re.
+    Ftesa
+    Hapësirat janë një mënyrë e re për të grupuar dhoma dhe njerëz. Që t’ia filloni, krijoni një hapësirë.
+    Ende pa hapësira.
+    Provoni përpunuesin e teksteve të pasur (për tekst të thjeshtë vjen së shpejti)
+    Aktivizo përpunues teksti të pasur
+    Krijo MD vetëm për mesazhin e parë
+    Një Element i thjeshtuar, me skeda opsionale
+    Aktivizo skemë të re
+    A - Z
+    Veprimtari
+    Renditi sipas
+    Shfaq të freskëta
+    Shfaq filtra
+    Parapëlqime skeme grafike
+    Shpërzgjidhi krejt
+    Përzgjidhi krejt
+    E mora
+    Më pas
+    Rifillo
+    sek
+    min
+    h
+    - Për disa përdorues u hoq shpërfillja
+    Kërkesë njëkohësimi fillestar
+    Eksploroni Dhoma
+    Ndërroni Hapësire
+    Krijo Dhomë
+    Filloni Fjalosje
+    Krejt Fjalosjet
+    
+        %1$d i përzgjedhura
+        %1$d të përzgjedhura
+    
+    Shërbyesi Home nuk mbulon hyrje me kod QR.
+    U has një problem sigurie, kur ujdisej shkëmbim i siguruar mesazhesh. Mund të jetë komprometuar një nga sa vijon: shërbyesi juaj Home; lidhja(et) tuaja internet; pajisja(et) tuaja;
+    Lidhja s’u plotësua në kohën e duhur.
+    Kontrolloni pajisjen ku jeni i futur, duhet të shfaqet kodi më poshtë. Sigurohuni se kodi më poshtë përputhet me atë pajisje:
+    Skanoni kodin QR më poshtë me pajisjen tuaj prej nga është dalë nga llogaria.
+    Përdorni pajisjen tuaj ku jeni brenda llogarisë që të skanoni kodin QR më poshtë:
+    Përdorni kamerën në këtë pajisje që të skanoni kodin QR të shfaqur në pajisjen tuaj tjetër:
+    Mirato vetvetiu widget-e Thirrjesh Element Call dhe akordo përdorim kamere / mikfrofoni
+    MSC3061: Po jepen kyçe dhome për mesazhe të dikurshëm
+    Shfaq hollësitë më të reja të profileve (avatar dhe emër në ekran) për krejt mesazhet.
+    Kërko doemos që tastiera të mos përditësojë ndonjë të dhënë të personalizuar, bie fjala, historik shtypjeje në të dhe fjalor bazuar në ç’keni shtypur në biseda. Kini parasysh se disa tastiera mund të mos e respektojnë këtë rregullim.
+    Ky kod QR duket i formuar keq. Ju lutemi, provoni ta verifikoni me tjetër metodë.
+    🔒 Keni aktivizuar fshehtëzim për sesionie të verifikuar vetëm për krejt dhomat, që nga Rregullime Sigurie.
+    Luaj figura të animuara te rrjedha kohora sapo zënë të duken
+    krijoi një pyetësor.
+    dërgoi një ngjitës.
+    dërgoi një video.
+    dërgoi një figurë.
+    dërgoi një mesazh zanor.
+    dërgoi një kartelë audio.
+    dërgoi një kartelë.
+    Në përgjigje të
+    Hyni/Dilni nga mënyra “Sa krejt ekrani”
+    Sesionet e verifikuar janë kudo ku përdorni këtë llogari pas dhënies së frazëkalimit tuaj, apo ripohimit të identitetit tuaj me një sesion tjetër të verifikuar.
+\n
+\nKjo do të thotë se keni krejt kyçet e nevojshëm për të shkyçur mesazhet tuaj të fshehtëzuar dhe për të ripohuar se e besoni këtë sesion.
+    Fshihe adresën IP
+    Shfaq adresë IP
+    
+        Dilni nga %1$d sesion
+        Dilni nga %1$d sesione
+    
+    Dilni
+    Formatim teksti
+    Edhe %1$s
+    Jeni duke incizuar tashmë një transmetim zanor. Ju lutemi, që të nisni një të ri, përfundoni transmetimin tuaj aktual zanor.
+    Dikush tjetër është tashmë duke incizuar një transmetim zanor. Prisni që të përfundojë transmetimi zanor i tij, pa të filloni një të ri.
+    S’keni lejet e domosdoshme për të nisur një transmetim zanor në këtë dhomë. Lidhuni me një përgjegjës dhome që të përmirësojë lejet tuaja.
+    S’mund të niset një transmetim i ri zanor
+    Shtyrje përpara 30 sekonda
+    Kthim prapa 30 sekonda
+    Si përgjigje për %s
+    Aktivizo MD të lënë për më vonë
+    Tkurr pjella të %s
+    Zgjero pjella të %s
+
\ No newline at end of file
diff --git a/library/ui-strings/src/main/res/values-sv/strings.xml b/library/ui-strings/src/main/res/values-sv/strings.xml
index eaa327e977..45cfe4338b 100644
--- a/library/ui-strings/src/main/res/values-sv/strings.xml
+++ b/library/ui-strings/src/main/res/values-sv/strings.xml
@@ -2836,4 +2836,34 @@
         %1$d vald
         %1$d valda
     
+    Växla fullskärmsläge
+    Verifierade sessioner är alla ställen där du använder det här kontot efter att ha angett din lösenfras eller bekräftat din identitet med en annan verifierad session.
+\n
+\nDetta betyder att du har alla nycklar som krävs för att låsa upp dina krypterade meddelanden att bekräfta för andra användare att du litar på den här sessionen.
+    
+        Logga ut ur %1$d session
+        Logga ut ur %1$d sessioner
+    
+    Logga ut
+    Textformatering
+    Du spelar redan in en röstsändning. Avsluta din nuvarande röstsändning för att starta en ny.
+    Någon annan spelar redan in en röstsändning. Vänta på att deras röstsändning avslutas för att starta en ny.
+    Du är inte behörig att starta en ny röstsändning i det här rummet. Kontakta en rumsadministratör för att uppgradera dina behörigheter.
+    Kan inte starta en ny röstsändning
+    Spola framåt 30 sekunder
+    Spola tillbaka 30 sekunder
+    skickade en omröstning.
+    skickade en dekal.
+    skickade en video.
+    skickade en bild.
+    skickade ett röstmeddelande.
+    skickade en ljudfil.
+    skickade en fil.
+    Svar på
+    Dölj IP-adress
+    Visa IP-adress
+    %1$s kvar
+    Citerar
+    Besvarar %s
+    Redigerar
 
\ No newline at end of file
diff --git a/library/ui-strings/src/main/res/values-uk/strings.xml b/library/ui-strings/src/main/res/values-uk/strings.xml
index f633a0ef2f..6a1c5355ab 100644
--- a/library/ui-strings/src/main/res/values-uk/strings.xml
+++ b/library/ui-strings/src/main/res/values-uk/strings.xml
@@ -2839,12 +2839,12 @@
     Перейменувати сеанс
     Вийти з цього сеансу
     Не звірений - Ваш поточний сеанс
-    Розпочати трансляцію голосового повідомлення
+    Розпочати голосову трансляцію
     Справжність цього зашифрованого повідомлення не може бути гарантована на цьому пристрої.
     Заборонити клавіатурі оновлювати будь-які персоналізовані дані, як-от історію набору тексту та словник, на основі того, що ви набрали в розмовах. Зверніть увагу, що деякі клавіатури можуть не дотримуватися цього налаштування.
     Клавіатура інкогніто
     Надсилає (╯°□°)╯︵ ┻━┻ на початку текстового повідомлення
-    Голосові повідомлення
+    Голосові трансляції
     Відкрийте інструменти розробника
     🔒 Ви увімкнули шифрування лише для перевірених сеансів для всіх кімнат у налаштуваннях безпеки.
     ⚠ У цій кімнаті є неперевірені пристрої, вони не зможуть розшифрувати повідомлення, які ви надсилаєте.
@@ -2920,21 +2920,21 @@
     Вхід з іншого пристрою вже виконано.
     Під час налаштування захищеного обміну повідомленнями виникла проблема з безпекою. Можливо, порушено одне з таких налаштувань: Ваш домашній сервер; Ваше інтернет-з\'єднання; Ваш пристрій;
     Запит не виконаний.
-    Можливість записувати та надсилати голосові повідомлення до стрічки кімнати.
-    Увімкнути голосові повідомлення (в активній розробці)
-    Буферизація
-    Призупинити голосове повідомлення
-    Відтворити або поновити відтворення голосового повідомлення
-    Припинити запис голосового повідомлення
-    Призупинити запис голосового повідомлення
-    Відновити запис голосового повідомлення
+    Можливість записувати та надсилати голосові трансляції до стрічки кімнати.
+    Увімкнути голосові трансляції (в активній розробці)
+    Буферизація…
+    Призупинити голосову трансляцію
+    Відтворити або поновити відтворення голосової трансляції
+    Припинити запис голосової трансляції
+    Призупинити запис голосової трансляції
+    Відновити запис голосової трансляції
     Наживо
     Вибрати сеанси
     Контакт
     Камера
     Місце перебування
     Опитування
-    Голосові повідомлення
+    Голосові трансляції
     Вкладення
     Наліпки
     Фотобібліотека
@@ -2946,4 +2946,38 @@
         Вибрано %1$d
     
     Вибрати все
+    Перемкнути повноекранний режим
+    Форматування тексту
+    Ви вже записуєте голосову трансляцію. Завершіть поточну трансляцію, щоб розпочати нову.
+    Хтось інший вже записує голосову трансляцію. Зачекайте, поки вона завершиться, щоб розпочати нову.
+    Ви не маєте необхідних дозволів для початку голосової трансляції в цю кімнату. Зверніться до адміністратора кімнати, щоб оновити ваші дозволи.
+    Не вдалося розпочати нову голосову трансляцію
+    Перемотати вперед на 30 секунд
+    Перемотати назад на 30 секунд
+    Звірені сеанси — це будь-який пристрій, на якому ви використовуєте цей обліковий запис після введення парольної фрази або підтвердження вашої особи за допомогою іншого звіреного сеансу.
+\n
+\nЦе означає, що ви маєте всі ключі, необхідні для розблокування ваших зашифрованих повідомлень і підтвердження іншим користувачам, що ви довіряєте цьому сеансу.
+    
+        Вийти з %1$d сеансу
+        Вийти з %1$d сеансів
+        Вийти з %1$d сеансів
+        Вийти з %1$d сеансів
+    
+    Вийти
+    Залишилося %1$s
+    надсилає аудіофайл.
+    надсилає файл.
+    У відповідь на
+    Сховати IP-адресу
+    створює опитування.
+    надсилає наліпку.
+    надсилає відео.
+    надсилає зображення.
+    надсилає голосове повідомлення.
+    Показати IP-адресу
+    Цитуючи
+    У відповідь %s
+    Редагування
+    Показувати останні бесіди в системному меню загального доступу
+    Увімкнути пряме поширення
 
\ No newline at end of file
diff --git a/library/ui-strings/src/main/res/values-zh-rCN/strings.xml b/library/ui-strings/src/main/res/values-zh-rCN/strings.xml
index 688652265b..0a01610c36 100644
--- a/library/ui-strings/src/main/res/values-zh-rCN/strings.xml
+++ b/library/ui-strings/src/main/res/values-zh-rCN/strings.xml
@@ -30,7 +30,7 @@
     发送者的设备没有向我们发送此消息的密钥。
     无法发送消息
     Matrix 错误
-    电子邮箱地址
+    电子邮件地址
     手机号码
     %1$s 撤回了对 %2$s 的邀请
     %1$s 让未来的房间历史记录对 %2$s 可见
@@ -214,8 +214,8 @@
     登录
     提交
     错误的用户名和/或密码
-    此电子邮箱地址似乎无效
-    此电子邮箱地址已被使用。
+    此电子邮件地址似乎无效
+    此电子邮件地址已被使用。
     忘记密码?
     请输入有效的 URL
     没有包含有效的 JSON
@@ -228,7 +228,7 @@
     搜索
     过滤房间成员
     没有结果
-    添加电子邮箱地址
+    添加电子邮件地址
     添加手机号码
     版本
     olm 版本
@@ -257,7 +257,7 @@
     开始视频通话
     拍摄照片或视频
     此主服务器想确认你不是机器人
-    电子邮箱地址验证失败:请确保你已点击邮件中的链接
+    电子邮件地址验证失败:请确保你已点击邮件中的链接
     原始
     通话正在连接……
     ${app_name} 需要权限以访问你的麦克风来进行语音通话。
@@ -348,11 +348,11 @@
     显示系统设置中的应用程序信息。
     通话请求
     使用条款
-    其他
+    其它
     通知目标
     登录为
-    请检查你的电子邮箱并点击里面包含的链接。完成时请点击继续。
-    此电子邮箱地址已被使用。
+    请检查你的电子邮件并点击里面包含的链接。完成时请点击继续。
+    此电子邮件地址已被使用。
     此手机号码已被使用。
     设置为主要地址
     取消设置为主要地址
@@ -434,7 +434,7 @@
     你添加了一个新会话“%s”,它正在请求加密密钥。
     你的未验证会话“%s”正在请求加密密钥。
     开始验证
-    bug报告
+    错误报告
     拍摄照片
     拍摄视频
     使用原生相机
@@ -828,7 +828,7 @@
     撤消
     断开连接
     拒绝
-    这不是有效的Matrix服务器地址
+    这不是有效的 Matrix 服务器地址
     无法在此 URL 找到主服务器,请检查
     播放
     忽略
@@ -990,13 +990,13 @@
     更改身份服务器
     你正在使用 %1$s 与你知道的现有联系人相互发现。
     你当前未使用身份服务器。若要与你知道的现有联系人相互发现,请在下方配置。
-    可发现电子邮件地址
+    可发现的电子邮件地址
     发现选项将在你添加电子邮件地址后出现。
     发现选项将在你添加电话号码后出现。
-    与你的身份服务器断开意味着你将无法被其它用户发现并且无法通过电子邮件和电话邀请他人。
+    与您的身份服务器断开连接意味着您将不会被其他用户发现,并且您将无法通过电子邮件或电话邀请其他人。
     可发现电话号码
     我们向%s发送了一封电子邮件,请检查你的电子邮件并点击确认链接
-    我们向%s发送了电子邮件,请先检查你的电子邮件并点击确认链接
+    我们向 %s 发送了一封电子邮件,请先检查您的电子邮件并点击确认链接
     输入身份服务器 URL
     无法连接到身份服务器
     请输入身份服务器 url
@@ -1007,7 +1007,7 @@
     您当前在身份服务器 %1$s 上共享电子邮件地址或电话号码。您需要重新连接到 %2$s 才能停止共享它们。
     同意身份服务器 (%s) 服务条款使你可以通过电子邮件地址或电话号码被发现。
     启用详细日志。
-    详细日志将通过在您发送 RageShake 时提供更多日志来帮助开发人员。即使启用,应用程序也不会记录消息内容或任何其他私人数据。
+    详细日志将通过在您发送愤怒摇动(RageShake)时提供更多日志来帮助开发人员。即使启用,应用程序也不会记录消息内容或任何其他私人数据。
     接收你的主服务器条款和条件后请重试。
     服务器似乎响应时间太长,这可能是由于连接不良或服务器错误引起的。请稍后再试。
     发送附件
@@ -1143,14 +1143,14 @@
     输入验证码
     重新发送
     下一个
-    国际电话号码必须以 ‘+’ 开头
+    国际电话号码必须以“+”开头
     电话号码似乎无效。请检查
     在 %1$s 上注册
     用户名或电子邮件
     用户名
     密码
     下一个
-    用户名已占用
+    该用户名已被使用
     警告
     你的账户尚未创建。是否中止注册过程?
     选择 matrix.org
@@ -1171,7 +1171,7 @@
     如果你在主服务器上设置了账户,在下方使用你的 Matrix ID(例 @user:domain.com)和密码。
     Matrix ID
     如果你不知道你的密码,返回并重置。
-    这不是一个有效的用户标识符。期望的格式:\'@user:homeserver.org\'
+    这不是有效的用户标识符。预期格式:\'@user:homeserver.org\'
     无法找到有效的主服务器。请检查你的标识符
     你已登出
     这可能由于多种原因:
@@ -1199,13 +1199,13 @@
     除非你登录以恢复加密密钥,否则你将无法访问安全消息。
     当前会话用于用户 %1$s 而你提供了用户 %2$s 的凭证。${app_name} 不支持此功能。
 \n请先清除数据,然后重新登录另一个账户。
-    你的 matrix.to 链接更是不正确
+    您的 matrix.to 链接格式错误
     描述太短
     初始同步…
     高级设置
     开发者模式
     开发者模式激活隐藏的功能,也可能使应用不稳定。仅供开发者使用!
-    摇一摇
+    愤怒摇动(Rageshake)
     检测阈值
     摇动手机以测试检测阈值
     检测到摇动!
@@ -1213,7 +1213,7 @@
     当前会话
     其它会话
     仅显示第一个结果,请输入更多字符…
-    快速失败
+    快速失败(Fail-fast)
     发生意外错误时,${app_name} 可能更经常崩溃
     在明文消息前添加 ¯\\_(ツ)_/¯
     启用加密
@@ -1531,7 +1531,7 @@
     你无法访问此消息因为发送者有意不发送密钥
     正在等待加密历史
     Riot 现已成为 Element!
-    我们很高兴地宣布我们改名了!你的应用已经更新到最新版本,并且你已登录你的账户。
+    我们很高兴地宣布我们已经更名了!您的应用程序是最新的,并且您已登录到您的帐户。
     明白了
     了解更多
     将恢复密钥保存到
@@ -1588,9 +1588,9 @@
     移除 %s?
     请确认你已点击我们向你发送的电子邮件中的链接。
     电子邮件和电话号码
-    管理链接到你的Matrix账户的电子邮件地址和电话号码
+    管理与您的 Matrix 帐户链接的电子邮件地址和电话号码
     代码
-    请使用国际格式(电话号码必须以“+”开始)
+    请使用国际格式(电话号码必须以“+”开头)
     验证此登录来确认你的身份,授权其访问加密消息。
     无法打开你被封禁的房间。
     无法找到此房间。请确认它存在。
@@ -1714,7 +1714,7 @@
     建议
     已知用户
     二维码
-    通过QR码添加
+    通过二维码添加
     房间设置
     话题
     房间话题(可选)
@@ -1804,7 +1804,7 @@
     
         %d 个条目
     
-    不是有效的 Matrix 二维码
+    这不是有效的 Matrix 二维码
     扫描二维码
     添加人员
     邀请朋友
@@ -2099,15 +2099,15 @@
     我的用户名
     我的显示名称
     通知事项
-    其他
+    其它
     提及和关键词
     默认通知
     可用视频通话
     可用语音通话
     在 ${app_name} 中直接接收邀请的设置 %s。
-    将此电子邮件地址与您的帐户相关联
-    加入这个空间的邀请被发送至 %s,此邮箱未与您的账户相关联
-    加入这个房间的邀请被发送至 %s,此邮箱未与您的账户相关联
+    将此电子邮件地址与您的帐户链接
+    此空间的邀请已发送至与您的帐户无关的 %s
+    此房间的邀请已发送至与您的帐户无关的 %s
     你所在的全部房间将显示在主页上。
     在主页上显示所有房间
     滑动结束通话
@@ -2171,7 +2171,7 @@
     空间访问
     谁可以访问?
     为 %s 启用电子邮件通知
-    要接收通知邮件,请将一个电子邮件地址关联到你的Matrix账户
+    要接收带有通知的电子邮件,请将电子邮件地址链接到您的 Matrix 帐户
     电子邮件通知
     升级空间
     更改空间名称
@@ -2303,7 +2303,7 @@
     BETA
     共享你的实时位置
     缩放到当前位置
-    地图上选定位置的图钉
+    地图上选定位置的固定标记
     无投票
     验证你的电子邮件
     
@@ -2336,12 +2336,12 @@
     共享位置
     您需要拥有正确的权限才能在此房间中共享实时位置。
     你没有权限共享实时位置
-    %1$s前已更新
+    %1$s 前已更新
     临时执行:地点在房间历史中持续存在
     启用实时位置共享
     位置共享正在进行中
-    ${app_name}实时位置
-    剩余%1$s
+    ${app_name} 实时位置
+    剩余 %1$s
     停止
     实时共享直到 %1$s
     查看实时位置
@@ -2350,14 +2350,14 @@
     启用实时位置
     加载地图失败
     打开,用
-    ${app_name}无法访问你的位置。请稍后再试。
-    ${app_name}无法访问你的位置
+    ${app_name} 无法访问你的位置。请稍后再试。
+    ${app_name} 无法访问你的位置
     在房间中查看
     MSC3061:为过去的消息共享房间密钥
     在共享历史的加密房间中邀请时,加密历史将可见。
-    8小时
-    1小时
-    15分钟
+    8 小时
+    1 小时
+    15 分钟
     共享此位置
     共享此位置
     共享实时位置
@@ -2409,7 +2409,7 @@
     发送图片和视频
     打开相机
     服务器政策
-    Element Matrix Services(EMS)是一个健壮且可靠的主机托管服务,可实现快速、安全和实时的通信。在<a href=\"${ftue_ems_url}\">element.io/ems</a>上了解如何使用
+    Element Matrix Services (EMS) 是一种强大且可靠的托管服务,可实现快速、安全和实时的通信。 了解如何在 <a href=\"${ftue_ems_url}\">element.io/ems</a>
     想架设自己的服务器?
     服务器URL
     选择你的服务器
@@ -2539,13 +2539,13 @@
     自动允许 Element 通话小部件并授予相机/麦克风访问权限
     启用 Element 通话权限快捷方式
     实时位置
-    这个QR码看起来不正常。请尝试用另一个方法验证。
+    此二维码看起来格式不正确。请尝试使用其它方法进行验证。
     你无法访问加密消息历史。重置你的安全消息备份和验证密钥以重新开始。
     无法验证此设备
     你的服务器地址是什么?
     你的对话发生的地方
     %1$s 和 %2$s
-    电子邮件未确认,检查你的收件箱
+    电子邮件未验证,请检查您的收件箱
     无法加载地图
 \n此主服务器可能没有设置好显示地图。
     打开设置
@@ -2562,7 +2562,7 @@
     A—Z
     活动
     排序方式
-    显示最近的
+    显示最近
     显示过滤条件
     布局偏好
     探索房间
@@ -2622,7 +2622,7 @@
     你当前的会话已准备好安全地收发消息。
     仅在首条消息创建私聊消息
     启用延迟的私聊消息
-    简化的Element,带有可选的标签
+    简化的 Element,带有可选的标签
     无痕键盘
     请求键盘不要根据您在对话中输入的内容更新任何个性化数据,例如输入历史记录和字典。 请注意,某些键盘可能不遵守此设置。
     ${app_name}需要权限来显示通知。通知可以显示消息、邀请等。
@@ -2694,7 +2694,7 @@
     验证您当前的会话以显示此会话的验证状态。
     未知的验证状态
     开始语音广播
-    缓冲
+    正在缓冲……
     暂停语音广播
     实时
     知道了
@@ -2762,9 +2762,46 @@
     停止语音广播录制
     暂停语音广播录制
     继续语音广播录制
-    扫描QR码
+    扫描二维码
     语音广播
     已启用:
     会话ID:
     出了点差错。请检查您的网络连接并重试。
+    联系人
+    切换全屏模式
+    选择会话
+    文本格式
+    相机
+    位置
+    投票
+    语音广播
+    附件
+    贴纸
+    照片库
+    您没有在此房间内开始语音广播所需的权限。联系房间管理员升级您的权限。
+    其他人已经在录制语音广播。等待他们的语音广播结束以开始新的广播。
+    您已经在录制语音广播。请结束您当前的语音广播以开始新的语音广播。
+    无法开始新的语音广播
+    快进 30 秒
+    快退 30 秒
+    取消全选
+    全选
+    
+        已选择 %1$d
+    
+    已创建投票。
+    已发送贴纸。
+    已发送视频。
+    已发送图片。
+    已发送语音消息。
+    已发送音频文件。
+    已发送文件。
+    已验证的会话是在输入你的口令词组或用另一个已验证的会话确认你的身份之后你使用此账户的任何地方。
+\n
+\n这意味着你拥有解锁你的已加密消息和向其他用户证明你信任此会话所需的全部密钥。
+    
+        登出%1$d个会话
+    
+    登出
+    剩余%1$s
 
\ No newline at end of file
diff --git a/library/ui-strings/src/main/res/values-zh-rTW/strings.xml b/library/ui-strings/src/main/res/values-zh-rTW/strings.xml
index 739ea09755..9a5439b2ae 100644
--- a/library/ui-strings/src/main/res/values-zh-rTW/strings.xml
+++ b/library/ui-strings/src/main/res/values-zh-rTW/strings.xml
@@ -2760,7 +2760,7 @@
     請求失敗。
     可以在聊天室時間軸中錄製並傳送語音廣播。
     啟用語音廣播(正在積極開發中)
-    正在緩衝
+    正在緩衝……
     暫停語音廣播
     播放或繼續語音廣播
     停止語音廣播錄製
@@ -2781,4 +2781,35 @@
     
         已選取 %1$d
     
+    切換全螢幕模式
+    文字格式化
+    您已在錄製語音廣播。請結束您目前的語音廣播以開始新的。
+    其他人已在錄製語音廣播。等待他們的語音廣播結束以開始新的。
+    您沒有在此聊天室中開始語音廣播的必要權限。請聯絡聊天室管理員以升級您的權限。
+    無法開始新的語音廣播
+    快轉30秒
+    快退30秒
+    已驗證的工作階段是您輸入通關密語或透過另一個已驗證工作階段確認您的身份後使用此帳號的任何地方。
+\n
+\n這代表了您擁有解鎖加密訊息並向其他使用者確認您信任此工作階段所需的所有金鑰。
+    
+        登出 %1$d 個工作階段
+    
+    登出
+    剩餘 %1$s
+    已建立投票。
+    已傳送貼圖。
+    已傳送影片。
+    已傳送圖片。
+    已傳送語音訊息。
+    已傳送音訊檔。
+    已傳送檔案。
+    回覆給
+    隱藏 IP 位置
+    顯示 IP 位置
+    引用
+    回覆給 %s
+    正在編輯
+    在系統分享選單中顯示最近聊天
+    啟用直接分享
 
\ No newline at end of file
diff --git a/library/ui-strings/src/main/res/values/donottranslate.xml b/library/ui-strings/src/main/res/values/donottranslate.xml
index 741d23dbc6..bfe751ef5a 100755
--- a/library/ui-strings/src/main/res/values/donottranslate.xml
+++ b/library/ui-strings/src/main/res/values/donottranslate.xml
@@ -2,6 +2,7 @@
 
 
     
+    
 
     
     Not implemented yet in ${app_name}
diff --git a/library/ui-strings/src/main/res/values/strings.xml b/library/ui-strings/src/main/res/values/strings.xml
index a5ee918fb5..822b9dd901 100644
--- a/library/ui-strings/src/main/res/values/strings.xml
+++ b/library/ui-strings/src/main/res/values/strings.xml
@@ -134,6 +134,9 @@
     ** Unable to decrypt: %s **
     The sender\'s device has not sent us the keys for this message.
 
+    %1$s ended a voice broadcast.
+    You ended a voice broadcast.
+
     
 
     
@@ -416,6 +419,7 @@
     Got it
     Select all
     Deselect all
+    Yes, Stop
 
     Copied to clipboard
 
@@ -1032,6 +1036,8 @@
     Use /confetti command or send a message containing ❄️ or 🎉
     Autoplay animated images
     Play animated images in the timeline as soon as they are visible
+    Enable direct share
+    Show recent chats in the system share menu
     Show join and leave events
     Invites, removes, and bans are unaffected.
     Show account events
@@ -1643,7 +1649,10 @@
     It looks like you’re trying to connect to another homeserver. Do you want to sign out?
 
     Edit
+    Editing
     Reply
+    Replying to %s
+    Quoting
     Reply in thread
     View In Room
 
@@ -1680,7 +1689,8 @@
     Create New Room
     Create New Space
     No network. Please check your Internet connection.
-    Something went wrong. Please check your network connection and try again.
+    
+    Something went wrong. Please check your network connection and try again.
     "Change network"
     "Please wait…"
     Updating your data…
@@ -2482,6 +2492,9 @@
     Key Requests
     Export Audit
 
+    Nightly build
+    Get the latest build (note: you may have trouble to sign in)
+
     Unlock encrypted messages history
 
     Refresh
@@ -2644,8 +2657,12 @@
     Unencrypted
     Encrypted by an unverified device
     The authenticity of this encrypted message can\'t be guaranteed on this device.
-    Review where you’re logged in
-    Verify all your sessions to ensure your account & messages are safe
+    
+    Review where you’re logged in
+    
+    Verify all your sessions to ensure your account & messages are safe
+    You have unverified sessions
+    Review to ensure your account is safe
     
     Verify the new login accessing your account: %1$s
 
@@ -3020,7 +3037,7 @@
 
     Auto Report Decryption Errors.
     Your system will automatically send logs when an unable to decrypt error occurs
-    Enable Thread Messages
+    Enable threaded messages
     Note: app will be restarted
     Show latest user info
     Show the latest profile info (avatar and display name) for all the messages.
@@ -3089,12 +3106,24 @@
     (%1$s)
 
     Live
+    Live broadcast
+    
+    Buffering…
     Resume voice broadcast record
     Pause voice broadcast record
     Stop voice broadcast record
     Play or resume voice broadcast
     Pause voice broadcast
-    Buffering
+    Fast backward 30 seconds
+    Fast forward 30 seconds
+    Can’t start a new voice broadcast
+    You don’t have the required permissions to start a voice broadcast in this room. Contact a room administrator to upgrade your permissions.
+    Someone else is already recording a voice broadcast. Wait for their voice broadcast to end to start a new one.
+    You are already recording a voice broadcast. Please end your current voice broadcast to start a new one.
+    
+    %1$s left
+    Stop live broadcasting?
+    Are you sure you want to stop your live broadcast? This will end the broadcast and the full recording will be available in the room.
 
     Anyone in %s will be able to find and join this room - no need to manually invite everyone. You’ll be able to change this in room settings anytime.
     Anyone in a parent space will be able to find and join this room - no need to manually invite everyone. You’ll be able to change this in room settings anytime.
@@ -3223,6 +3252,7 @@
     Location
     Camera
     Contact
+    Text formatting
 
     Show less
     
@@ -3286,6 +3316,7 @@
     Verify your current session for enhanced secure messaging.
     Verify or sign out from this session for best security and reliability.
     Verify your current session to reveal this session\'s verification status.
+    This session doesn\'t support encryption and thus can\'t be verified.
     Verify Session
     View Details
     View All (%1$d)
@@ -3308,7 +3339,7 @@
         Consider signing out from old sessions (%1$d day or more) that you don’t use anymore.
         Consider signing out from old sessions (%1$d days or more) that you don’t use anymore.
     
-    Current Session
+    Current session
     Session
     Device
     
@@ -3339,6 +3370,14 @@
     No inactive sessions found.
     Clear Filter
     Select sessions
+    Sign out
+    
+        Sign out of %1$d session
+        Sign out of %1$d sessions
+    
+    Sign out of all other sessions
+    Show IP address
+    Hide IP address
     Sign out of this session
     Session details
     Application, device, and activity information.
@@ -3367,7 +3406,10 @@
     Unverified sessions
     Unverified sessions are sessions that have logged in with your credentials but not been cross-verified.\n\nYou should make especially certain that you recognise these sessions as they could represent an unauthorised use of your account.
     Verified sessions
-    Verified sessions have logged in with your credentials and then been verified, either using your secure passphrase or by cross-verifying.\n\nThis means they hold encryption keys for your previous messages, and confirm to other users you are communicating with that these sessions are really you.
+    
+    Verified sessions have logged in with your credentials and then been verified, either using your secure passphrase or by cross-verifying.\n\nThis means they hold encryption keys for your previous messages, and confirm to other users you are communicating with that these sessions are really you.
+    Verified sessions are anywhere you are using this account after entering your passphrase or confirming your identity with another verified session.\n\nThis means that you have all the keys needed to unlock your encrypted messages and confirm to other users that you trust this session.
+    This session doesn\'t support encryption, so it can\'t be verified.\n\nYou won\'t be able to participate in rooms where encryption is enabled when using this session.\n\nFor best security and privacy, it is recommended to use Matrix clients that support encryption.
     Renaming sessions
     Other users in direct messages and rooms that you join are able to view a full list of your sessions.\n\nThis provides them with confidence that they are really speaking to you, but it also means they can see the session name you enter here.
     Enable new session manager
@@ -3438,10 +3480,26 @@
     Confirm
     Please ensure that you know the origin of this code. By linking devices, you will provide someone with full access to your account.
 
-    
+    
     Apply bold format
     Apply italic format
     Apply strikethrough format
     Apply underline format
-
+    Set link
+    Toggle full screen mode
+
+    Text
+    Link
+    Create a link
+    Edit link
+
+    
+    In reply to
+    sent a file.
+    sent an audio file.
+    sent a voice message.
+    sent an image.
+    sent a video.
+    sent a sticker.
+    created a poll.
 
diff --git a/library/ui-styles/src/main/res/values/dimens.xml b/library/ui-styles/src/main/res/values/dimens.xml
index 50d5aaf014..4c911c9e97 100644
--- a/library/ui-styles/src/main/res/values/dimens.xml
+++ b/library/ui-styles/src/main/res/values/dimens.xml
@@ -49,6 +49,7 @@
     1dp
     28dp
     14dp
+    44dp
 
     28dp
     6dp
@@ -74,7 +75,8 @@
     22dp
 
     
-    48dp
+    48dp
+    36dp
 
     
     112dp
diff --git a/library/ui-styles/src/main/res/values/stylable_sessions_list_header_view.xml b/library/ui-styles/src/main/res/values/stylable_sessions_list_header_view.xml
index 098ec263fc..c1a51000b7 100644
--- a/library/ui-styles/src/main/res/values/stylable_sessions_list_header_view.xml
+++ b/library/ui-styles/src/main/res/values/stylable_sessions_list_header_view.xml
@@ -5,6 +5,7 @@
         
         
         
+        
     
 
 
diff --git a/library/ui-styles/src/main/res/values/stylable_voice_broadcast_metadata_view.xml b/library/ui-styles/src/main/res/values/stylable_voice_broadcast_metadata_view.xml
new file mode 100644
index 0000000000..1f72eeb396
--- /dev/null
+++ b/library/ui-styles/src/main/res/values/stylable_voice_broadcast_metadata_view.xml
@@ -0,0 +1,9 @@
+
+
+
+    
+        
+        
+    
+
+
diff --git a/library/ui-styles/src/main/res/values/styles_edit_text.xml b/library/ui-styles/src/main/res/values/styles_edit_text.xml
index b640fc49d9..94f4d86160 100644
--- a/library/ui-styles/src/main/res/values/styles_edit_text.xml
+++ b/library/ui-styles/src/main/res/values/styles_edit_text.xml
@@ -4,7 +4,7 @@
     
diff --git a/library/ui-styles/src/main/res/values/styles_voice_broadcast.xml b/library/ui-styles/src/main/res/values/styles_voice_broadcast.xml
new file mode 100644
index 0000000000..eb85378141
--- /dev/null
+++ b/library/ui-styles/src/main/res/values/styles_voice_broadcast.xml
@@ -0,0 +1,19 @@
+
+
+
+    
+
+
diff --git a/library/ui-styles/src/main/res/values/theme_dark.xml b/library/ui-styles/src/main/res/values/theme_dark.xml
index 6b3bca924c..308fd85fe9 100644
--- a/library/ui-styles/src/main/res/values/theme_dark.xml
+++ b/library/ui-styles/src/main/res/values/theme_dark.xml
@@ -55,7 +55,7 @@
         ?vctr_content_quinary
         ?vctr_system
         ?vctr_system
-        ?vctr_content_tertiary
+        ?vctr_notice_secondary
 
         
         @color/element_accent_dark
diff --git a/library/ui-styles/src/main/res/values/theme_light.xml b/library/ui-styles/src/main/res/values/theme_light.xml
index a9dcae0436..73edab6894 100644
--- a/library/ui-styles/src/main/res/values/theme_light.xml
+++ b/library/ui-styles/src/main/res/values/theme_light.xml
@@ -55,7 +55,7 @@
         ?vctr_content_quinary
         ?vctr_system
         ?vctr_system
-        ?vctr_content_tertiary
+        ?vctr_notice_secondary
 
         
         @color/element_accent_light
diff --git a/matrix-sdk-android-flow/src/main/java/org/matrix/android/sdk/flow/FlowRoom.kt b/matrix-sdk-android-flow/src/main/java/org/matrix/android/sdk/flow/FlowRoom.kt
index a6b4cc98a6..94f09e0bf5 100644
--- a/matrix-sdk-android-flow/src/main/java/org/matrix/android/sdk/flow/FlowRoom.kt
+++ b/matrix-sdk-android-flow/src/main/java/org/matrix/android/sdk/flow/FlowRoom.kt
@@ -31,7 +31,6 @@ import org.matrix.android.sdk.api.session.room.model.RoomMemberSummary
 import org.matrix.android.sdk.api.session.room.model.RoomSummary
 import org.matrix.android.sdk.api.session.room.notification.RoomNotificationState
 import org.matrix.android.sdk.api.session.room.send.UserDraft
-import org.matrix.android.sdk.api.session.room.threads.model.ThreadSummary
 import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent
 import org.matrix.android.sdk.api.util.Optional
 import org.matrix.android.sdk.api.util.toOptional
@@ -100,8 +99,8 @@ class FlowRoom(private val room: Room) {
         return room.readService().getReadMarkerLive().asFlow()
     }
 
-    fun liveReadReceipt(): Flow> {
-        return room.readService().getMyReadReceiptLive().asFlow()
+    fun liveReadReceipt(threadId: String?): Flow> {
+        return room.readService().getMyReadReceiptLive(threadId).asFlow()
     }
 
     fun liveEventReadReceipts(eventId: String): Flow> {
@@ -119,13 +118,6 @@ class FlowRoom(private val room: Room) {
         return room.roomPushRuleService().getLiveRoomNotificationState().asFlow()
     }
 
-    fun liveThreadSummaries(): Flow> {
-        return room.threadsService().getAllThreadSummariesLive().asFlow()
-                .startWith(room.coroutineDispatchers.io) {
-                    room.threadsService().getAllThreadSummaries()
-                }
-    }
-
     fun liveThreadList(): Flow> {
         return room.threadsLocalService().getAllThreadsLive().asFlow()
                 .startWith(room.coroutineDispatchers.io) {
diff --git a/matrix-sdk-android/build.gradle b/matrix-sdk-android/build.gradle
index b9603dbd65..9a4fafebdb 100644
--- a/matrix-sdk-android/build.gradle
+++ b/matrix-sdk-android/build.gradle
@@ -62,7 +62,7 @@ android {
         // that the app's state is completely cleared between tests.
         testInstrumentationRunnerArguments clearPackageData: 'true'
 
-        buildConfigField "String", "SDK_VERSION", "\"1.5.7\""
+        buildConfigField "String", "SDK_VERSION", "\"1.5.18\""
 
         buildConfigField "String", "GIT_SDK_REVISION", "\"${gitRevision()}\""
         buildConfigField "String", "GIT_SDK_REVISION_UNIX_DATE", "\"${gitRevisionUnixDate()}\""
diff --git a/matrix-sdk-android/src/androidTest/assets/crypto_store_20.realm b/matrix-sdk-android/src/androidTest/assets/crypto_store_20.realm
new file mode 100644
index 0000000000..cfdd2e6da6
--- /dev/null
+++ b/matrix-sdk-android/src/androidTest/assets/crypto_store_20.realm
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:a7acd69f37612bab0a1ab7f456656712d7ba19dbb679f81b97b58ef44e239f42
+size 8523776
diff --git a/matrix-sdk-android/src/androidTest/assets/session_42.realm b/matrix-sdk-android/src/androidTest/assets/session_42.realm
new file mode 100644
index 0000000000..9268111699
--- /dev/null
+++ b/matrix-sdk-android/src/androidTest/assets/session_42.realm
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:ab134a3d77862db7097a507ec363ae17c60b183c98dac460bc7fb227bfd51f2e
+size 270336
diff --git a/matrix-sdk-android/src/androidTest/assets/tchap_session_41.realm b/matrix-sdk-android/src/androidTest/assets/tchap_session_41.realm
new file mode 100644
index 0000000000..4376e55dc6
--- /dev/null
+++ b/matrix-sdk-android/src/androidTest/assets/tchap_session_41.realm
@@ -0,0 +1,3 @@
+version https://git-lfs.github.com/spec/v1
+oid sha256:ef28ea4c5ab665398911f48437441a6c5d68593cde1e2c79c4ef6572578473ff
+size 270336
diff --git a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/common/TestRoomDisplayNameFallbackProvider.kt b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/common/TestRoomDisplayNameFallbackProvider.kt
index af2d57f9ce..a74f5010c2 100644
--- a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/common/TestRoomDisplayNameFallbackProvider.kt
+++ b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/common/TestRoomDisplayNameFallbackProvider.kt
@@ -16,7 +16,7 @@
 
 package org.matrix.android.sdk.common
 
-import org.matrix.android.sdk.api.RoomDisplayNameFallbackProvider
+import org.matrix.android.sdk.api.provider.RoomDisplayNameFallbackProvider
 
 class TestRoomDisplayNameFallbackProvider : RoomDisplayNameFallbackProvider {
 
diff --git a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/database/CryptoSanityMigrationTest.kt b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/database/CryptoSanityMigrationTest.kt
new file mode 100644
index 0000000000..2643bf643a
--- /dev/null
+++ b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/database/CryptoSanityMigrationTest.kt
@@ -0,0 +1,65 @@
+/*
+ * Copyright 2022 The Matrix.org Foundation C.I.C.
+ *
+ * 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 org.matrix.android.sdk.internal.database
+
+import android.content.Context
+import androidx.test.platform.app.InstrumentationRegistry
+import io.realm.Realm
+import org.junit.After
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.matrix.android.sdk.internal.crypto.store.db.RealmCryptoStoreMigration
+import org.matrix.android.sdk.internal.crypto.store.db.RealmCryptoStoreModule
+import org.matrix.android.sdk.internal.util.time.Clock
+
+class CryptoSanityMigrationTest {
+    @get:Rule val configurationFactory = TestRealmConfigurationFactory()
+
+    lateinit var context: Context
+    var realm: Realm? = null
+
+    @Before
+    fun setUp() {
+        context = InstrumentationRegistry.getInstrumentation().context
+    }
+
+    @After
+    fun tearDown() {
+        realm?.close()
+    }
+
+    @Test
+    fun cryptoDatabaseShouldMigrateGracefully() {
+        val realmName = "crypto_store_20.realm"
+        val migration = RealmCryptoStoreMigration(object : Clock {
+            override fun epochMillis(): Long {
+                return 0L
+            }
+        })
+        val realmConfiguration = configurationFactory.createConfiguration(
+                realmName,
+                "7b9a21a8a311e85d75b069a343c23fc952fc3fec5e0c83ecfa13f24b787479c487c3ed587db3dd1f5805d52041fc0ac246516e94b27ffa699ff928622e621aca",
+                RealmCryptoStoreModule(),
+                migration.schemaVersion,
+                migration
+        )
+        configurationFactory.copyRealmFromAssets(context, realmName, realmName)
+
+        realm = Realm.getInstance(realmConfiguration)
+    }
+}
diff --git a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/database/RealmSessionStoreMigration43Test.kt b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/database/RealmSessionStoreMigration43Test.kt
new file mode 100644
index 0000000000..e5ff7a73eb
--- /dev/null
+++ b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/database/RealmSessionStoreMigration43Test.kt
@@ -0,0 +1,132 @@
+/*
+ * Copyright 2022 The Matrix.org Foundation C.I.C.
+ *
+ * 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 org.matrix.android.sdk.internal.database
+
+import android.content.Context
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import io.realm.Realm
+import org.amshove.kluent.fail
+import org.amshove.kluent.shouldBe
+import org.amshove.kluent.shouldBeEqualTo
+import org.amshove.kluent.shouldNotBe
+import org.junit.After
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.matrix.android.sdk.api.session.events.model.toModel
+import org.matrix.android.sdk.api.session.room.model.message.MessageContent
+import org.matrix.android.sdk.internal.database.mapper.EventMapper
+import org.matrix.android.sdk.internal.database.model.EventAnnotationsSummaryEntity
+import org.matrix.android.sdk.internal.database.model.SessionRealmModule
+import org.matrix.android.sdk.internal.database.query.where
+import org.matrix.android.sdk.internal.util.Normalizer
+
+@RunWith(AndroidJUnit4::class)
+class RealmSessionStoreMigration43Test {
+
+    @get:Rule val configurationFactory = TestRealmConfigurationFactory()
+
+    lateinit var context: Context
+    var realm: Realm? = null
+
+    @Before
+    fun setUp() {
+        context = InstrumentationRegistry.getInstrumentation().context
+    }
+
+    @After
+    fun tearDown() {
+        realm?.close()
+    }
+
+    // Tchap: Use custom realm database
+    @Test
+    fun migrationShouldBeNeeed() {
+        val realmName = "tchap_session_41.realm"
+        val realmConfiguration = configurationFactory.createConfiguration(
+                realmName,
+                "2948ff8106ad80ca8aeba7ef59775075258d8805f9f6eb306add6c3097154bf5c99bbc965931a29e4512f0b3981d3a562c4c86b860846bac2312e1ab61026762",
+                SessionRealmModule(),
+                43,
+                null
+        )
+        configurationFactory.copyRealmFromAssets(context, realmName, realmName)
+
+        try {
+            realm = Realm.getInstance(realmConfiguration)
+            fail("Should need a migration")
+        } catch (failure: Throwable) {
+            // nop
+        }
+    }
+
+    // Tchap: Use custom realm database
+    //  Database key for alias `session_db_feb3823dd11e8b4b0b19d5d1d9e3b864`: 2948ff8106ad80ca8aeba7ef59775075258d8805f9f6eb306add6c3097154bf5c99bbc965931a29e4512f0b3981d3a562c4c86b860846bac2312e1ab61026762
+    // $167541532417nKwjC:agent2.tchap.incubateur.net
+    // $167541543920SxkBP:agent2.tchap.incubateur.net
+    @Test
+    fun testMigration43() {
+        val realmName = "tchap_session_41.realm"
+        val migration = RealmSessionStoreMigration(Normalizer())
+        val realmConfiguration = configurationFactory.createConfiguration(
+                realmName,
+                "2948ff8106ad80ca8aeba7ef59775075258d8805f9f6eb306add6c3097154bf5c99bbc965931a29e4512f0b3981d3a562c4c86b860846bac2312e1ab61026762",
+                SessionRealmModule(),
+                43,
+                migration
+        )
+        configurationFactory.copyRealmFromAssets(context, realmName, realmName)
+
+        realm = Realm.getInstance(realmConfiguration)
+
+        // assert that the edit from 42 are migrated
+        val editions = EventAnnotationsSummaryEntity
+                .where(realm!!, "\$167541532417nKwjC:agent2.tchap.incubateur.net")
+                .findFirst()
+                ?.editSummary
+                ?.editions
+
+        editions shouldNotBe null
+        editions!!.size shouldBe 1
+        val firstEdition = editions.first()
+        firstEdition?.eventId shouldBeEqualTo "\$167541534818YsPGX:agent2.tchap.incubateur.net"
+        firstEdition?.isLocalEcho shouldBeEqualTo false
+
+        val editEvent = EventMapper.map(firstEdition!!.event!!)
+        val body = editEvent.content.toModel()?.body
+        body shouldBeEqualTo "* Message 2 with edit"
+
+        // assert that the edit from 42 are migrated
+        val editionsOfE2E = EventAnnotationsSummaryEntity
+                .where(realm!!, "\$167541543920SxkBP:agent2.tchap.incubateur.net")
+                .findFirst()
+                ?.editSummary
+                ?.editions
+
+        editionsOfE2E shouldNotBe null
+        editionsOfE2E!!.size shouldBe 1
+        val firstEditionE2E = editionsOfE2E.first()
+        firstEditionE2E?.eventId shouldBeEqualTo "\$167541545321BMCNj:agent2.tchap.incubateur.net"
+        firstEditionE2E?.isLocalEcho shouldBeEqualTo false
+
+        val editEventE2E = EventMapper.map(firstEditionE2E!!.event!!)
+        val body2 = editEventE2E.getClearContent().toModel()?.body
+        body2 shouldBeEqualTo "* Message 2, e2e edit"
+    }
+}
diff --git a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/database/SessionSanityMigrationTest.kt b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/database/SessionSanityMigrationTest.kt
new file mode 100644
index 0000000000..c916ffc303
--- /dev/null
+++ b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/database/SessionSanityMigrationTest.kt
@@ -0,0 +1,65 @@
+/*
+ * Copyright 2020 The Matrix.org Foundation C.I.C.
+ *
+ * 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 org.matrix.android.sdk.internal.database
+
+import android.content.Context
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.platform.app.InstrumentationRegistry
+import io.realm.Realm
+import org.junit.After
+import org.junit.Before
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.matrix.android.sdk.internal.database.model.SessionRealmModule
+import org.matrix.android.sdk.internal.util.Normalizer
+
+@RunWith(AndroidJUnit4::class)
+class SessionSanityMigrationTest {
+
+    @get:Rule val configurationFactory = TestRealmConfigurationFactory()
+
+    lateinit var context: Context
+    var realm: Realm? = null
+
+    @Before
+    fun setUp() {
+        context = InstrumentationRegistry.getInstrumentation().context
+    }
+
+    @After
+    fun tearDown() {
+        realm?.close()
+    }
+
+    // Tchap: use custom realm database
+    @Test
+    fun sessionDatabaseShouldMigrateGracefully() {
+        val realmName = "tchap_session_41.realm"
+        val migration = RealmSessionStoreMigration(Normalizer())
+        val realmConfiguration = configurationFactory.createConfiguration(
+                realmName,
+                "2948ff8106ad80ca8aeba7ef59775075258d8805f9f6eb306add6c3097154bf5c99bbc965931a29e4512f0b3981d3a562c4c86b860846bac2312e1ab61026762",
+                SessionRealmModule(),
+                migration.schemaVersion,
+                migration
+        )
+        configurationFactory.copyRealmFromAssets(context, realmName, realmName)
+
+        realm = Realm.getInstance(realmConfiguration)
+    }
+}
diff --git a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/database/TestRealmConfigurationFactory.kt b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/database/TestRealmConfigurationFactory.kt
new file mode 100644
index 0000000000..fc5a017287
--- /dev/null
+++ b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/database/TestRealmConfigurationFactory.kt
@@ -0,0 +1,196 @@
+/*
+ * Copyright 2022 The Matrix.org Foundation C.I.C.
+ *
+ * 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 org.matrix.android.sdk.internal.database
+
+import android.content.Context
+import androidx.test.platform.app.InstrumentationRegistry
+import io.realm.Realm
+import io.realm.RealmConfiguration
+import io.realm.RealmMigration
+import org.junit.rules.TemporaryFolder
+import org.junit.runner.Description
+import org.junit.runners.model.Statement
+import java.io.File
+import java.io.FileOutputStream
+import java.io.IOException
+import java.io.InputStream
+import java.lang.IllegalStateException
+import java.util.Collections
+import java.util.Locale
+import java.util.concurrent.ConcurrentHashMap
+import kotlin.Throws
+
+/**
+ * Based on https://github.com/realm/realm-java/blob/master/realm/realm-library/src/testUtils/java/io/realm/TestRealmConfigurationFactory.java
+ */
+class TestRealmConfigurationFactory : TemporaryFolder() {
+    private val map: Map = ConcurrentHashMap()
+    private val configurations = Collections.newSetFromMap(map)
+    @get:Synchronized private var isUnitTestFailed = false
+    private var testName = ""
+    private var tempFolder: File? = null
+
+    override fun apply(base: Statement, description: Description): Statement {
+        return object : Statement() {
+            @Throws(Throwable::class)
+            override fun evaluate() {
+                setTestName(description)
+                before()
+                try {
+                    base.evaluate()
+                } catch (throwable: Throwable) {
+                    setUnitTestFailed()
+                    throw throwable
+                } finally {
+                    after()
+                }
+            }
+        }
+    }
+
+    @Throws(Throwable::class)
+    override fun before() {
+        Realm.init(InstrumentationRegistry.getInstrumentation().targetContext)
+        super.before()
+    }
+
+    override fun after() {
+        try {
+            for (configuration in configurations) {
+                Realm.deleteRealm(configuration)
+            }
+        } catch (e: IllegalStateException) {
+            // Only throws the exception caused by deleting the opened Realm if the test case itself doesn't throw.
+            if (!isUnitTestFailed) {
+                throw e
+            }
+        } finally {
+            // This will delete the temp directory.
+            super.after()
+        }
+    }
+
+    @Throws(IOException::class)
+    override fun create() {
+        super.create()
+        tempFolder = File(super.getRoot(), testName)
+        check(!(tempFolder!!.exists() && !tempFolder!!.delete())) { "Could not delete folder: " + tempFolder!!.absolutePath }
+        check(tempFolder!!.mkdir()) { "Could not create folder: " + tempFolder!!.absolutePath }
+    }
+
+    override fun getRoot(): File {
+        checkNotNull(tempFolder) { "the temporary folder has not yet been created" }
+        return tempFolder!!
+    }
+
+    /**
+     * To be called in the [.apply].
+     */
+    protected fun setTestName(description: Description) {
+        testName = description.displayName
+    }
+
+    @Synchronized
+    fun setUnitTestFailed() {
+        isUnitTestFailed = true
+    }
+
+    // This builder creates a configuration that is *NOT* managed.
+    // You have to delete it yourself.
+    private fun createConfigurationBuilder(): RealmConfiguration.Builder {
+        return RealmConfiguration.Builder().directory(root)
+    }
+
+    fun String.decodeHex(): ByteArray {
+        check(length % 2 == 0) { "Must have an even length" }
+        return chunked(2)
+                .map { it.toInt(16).toByte() }
+                .toByteArray()
+    }
+
+    fun createConfiguration(
+            name: String,
+            key: String?,
+            module: Any,
+            schemaVersion: Long,
+            migration: RealmMigration?
+    ): RealmConfiguration {
+        val builder = createConfigurationBuilder()
+        builder
+                .directory(root)
+                .name(name)
+                .apply {
+                    if (key != null) {
+                        encryptionKey(key.decodeHex())
+                    }
+                }
+                .modules(module)
+                // Allow writes on UI
+                .allowWritesOnUiThread(true)
+                .schemaVersion(schemaVersion)
+                .apply {
+                    migration?.let { migration(it) }
+                }
+        val configuration = builder.build()
+        configurations.add(configuration)
+        return configuration
+    }
+
+    // Copies a Realm file from assets to temp dir
+    @Throws(IOException::class)
+    fun copyRealmFromAssets(context: Context, realmPath: String, newName: String) {
+        val config = RealmConfiguration.Builder()
+                .directory(root)
+                .name(newName)
+                .build()
+        copyRealmFromAssets(context, realmPath, config)
+    }
+
+    @Throws(IOException::class)
+    fun copyRealmFromAssets(context: Context, realmPath: String, config: RealmConfiguration) {
+        check(!File(config.path).exists()) { String.format(Locale.ENGLISH, "%s exists!", config.path) }
+        val outFile = File(config.realmDirectory, config.realmFileName)
+        copyFileFromAssets(context, realmPath, outFile)
+    }
+
+    @Throws(IOException::class)
+    fun copyFileFromAssets(context: Context, assetPath: String?, outFile: File?) {
+        var stream: InputStream? = null
+        var os: FileOutputStream? = null
+        try {
+            stream = context.assets.open(assetPath!!)
+            os = FileOutputStream(outFile)
+            val buf = ByteArray(1024)
+            var bytesRead: Int
+            while (stream.read(buf).also { bytesRead = it } > -1) {
+                os.write(buf, 0, bytesRead)
+            }
+        } finally {
+            if (stream != null) {
+                try {
+                    stream.close()
+                } catch (ignore: IOException) {
+                }
+            }
+            if (os != null) {
+                try {
+                    os.close()
+                } catch (ignore: IOException) {
+                }
+            }
+        }
+    }
+}
diff --git a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/room/timeline/PollAggregationTest.kt b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/room/timeline/PollAggregationTest.kt
index a37d2ce015..a52e3cd7c7 100644
--- a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/room/timeline/PollAggregationTest.kt
+++ b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/session/room/timeline/PollAggregationTest.kt
@@ -66,7 +66,7 @@ class PollAggregationTest : InstrumentedTest {
 
         val aliceEventsListener = object : Timeline.Listener {
             override fun onTimelineUpdated(snapshot: List) {
-                snapshot.firstOrNull { it.root.getClearType() in EventType.POLL_START }?.let { pollEvent ->
+                snapshot.firstOrNull { it.root.getClearType() in EventType.POLL_START.values }?.let { pollEvent ->
                     val pollEventId = pollEvent.eventId
                     val pollContent = pollEvent.root.content?.toModel()
                     val pollSummary = pollEvent.annotations?.pollResponseSummary
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/MatrixConfiguration.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/MatrixConfiguration.kt
index 00d74ab446..68b6a2ddf8 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/MatrixConfiguration.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/MatrixConfiguration.kt
@@ -20,6 +20,9 @@ import okhttp3.ConnectionSpec
 import okhttp3.Interceptor
 import org.matrix.android.sdk.api.crypto.MXCryptoConfig
 import org.matrix.android.sdk.api.metrics.MetricPlugin
+import org.matrix.android.sdk.api.provider.CustomEventTypesProvider
+import org.matrix.android.sdk.api.provider.MatrixItemDisplayNameFallbackProvider
+import org.matrix.android.sdk.api.provider.RoomDisplayNameFallbackProvider
 import java.net.Proxy
 
 data class MatrixConfiguration(
@@ -66,7 +69,7 @@ data class MatrixConfiguration(
         /**
          * Thread messages default enable/disabled value.
          */
-        val threadMessagesEnabledDefault: Boolean = false,
+        val threadMessagesEnabledDefault: Boolean = true,
         /**
          * List of network interceptors, they will be added when building an OkHttp client.
          */
@@ -75,9 +78,12 @@ data class MatrixConfiguration(
          * Sync configuration.
          */
         val syncConfig: SyncConfig = SyncConfig(),
-
         /**
          * Metrics plugin that can be used to capture metrics from matrix-sdk-android.
          */
-        val metricPlugins: List = emptyList()
+        val metricPlugins: List = emptyList(),
+        /**
+         * CustomEventTypesProvider to provide custom event types to the sdk which should be processed with internal events.
+         */
+        val customEventTypesProvider: CustomEventTypesProvider? = null,
 )
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/SyncConfig.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/SyncConfig.kt
index a9753e2407..84650da72f 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/SyncConfig.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/SyncConfig.kt
@@ -16,9 +16,13 @@
 
 package org.matrix.android.sdk.api
 
+import org.matrix.android.sdk.api.session.sync.filter.SyncFilterParams
+
 data class SyncConfig(
         /**
          * Time to keep sync connection alive for before making another request in milliseconds.
          */
         val longPollTimeout: Long = 30_000L,
+
+        val syncFilterParams: SyncFilterParams = SyncFilterParams()
 )
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/account/LocalNotificationSettingsContent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/account/LocalNotificationSettingsContent.kt
index 2a95ccce7a..75d04f340a 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/account/LocalNotificationSettingsContent.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/account/LocalNotificationSettingsContent.kt
@@ -21,5 +21,6 @@ import com.squareup.moshi.JsonClass
 
 @JsonClass(generateAdapter = true)
 data class LocalNotificationSettingsContent(
-        @Json(name = "is_silenced") val isSilenced: Boolean = false
+        @Json(name = "is_silenced")
+        val isSilenced: Boolean?
 )
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/AuthenticationService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/AuthenticationService.kt
index f1f48adaf5..668b24a119 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/AuthenticationService.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/AuthenticationService.kt
@@ -131,12 +131,6 @@ interface AuthenticationService {
      */
     suspend fun getPasswordPolicy(homeServerConnectionConfig: HomeServerConnectionConfig): PasswordPolicy
 
-    /**
-     * @param homeServerConnectionConfig the information about the homeserver and other configuration
-     * Return true if qr code login is supported by the server, false otherwise.
-     */
-    suspend fun isQrLoginSupported(homeServerConnectionConfig: HomeServerConnectionConfig): Boolean
-
     /**
      * Authenticate using m.login.token method during sign in with QR code.
      * @param homeServerConnectionConfig the information about the homeserver and other configuration
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/data/LoginFlowResult.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/data/LoginFlowResult.kt
index 5b6c1897bf..5de83033e1 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/data/LoginFlowResult.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/auth/data/LoginFlowResult.kt
@@ -22,5 +22,6 @@ data class LoginFlowResult(
         val isLoginAndRegistrationSupported: Boolean,
         val homeServerUrl: String,
         val isOutdatedHomeserver: Boolean,
-        val isLogoutDevicesSupported: Boolean
+        val isLogoutDevicesSupported: Boolean,
+        val isLoginWithQrSupported: Boolean,
 )
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/extensions/MetricsExtensions.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/extensions/MetricsExtensions.kt
index 9487a27086..7f0e828f62 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/extensions/MetricsExtensions.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/extensions/MetricsExtensions.kt
@@ -17,25 +17,51 @@
 package org.matrix.android.sdk.api.extensions
 
 import org.matrix.android.sdk.api.metrics.MetricPlugin
+import org.matrix.android.sdk.api.metrics.SpannableMetricPlugin
 import kotlin.contracts.ExperimentalContracts
 import kotlin.contracts.InvocationKind
 import kotlin.contracts.contract
 
 /**
  * Executes the given [block] while measuring the transaction.
+ *
+ * @param block Action/Task to be executed within this span.
+ */
+@OptIn(ExperimentalContracts::class)
+inline fun List.measureMetric(block: () -> Unit) {
+    contract {
+        callsInPlace(block, InvocationKind.EXACTLY_ONCE)
+    }
+    try {
+        this.forEach { plugin -> plugin.startTransaction() } // Start the transaction.
+        block()
+    } catch (throwable: Throwable) {
+        this.forEach { plugin -> plugin.onError(throwable) } // Capture if there is any exception thrown.
+        throw throwable
+    } finally {
+        this.forEach { plugin -> plugin.finishTransaction() } // Finally, finish this transaction.
+    }
+}
+
+/**
+ * Executes the given [block] while measuring a span.
+ *
+ * @param operation Name of the new span.
+ * @param description Description of the new span.
+ * @param block Action/Task to be executed within this span.
  */
 @OptIn(ExperimentalContracts::class)
-inline fun measureMetric(metricMeasurementPlugins: List, block: () -> Unit) {
+inline fun List.measureSpan(operation: String, description: String, block: () -> Unit) {
     contract {
         callsInPlace(block, InvocationKind.EXACTLY_ONCE)
     }
     try {
-        metricMeasurementPlugins.forEach { plugin -> plugin.startTransaction() } // Start the transaction.
+        this.forEach { plugin -> plugin.startSpan(operation, description) } // Start the transaction.
         block()
     } catch (throwable: Throwable) {
-        metricMeasurementPlugins.forEach { plugin -> plugin.onError(throwable) } // Capture if there is any exception thrown.
+        this.forEach { plugin -> plugin.onError(throwable) } // Capture if there is any exception thrown.
         throw throwable
     } finally {
-        metricMeasurementPlugins.forEach { plugin -> plugin.finishTransaction() } // Finally, finish this transaction.
+        this.forEach { plugin -> plugin.finishSpan() } // Finally, finish this transaction.
     }
 }
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/metrics/SpannableMetricPlugin.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/metrics/SpannableMetricPlugin.kt
new file mode 100644
index 0000000000..54aa21877e
--- /dev/null
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/metrics/SpannableMetricPlugin.kt
@@ -0,0 +1,36 @@
+/*
+ * Copyright (c) 2022 The Matrix.org Foundation C.I.C.
+ *
+ * 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 org.matrix.android.sdk.api.metrics
+
+/**
+ * A plugin that tracks span along with transactions.
+ */
+interface SpannableMetricPlugin : MetricPlugin {
+
+    /**
+     * Starts the span for a sub-task.
+     *
+     * @param operation Name of the new span.
+     * @param description Description of the new span.
+     */
+    fun startSpan(operation: String, description: String)
+
+    /**
+     * Finish the span when sub-task is completed.
+     */
+    fun finishSpan()
+}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/metrics/SyncDurationMetricPlugin.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/metrics/SyncDurationMetricPlugin.kt
new file mode 100644
index 0000000000..79ece002e9
--- /dev/null
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/metrics/SyncDurationMetricPlugin.kt
@@ -0,0 +1,32 @@
+/*
+ * Copyright 2022 The Matrix.org Foundation C.I.C.
+ *
+ * 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 org.matrix.android.sdk.api.metrics
+
+import org.matrix.android.sdk.api.logger.LoggerTag
+import timber.log.Timber
+
+private val loggerTag = LoggerTag("SyncDurationMetricPlugin", LoggerTag.CRYPTO)
+
+/**
+ * An spannable metric plugin for sync response handling task.
+ */
+interface SyncDurationMetricPlugin : SpannableMetricPlugin {
+
+    override fun logTransaction(message: String?) {
+        Timber.tag(loggerTag.value).v("## syncResponseHandler() : $message")
+    }
+}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/provider/CustomEventTypesProvider.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/provider/CustomEventTypesProvider.kt
new file mode 100644
index 0000000000..c0f66dc1c2
--- /dev/null
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/provider/CustomEventTypesProvider.kt
@@ -0,0 +1,30 @@
+/*
+ * Copyright 2022 The Matrix.org Foundation C.I.C.
+ *
+ * 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 org.matrix.android.sdk.api.provider
+
+import org.matrix.android.sdk.api.session.room.model.RoomSummary
+
+/**
+ * Provide custom event types which should be processed with the internal event types.
+ */
+interface CustomEventTypesProvider {
+
+    /**
+     * Custom event types to include when computing [RoomSummary.latestPreviewableEvent].
+     */
+    val customPreviewableEventTypes: List
+}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/MatrixItemDisplayNameFallbackProvider.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/provider/MatrixItemDisplayNameFallbackProvider.kt
similarity index 94%
rename from matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/MatrixItemDisplayNameFallbackProvider.kt
rename to matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/provider/MatrixItemDisplayNameFallbackProvider.kt
index 82008cda8c..971845eae7 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/MatrixItemDisplayNameFallbackProvider.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/provider/MatrixItemDisplayNameFallbackProvider.kt
@@ -14,7 +14,7 @@
  * limitations under the License.
  */
 
-package org.matrix.android.sdk.api
+package org.matrix.android.sdk.api.provider
 
 import org.matrix.android.sdk.api.util.MatrixItem
 
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/RoomDisplayNameFallbackProvider.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/provider/RoomDisplayNameFallbackProvider.kt
similarity index 97%
rename from matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/RoomDisplayNameFallbackProvider.kt
rename to matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/provider/RoomDisplayNameFallbackProvider.kt
index 3c376b55ee..37d9b46b0b 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/RoomDisplayNameFallbackProvider.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/provider/RoomDisplayNameFallbackProvider.kt
@@ -14,7 +14,7 @@
  * limitations under the License.
  */
 
-package org.matrix.android.sdk.api
+package org.matrix.android.sdk.api.provider
 
 /**
  * This interface exists to let the implementation provide localized room display name fallback.
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/Rendezvous.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/Rendezvous.kt
index f724ac4b62..e5b2d6bf12 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/Rendezvous.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/rendezvous/Rendezvous.kt
@@ -35,7 +35,10 @@ import org.matrix.android.sdk.api.session.crypto.crosssigning.KEYBACKUP_SECRET_S
 import org.matrix.android.sdk.api.session.crypto.crosssigning.MASTER_KEY_SSSS_NAME
 import org.matrix.android.sdk.api.session.crypto.crosssigning.SELF_SIGNING_KEY_SSSS_NAME
 import org.matrix.android.sdk.api.session.crypto.crosssigning.USER_SIGNING_KEY_SSSS_NAME
+import org.matrix.android.sdk.api.session.crypto.model.CryptoDeviceInfo
+import org.matrix.android.sdk.api.session.crypto.model.MXUsersDevicesMap
 import org.matrix.android.sdk.api.util.MatrixJsonParser
+import org.matrix.android.sdk.api.util.awaitCallback
 import timber.log.Timber
 
 /**
@@ -147,6 +150,14 @@ class Rendezvous(
         val deviceKey = crypto.getMyDevice().fingerprint()
         send(Payload(PayloadType.PROGRESS, outcome = Outcome.SUCCESS, deviceId = deviceId, deviceKey = deviceKey))
 
+        try {
+            // explicitly download keys for ourself rather than racing with initial sync which might not complete in time
+            awaitCallback> { crypto.downloadKeys(listOf(userId), false, it) }
+        } catch (e: Throwable) {
+            // log as warning and continue as initial sync might still complete
+            Timber.tag(TAG).w(e, "Failed to download keys for self")
+        }
+
         // await confirmation of verification
         val verificationResponse = receive()
         if (verificationResponse?.outcome == Outcome.VERIFIED) {
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/Session.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/Session.kt
index 94617ab7e6..54558337da 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/Session.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/Session.kt
@@ -52,7 +52,6 @@ import org.matrix.android.sdk.api.session.securestorage.SharedSecretStorageServi
 import org.matrix.android.sdk.api.session.signout.SignOutService
 import org.matrix.android.sdk.api.session.space.SpaceService
 import org.matrix.android.sdk.api.session.statistics.StatisticsListener
-import org.matrix.android.sdk.api.session.sync.FilterService
 import org.matrix.android.sdk.api.session.sync.SyncService
 import org.matrix.android.sdk.api.session.terms.TermsService
 import org.matrix.android.sdk.api.session.thirdparty.ThirdPartyService
@@ -165,11 +164,6 @@ interface Session {
      */
     fun signOutService(): SignOutService
 
-    /**
-     * Returns the FilterService associated with the session.
-     */
-    fun filterService(): FilterService
-
     /**
      * Returns the PushRuleService associated with the session.
      */
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/accountdata/SessionAccountDataService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/accountdata/SessionAccountDataService.kt
index a22dd33774..8addb0782e 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/accountdata/SessionAccountDataService.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/accountdata/SessionAccountDataService.kt
@@ -63,4 +63,17 @@ interface SessionAccountDataService {
      * Update the account data with the provided type and the provided account data content.
      */
     suspend fun updateUserAccountData(type: String, content: Content)
+
+    /**
+     * Retrieve user account data list whose type starts with the given type.
+     * @param type the type or the starting part of a type
+     * @return list of account data whose type starts with the given type
+     */
+    fun getUserAccountDataEventsStartWith(type: String): List
+
+    /**
+     * Deletes user account data of the given type.
+     * @param type the type to delete from user account data
+     */
+    suspend fun deleteUserAccountData(type: String)
 }
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/crypto/CryptoService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/crypto/CryptoService.kt
index d2aa8020e8..971d04261e 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/crypto/CryptoService.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/crypto/CryptoService.kt
@@ -17,6 +17,7 @@
 package org.matrix.android.sdk.api.session.crypto
 
 import android.content.Context
+import androidx.annotation.Size
 import androidx.lifecycle.LiveData
 import androidx.paging.PagedList
 import org.matrix.android.sdk.api.MatrixCallback
@@ -55,6 +56,8 @@ interface CryptoService {
 
     fun deleteDevice(deviceId: String, userInteractiveAuthInterceptor: UserInteractiveAuthInterceptor, callback: MatrixCallback)
 
+    fun deleteDevices(@Size(min = 1) deviceIds: List, userInteractiveAuthInterceptor: UserInteractiveAuthInterceptor, callback: MatrixCallback)
+
     fun getCryptoVersion(context: Context, longFormat: Boolean): String
 
     fun isCryptoEnabled(): Boolean
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/AggregatedAnnotation.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/AggregatedAnnotation.kt
index 239f749993..5b2ab77467 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/AggregatedAnnotation.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/AggregatedAnnotation.kt
@@ -38,5 +38,4 @@ data class AggregatedAnnotation(
         override val limited: Boolean? = false,
         override val count: Int? = 0,
         val chunk: List? = null
-
 ) : UnsignedRelationInfo
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/AggregatedRelations.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/AggregatedRelations.kt
index ae8ed3941f..6577a9b41e 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/AggregatedRelations.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/AggregatedRelations.kt
@@ -19,7 +19,8 @@ import com.squareup.moshi.Json
 import com.squareup.moshi.JsonClass
 
 /**
- * 
+ * Server side relation aggregation.
+ * ```
  *  {
  *       "m.annotation": {
  *          "chunk": [
@@ -43,12 +44,13 @@ import com.squareup.moshi.JsonClass
  *           "count": 1
  *           }
  *      }
- * 
+ * ```
  */
 
 @JsonClass(generateAdapter = true)
 data class AggregatedRelations(
         @Json(name = "m.annotation") val annotations: AggregatedAnnotation? = null,
         @Json(name = "m.reference") val references: DefaultUnsignedRelationInfo? = null,
+        @Json(name = "m.replace") val replaces: AggregatedReplace? = null,
         @Json(name = RelationType.THREAD) val latestThread: LatestThreadUnsignedRelation? = null
 )
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/AggregatedReplace.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/AggregatedReplace.kt
new file mode 100644
index 0000000000..2ae091a1a4
--- /dev/null
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/AggregatedReplace.kt
@@ -0,0 +1,33 @@
+/*
+ * Copyright 2022 The Matrix.org Foundation C.I.C.
+ *
+ * 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 org.matrix.android.sdk.api.session.events.model
+
+import com.squareup.moshi.Json
+import com.squareup.moshi.JsonClass
+
+/**
+ * Note that there can be multiple events with an m.replace relationship to a given event (for example, if an event is edited multiple times).
+ * These should be aggregated by the homeserver.
+ * https://spec.matrix.org/v1.4/client-server-api/#server-side-aggregation-of-mreplace-relationships
+ *
+ */
+@JsonClass(generateAdapter = true)
+data class AggregatedReplace(
+        @Json(name = "event_id") val eventId: String? = null,
+        @Json(name = "origin_server_ts") val originServerTs: Long? = null,
+        @Json(name = "sender") val senderId: String? = null,
+)
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/Event.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/Event.kt
index 1f16041b54..9b5f4ac19f 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/Event.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/Event.kt
@@ -26,13 +26,12 @@ import org.matrix.android.sdk.api.session.crypto.model.OlmDecryptionResult
 import org.matrix.android.sdk.api.session.events.model.content.EncryptedEventContent
 import org.matrix.android.sdk.api.session.room.model.Membership
 import org.matrix.android.sdk.api.session.room.model.RoomMemberContent
-import org.matrix.android.sdk.api.session.room.model.message.MessageBeaconLocationDataContent
 import org.matrix.android.sdk.api.session.room.model.message.MessageContent
 import org.matrix.android.sdk.api.session.room.model.message.MessagePollContent
-import org.matrix.android.sdk.api.session.room.model.message.MessageStickerContent
 import org.matrix.android.sdk.api.session.room.model.message.MessageType
 import org.matrix.android.sdk.api.session.room.model.message.asMessageAudioEvent
 import org.matrix.android.sdk.api.session.room.model.relation.RelationDefaultContent
+import org.matrix.android.sdk.api.session.room.model.relation.isReply
 import org.matrix.android.sdk.api.session.room.model.relation.shouldRenderInThread
 import org.matrix.android.sdk.api.session.room.send.SendState
 import org.matrix.android.sdk.api.session.threads.ThreadDetails
@@ -53,7 +52,7 @@ inline fun  Content?.toModel(catchError: Boolean = true): T? {
     val moshiAdapter = moshi.adapter(T::class.java)
     return try {
         moshiAdapter.fromJsonValue(this)
-    } catch (e: Exception) {
+    } catch (e: Throwable) {
         if (catchError) {
             Timber.e(e, "To model failed : $e")
             null
@@ -228,11 +227,14 @@ data class Event(
         return when {
             isReplyRenderedInThread() || isQuote() -> ContentUtils.extractUsefulTextFromReply(text)
             isFileMessage() -> "sent a file."
+            isVoiceMessage() -> "sent a voice message."
             isAudioMessage() -> "sent an audio file."
             isImageMessage() -> "sent an image."
             isVideoMessage() -> "sent a video."
-            isSticker() -> "sent a sticker"
+            isSticker() -> "sent a sticker."
             isPoll() -> getPollQuestion() ?: "created a poll."
+            isLiveLocation() -> "Live location."
+            isLocationMessage() -> "has shared their location."
             else -> text
         }
     }
@@ -386,24 +388,24 @@ fun Event.isLocationMessage(): Boolean {
     }
 }
 
-fun Event.isPoll(): Boolean = getClearType() in EventType.POLL_START || getClearType() in EventType.POLL_END
+fun Event.isPoll(): Boolean = isPollStart() || isPollEnd()
+
+fun Event.isPollStart(): Boolean = getClearType() in EventType.POLL_START.values
+
+fun Event.isPollResponse(): Boolean = getClearType() in EventType.POLL_RESPONSE.values
+
+fun Event.isPollEnd(): Boolean = getClearType() in EventType.POLL_END.values
 
 fun Event.isSticker(): Boolean = getClearType() == EventType.STICKER
 
-fun Event.isLiveLocation(): Boolean = getClearType() in EventType.STATE_ROOM_BEACON_INFO
+fun Event.isLiveLocation(): Boolean = getClearType() in EventType.STATE_ROOM_BEACON_INFO.values
 
 fun Event.getRelationContent(): RelationDefaultContent? {
     return if (isEncrypted()) {
         content.toModel()?.relatesTo
     } else {
-        content.toModel()?.relatesTo ?: run {
-            // Special cases when there is only a local msgtype for some event types
-            when (getClearType()) {
-                EventType.STICKER -> getClearContent().toModel()?.relatesTo
-                in EventType.BEACON_LOCATION_DATA -> getClearContent().toModel()?.relatesTo
-                else -> getClearContent()?.get("m.relates_to")?.toContent().toModel()
-            }
-        }
+        content.toModel()?.relatesTo
+                ?: getClearContent()?.get("m.relates_to")?.toContent().toModel() // Special cases when there is only a local msgtype for some event types
     }
 }
 
@@ -420,7 +422,7 @@ fun Event.getRelationContentForType(type: String): RelationDefaultContent? =
         getRelationContent()?.takeIf { it.type == type }
 
 fun Event.isReply(): Boolean {
-    return getRelationContent()?.inReplyTo?.eventId != null
+    return getRelationContent().isReply()
 }
 
 fun Event.isReplyRenderedInThread(): Boolean {
@@ -443,11 +445,11 @@ fun Event.isInvitation(): Boolean = type == EventType.STATE_ROOM_MEMBER &&
         content?.toModel()?.membership == Membership.INVITE
 
 fun Event.getPollContent(): MessagePollContent? {
-    return content.toModel()
+    return getClearContent().toModel()
 }
 
 fun Event.supportsNotification() =
-        this.getClearType() in EventType.MESSAGE + EventType.POLL_START + EventType.STATE_ROOM_BEACON_INFO
+        this.getClearType() in EventType.MESSAGE + EventType.POLL_START.values + EventType.STATE_ROOM_BEACON_INFO.values
 
 fun Event.isContentReportable() =
-        this.getClearType() in EventType.MESSAGE + EventType.STATE_ROOM_BEACON_INFO
+        this.getClearType() in EventType.MESSAGE + EventType.STATE_ROOM_BEACON_INFO.values
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/EventExt.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/EventExt.kt
new file mode 100644
index 0000000000..32d5ebed8c
--- /dev/null
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/EventExt.kt
@@ -0,0 +1,46 @@
+/*
+ * Copyright 2022 The Matrix.org Foundation C.I.C.
+ *
+ * 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 org.matrix.android.sdk.api.session.events.model
+
+fun Event.toValidDecryptedEvent(): ValidDecryptedEvent? {
+    if (!this.isEncrypted()) return null
+    val decryptedContent = this.getDecryptedContent() ?: return null
+    val eventId = this.eventId ?: return null
+    val roomId = this.roomId ?: return null
+    val type = this.getDecryptedType() ?: return null
+    val senderKey = this.getSenderKey() ?: return null
+    val algorithm = this.content?.get("algorithm") as? String ?: return null
+
+    // copy the relation as it's in clear in the encrypted content
+    val updatedContent = this.content.get("m.relates_to")?.let {
+        decryptedContent.toMutableMap().apply {
+            put("m.relates_to", it)
+        }
+    } ?: decryptedContent
+    return ValidDecryptedEvent(
+            type = type,
+            eventId = eventId,
+            clearContent = updatedContent,
+            prevContent = this.prevContent,
+            originServerTs = this.originServerTs ?: 0,
+            cryptoSenderKey = senderKey,
+            roomId = roomId,
+            unsignedData = this.unsignedData,
+            redacts = this.redacts,
+            algorithm = algorithm
+    )
+}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/EventType.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/EventType.kt
index 3ad4f3a87f..013b452ced 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/EventType.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/EventType.kt
@@ -16,6 +16,8 @@
 
 package org.matrix.android.sdk.api.session.events.model
 
+import org.matrix.android.sdk.api.session.room.model.message.MessageType.MSGTYPE_VERIFICATION_REQUEST
+
 /**
  * Constants defining known event types from Matrix specifications.
  */
@@ -49,11 +51,10 @@ object EventType {
     const val STATE_ROOM_JOIN_RULES = "m.room.join_rules"
     const val STATE_ROOM_GUEST_ACCESS = "m.room.guest_access"
     const val STATE_ROOM_POWER_LEVELS = "m.room.power_levels"
-    val STATE_ROOM_BEACON_INFO = listOf("org.matrix.msc3672.beacon_info", "m.beacon_info")
-    val BEACON_LOCATION_DATA = listOf("org.matrix.msc3672.beacon", "m.beacon")
+    val STATE_ROOM_BEACON_INFO = StableUnstableId(stable = "m.beacon_info", unstable = "org.matrix.msc3672.beacon_info")
+    val BEACON_LOCATION_DATA = StableUnstableId(stable = "m.beacon", unstable = "org.matrix.msc3672.beacon")
 
     const val STATE_SPACE_CHILD = "m.space.child"
-
     const val STATE_SPACE_PARENT = "m.space.parent"
 
     /**
@@ -81,8 +82,7 @@ object EventType {
     const val CALL_NEGOTIATE = "m.call.negotiate"
     const val CALL_REJECT = "m.call.reject"
     const val CALL_HANGUP = "m.call.hangup"
-    const val CALL_ASSERTED_IDENTITY = "m.call.asserted_identity"
-    const val CALL_ASSERTED_IDENTITY_PREFIX = "org.matrix.call.asserted_identity"
+    val CALL_ASSERTED_IDENTITY = StableUnstableId(stable = "m.call.asserted_identity", unstable = "org.matrix.call.asserted_identity")
 
     // This type is not processed by the client, just sent to the server
     const val CALL_REPLACES = "m.call.replaces"
@@ -90,10 +90,7 @@ object EventType {
     // Key share events
     const val ROOM_KEY_REQUEST = "m.room_key_request"
     const val FORWARDED_ROOM_KEY = "m.forwarded_room_key"
-    val ROOM_KEY_WITHHELD = StableUnstableId(
-            stable = "m.room_key.withheld",
-            unstable = "org.matrix.room_key.withheld"
-    )
+    val ROOM_KEY_WITHHELD = StableUnstableId(stable = "m.room_key.withheld", unstable = "org.matrix.room_key.withheld")
 
     const val REQUEST_SECRET = "m.secret.request"
     const val SEND_SECRET = "m.secret.send"
@@ -111,9 +108,9 @@ object EventType {
     const val REACTION = "m.reaction"
 
     // Poll
-    val POLL_START = listOf("org.matrix.msc3381.poll.start", "m.poll.start")
-    val POLL_RESPONSE = listOf("org.matrix.msc3381.poll.response", "m.poll.response")
-    val POLL_END = listOf("org.matrix.msc3381.poll.end", "m.poll.end")
+    val POLL_START = StableUnstableId(stable = "m.poll.start", unstable = "org.matrix.msc3381.poll.start")
+    val POLL_RESPONSE = StableUnstableId(stable = "m.poll.response", unstable = "org.matrix.msc3381.poll.response")
+    val POLL_END = StableUnstableId(stable = "m.poll.end", unstable = "org.matrix.msc3381.poll.end")
 
     // Unwedging
     internal const val DUMMY = "m.dummy"
@@ -131,6 +128,7 @@ object EventType {
 
     fun isVerificationEvent(type: String): Boolean {
         return when (type) {
+            MSGTYPE_VERIFICATION_REQUEST,
             KEY_VERIFICATION_START,
             KEY_VERIFICATION_ACCEPT,
             KEY_VERIFICATION_KEY,
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/ValidDecryptedEvent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/ValidDecryptedEvent.kt
new file mode 100644
index 0000000000..b305bf19b0
--- /dev/null
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/events/model/ValidDecryptedEvent.kt
@@ -0,0 +1,37 @@
+/*
+ * Copyright 2022 The Matrix.org Foundation C.I.C.
+ *
+ * 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 org.matrix.android.sdk.api.session.events.model
+
+import org.matrix.android.sdk.api.session.room.model.message.MessageRelationContent
+import org.matrix.android.sdk.api.session.room.model.relation.RelationDefaultContent
+
+data class ValidDecryptedEvent(
+        val type: String,
+        val eventId: String,
+        val clearContent: Content,
+        val prevContent: Content? = null,
+        val originServerTs: Long,
+        val cryptoSenderKey: String,
+        val roomId: String,
+        val unsignedData: UnsignedData? = null,
+        val redacts: String? = null,
+        val algorithm: String,
+)
+
+fun ValidDecryptedEvent.getRelationContent(): RelationDefaultContent? {
+    return clearContent.toModel()?.relatesTo
+}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/homeserver/HomeServerCapabilities.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/homeserver/HomeServerCapabilities.kt
index 773e870ffd..11638837cc 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/homeserver/HomeServerCapabilities.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/homeserver/HomeServerCapabilities.kt
@@ -70,6 +70,11 @@ data class HomeServerCapabilities(
          * True if the home server supports threaded read receipts and unread notifications.
          */
         val canUseThreadReadReceiptsAndNotifications: Boolean = false,
+
+        /**
+         * True if the home server supports remote toggle of Pusher for a given device.
+         */
+        val canRemotelyTogglePushNotificationsOfDevices: Boolean = false,
 ) {
 
     enum class RoomCapabilitySupport {
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/homeserver/HomeServerCapabilitiesService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/homeserver/HomeServerCapabilitiesService.kt
index 9d2c48e194..c65a5382fb 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/homeserver/HomeServerCapabilitiesService.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/homeserver/HomeServerCapabilitiesService.kt
@@ -16,6 +16,9 @@
 
 package org.matrix.android.sdk.api.session.homeserver
 
+import androidx.lifecycle.LiveData
+import org.matrix.android.sdk.api.util.Optional
+
 /**
  * This interface defines a method to retrieve the homeserver capabilities.
  */
@@ -30,4 +33,9 @@ interface HomeServerCapabilitiesService {
      * Get the HomeServer capabilities.
      */
     fun getHomeServerCapabilities(): HomeServerCapabilities
+
+    /**
+     * Get a LiveData on the HomeServer capabilities.
+     */
+    fun getHomeServerCapabilitiesLive(): LiveData>
 }
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/location/LocationSharingService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/location/LocationSharingService.kt
index cd8acbcccc..93208be27b 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/location/LocationSharingService.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/location/LocationSharingService.kt
@@ -47,10 +47,9 @@ interface LocationSharingService {
     /**
      * Starts sharing live location in the room.
      * @param timeoutMillis timeout of the live in milliseconds
-     * @param description description of the live for text fallback
      * @return the result of the update of the live
      */
-    suspend fun startLiveLocationShare(timeoutMillis: Long, description: String): UpdateLiveLocationShareResult
+    suspend fun startLiveLocationShare(timeoutMillis: Long): UpdateLiveLocationShareResult
 
     /**
      * Stops sharing live location in the room.
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/EditAggregatedSummary.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/EditAggregatedSummary.kt
index 67bab626cb..7d445a5cc6 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/EditAggregatedSummary.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/EditAggregatedSummary.kt
@@ -15,10 +15,10 @@
  */
 package org.matrix.android.sdk.api.session.room.model
 
-import org.matrix.android.sdk.api.session.events.model.Content
+import org.matrix.android.sdk.api.session.events.model.Event
 
 data class EditAggregatedSummary(
-        val latestContent: Content? = null,
+        val latestEdit: Event? = null,
         // The list of the eventIDs used to build the summary (might be out of sync if chunked received from message chunk)
         val sourceEvents: List,
         val localEchos: List,
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/ReadReceipt.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/ReadReceipt.kt
index 5639730219..da7e4ea928 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/ReadReceipt.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/ReadReceipt.kt
@@ -18,5 +18,6 @@ package org.matrix.android.sdk.api.session.room.model
 
 data class ReadReceipt(
         val roomMember: RoomMemberSummary,
-        val originServerTs: Long
+        val originServerTs: Long,
+        val threadId: String?
 )
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/MessageStickerContent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/MessageStickerContent.kt
index f8c1c0d798..627ce53df6 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/MessageStickerContent.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/message/MessageStickerContent.kt
@@ -34,7 +34,7 @@ data class MessageStickerContent(
          * Required. A textual representation of the image. This could be the alt text of the image, the filename of the image,
          * or some kind of content description for accessibility e.g. 'image attachment'.
          */
-        @Json(name = "body") override val body: String,
+        @Json(name = "body") override val body: String = "",
 
         /**
          * Metadata about the image referred to in url.
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/relation/RelationDefaultContent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/relation/RelationDefaultContent.kt
index 5dcb1b4323..b9f9335dbd 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/relation/RelationDefaultContent.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/model/relation/RelationDefaultContent.kt
@@ -28,3 +28,5 @@ data class RelationDefaultContent(
 ) : RelationContent
 
 fun RelationDefaultContent.shouldRenderInThread(): Boolean = isFallingBack == false
+
+fun RelationDefaultContent?.isReply(): Boolean = this?.inReplyTo?.eventId != null
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/read/ReadService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/read/ReadService.kt
index dac1a1a773..83680ec2d8 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/read/ReadService.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/read/ReadService.kt
@@ -34,12 +34,14 @@ interface ReadService {
     /**
      * Force the read marker to be set on the latest event.
      */
-    suspend fun markAsRead(params: MarkAsReadParams = MarkAsReadParams.BOTH)
+    suspend fun markAsRead(params: MarkAsReadParams = MarkAsReadParams.BOTH, mainTimeLineOnly: Boolean = true)
 
     /**
      * Set the read receipt on the event with provided eventId.
+     * @param eventId the id of the event where read receipt will be set
+     * @param threadId the id of the thread in which read receipt will be set. For main thread use [ReadService.THREAD_ID_MAIN] constant
      */
-    suspend fun setReadReceipt(eventId: String)
+    suspend fun setReadReceipt(eventId: String, threadId: String)
 
     /**
      * Set the read marker on the event with provided eventId.
@@ -59,10 +61,10 @@ interface ReadService {
     /**
      * Returns a live read receipt id for the room.
      */
-    fun getMyReadReceiptLive(): LiveData>
+    fun getMyReadReceiptLive(threadId: String?): LiveData>
 
     /**
-     * Get the eventId where the read receipt for the provided user is.
+     * Get the eventId from the main timeline where the read receipt for the provided user is.
      * @param userId the id of the user to look for
      *
      * @return the eventId where the read receipt for the provided user is attached, or null if not found
@@ -74,4 +76,8 @@ interface ReadService {
      * @param eventId the event
      */
     fun getEventReadReceiptsLive(eventId: String): LiveData>
+
+    companion object {
+        const val THREAD_ID_MAIN = "main"
+    }
 }
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/summary/RoomSummaryConstants.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/summary/RoomSummaryConstants.kt
index 8f214e0f89..634e71c43b 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/summary/RoomSummaryConstants.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/summary/RoomSummaryConstants.kt
@@ -33,5 +33,7 @@ object RoomSummaryConstants {
             EventType.ENCRYPTED,
             EventType.STICKER,
             EventType.REACTION
-    ) + EventType.POLL_START + EventType.STATE_ROOM_BEACON_INFO
+    ) +
+            EventType.POLL_START.values +
+            EventType.STATE_ROOM_BEACON_INFO.values
 }
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/threads/FetchThreadsResult.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/threads/FetchThreadsResult.kt
new file mode 100644
index 0000000000..5d4d67a65e
--- /dev/null
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/threads/FetchThreadsResult.kt
@@ -0,0 +1,23 @@
+/*
+ * Copyright 2022 The Matrix.org Foundation C.I.C.
+ *
+ * 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 org.matrix.android.sdk.api.session.room.threads
+
+sealed class FetchThreadsResult {
+    data class ShouldFetchMore(val nextBatch: String) : FetchThreadsResult()
+    object ReachedEnd : FetchThreadsResult()
+    object Failed : FetchThreadsResult()
+}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/threads/ThreadFilter.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/threads/ThreadFilter.kt
new file mode 100644
index 0000000000..3f3576728f
--- /dev/null
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/threads/ThreadFilter.kt
@@ -0,0 +1,26 @@
+/*
+ * Copyright 2022 The Matrix.org Foundation C.I.C.
+ *
+ * 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 org.matrix.android.sdk.api.session.room.threads
+
+import com.squareup.moshi.Json
+import com.squareup.moshi.JsonClass
+
+@JsonClass(generateAdapter = false)
+enum class ThreadFilter {
+    @Json(name = "all") ALL,
+    @Json(name = "participated") PARTICIPATED,
+}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/threads/ThreadLivePageResult.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/threads/ThreadLivePageResult.kt
new file mode 100644
index 0000000000..7693dc6fde
--- /dev/null
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/threads/ThreadLivePageResult.kt
@@ -0,0 +1,27 @@
+/*
+ * Copyright (c) 2021 The Matrix.org Foundation C.I.C.
+ *
+ * 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 org.matrix.android.sdk.api.session.room.threads
+
+import androidx.lifecycle.LiveData
+import androidx.paging.PagedList
+import org.matrix.android.sdk.api.session.room.ResultBoundaries
+import org.matrix.android.sdk.api.session.room.threads.model.ThreadSummary
+
+data class ThreadLivePageResult(
+        val livePagedList: LiveData>,
+        val liveBoundaries: LiveData
+)
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/threads/ThreadsService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/threads/ThreadsService.kt
index 9587be68f1..bb6f6b51d3 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/threads/ThreadsService.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/threads/ThreadsService.kt
@@ -16,7 +16,7 @@
 
 package org.matrix.android.sdk.api.session.room.threads
 
-import androidx.lifecycle.LiveData
+import androidx.paging.PagedList
 import org.matrix.android.sdk.api.session.room.threads.model.ThreadSummary
 
 /**
@@ -27,15 +27,14 @@ import org.matrix.android.sdk.api.session.room.threads.model.ThreadSummary
  */
 interface ThreadsService {
 
-    /**
-     * Returns a [LiveData] list of all the [ThreadSummary] that exists at the room level.
-     */
-    fun getAllThreadSummariesLive(): LiveData>
+    suspend fun getPagedThreadsList(userParticipating: Boolean, pagedListConfig: PagedList.Config): ThreadLivePageResult
+
+    suspend fun fetchThreadList(nextBatchId: String?, limit: Int, filter: ThreadFilter = ThreadFilter.ALL): FetchThreadsResult
 
     /**
      * Returns a list of all the [ThreadSummary] that exists at the room level.
      */
-    fun getAllThreadSummaries(): List
+    suspend fun getAllThreadSummaries(): List
 
     /**
      * Enhance the provided ThreadSummary[List] by adding the latest
@@ -51,9 +50,4 @@ interface ThreadsService {
      * @param limit defines the number of max results the api will respond with
      */
     suspend fun fetchThreadTimeline(rootThreadEventId: String, from: String, limit: Int)
-
-    /**
-     * Fetch all thread summaries for the current room using the enhanced /messages api.
-     */
-    suspend fun fetchThreadSummaries()
 }
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineEvent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineEvent.kt
index 223acd1b9c..9053425a39 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineEvent.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineEvent.kt
@@ -18,6 +18,7 @@ package org.matrix.android.sdk.api.session.room.timeline
 
 import org.matrix.android.sdk.BuildConfig
 import org.matrix.android.sdk.api.extensions.orFalse
+import org.matrix.android.sdk.api.session.events.model.Content
 import org.matrix.android.sdk.api.session.events.model.Event
 import org.matrix.android.sdk.api.session.events.model.EventType
 import org.matrix.android.sdk.api.session.events.model.RelationType
@@ -142,13 +143,21 @@ fun TimelineEvent.getEditedEventId(): String? {
 fun TimelineEvent.getLastMessageContent(): MessageContent? {
     return when (root.getClearType()) {
         EventType.STICKER -> root.getClearContent().toModel()
-        in EventType.POLL_START -> (annotations?.editSummary?.latestContent ?: root.getClearContent()).toModel()
-        in EventType.STATE_ROOM_BEACON_INFO -> (annotations?.editSummary?.latestContent ?: root.getClearContent()).toModel()
-        in EventType.BEACON_LOCATION_DATA -> (annotations?.editSummary?.latestContent ?: root.getClearContent()).toModel()
-        else -> (annotations?.editSummary?.latestContent ?: root.getClearContent()).toModel()
+        // XXX
+        // Polls/Beacon are not message contents like others as there is no msgtype subtype to discriminate moshi parsing
+        // so toModel won't parse them correctly
+        // It's discriminated on event type instead. Maybe it shouldn't be MessageContent at all to avoid confusion?
+        in EventType.POLL_START.values -> (getLastEditNewContent() ?: root.getClearContent()).toModel()
+        in EventType.STATE_ROOM_BEACON_INFO.values -> (getLastEditNewContent() ?: root.getClearContent()).toModel()
+        in EventType.BEACON_LOCATION_DATA.values -> (getLastEditNewContent() ?: root.getClearContent()).toModel()
+        else -> (getLastEditNewContent() ?: root.getClearContent()).toModel()
     }
 }
 
+fun TimelineEvent.getLastEditNewContent(): Content? {
+    return annotations?.editSummary?.latestEdit?.getClearContent()?.toModel()?.newContent
+}
+
 /**
  * Returns true if it's a reply.
  */
@@ -180,11 +189,13 @@ fun TimelineEvent.isRootThread(): Boolean {
 
 /**
  * Get the latest message body, after a possible edition, stripping the reply prefix if necessary.
+ * @param formatted Indicates whether the formatted HTML body of the message should be retrieved of the plain text one.
+ * @return If [formatted] is `true`, the HTML body of the message will be retrieved if available. Otherwise, the plain text/markdown version will be returned.
  */
 fun TimelineEvent.getTextEditableContent(formatted: Boolean): String {
     val lastMessageContent = getLastMessageContent()
     val lastContentBody = if (formatted && lastMessageContent is MessageContentWithFormattedBody) {
-        lastMessageContent.formattedBody
+        lastMessageContent.formattedBody ?: lastMessageContent.body
     } else {
         lastMessageContent?.body
     } ?: return ""
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/sync/filter/SyncFilterParams.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/sync/filter/SyncFilterParams.kt
new file mode 100644
index 0000000000..02c5b0f8ef
--- /dev/null
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/sync/filter/SyncFilterParams.kt
@@ -0,0 +1,25 @@
+/*
+ * Copyright 2022 The Matrix.org Foundation C.I.C.
+ *
+ * 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 org.matrix.android.sdk.api.session.sync.filter
+
+data class SyncFilterParams(
+        val lazyLoadMembersForStateEvents: Boolean? = null,
+        val lazyLoadMembersForMessageEvents: Boolean? = null,
+        val useThreadNotifications: Boolean? = null,
+        val listOfSupportedEventTypes: List? = null,
+        val listOfSupportedStateEventTypes: List? = null,
+)
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/DefaultAuthenticationService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/DefaultAuthenticationService.kt
index d1d32e7945..362bb7cf2d 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/DefaultAuthenticationService.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/DefaultAuthenticationService.kt
@@ -31,7 +31,6 @@ import org.matrix.android.sdk.api.auth.data.PasswordPolicy
 import org.matrix.android.sdk.api.auth.login.LoginWizard
 import org.matrix.android.sdk.api.auth.registration.RegistrationWizard
 import org.matrix.android.sdk.api.auth.wellknown.WellknownResult
-import org.matrix.android.sdk.api.extensions.orFalse
 import org.matrix.android.sdk.api.failure.Failure
 import org.matrix.android.sdk.api.failure.MatrixIdFailure
 import org.matrix.android.sdk.api.session.Session
@@ -300,7 +299,8 @@ internal class DefaultAuthenticationService @Inject constructor(
                 isLoginAndRegistrationSupported = versions.isLoginAndRegistrationSupportedBySdk(),
                 homeServerUrl = homeServerUrl,
                 isOutdatedHomeserver = !versions.isSupportedBySdk(),
-                isLogoutDevicesSupported = versions.doesServerSupportLogoutDevices()
+                isLogoutDevicesSupported = versions.doesServerSupportLogoutDevices(),
+                isLoginWithQrSupported = versions.doesServerSupportQrCodeLogin(),
         )
     }
 
@@ -417,20 +417,6 @@ internal class DefaultAuthenticationService @Inject constructor(
         }
     }
 
-    override suspend fun isQrLoginSupported(homeServerConnectionConfig: HomeServerConnectionConfig): Boolean {
-        val authAPI = buildAuthAPI(homeServerConnectionConfig)
-        val versions = runCatching {
-            executeRequest(null) {
-                authAPI.versions()
-            }
-        }
-        return if (versions.isSuccess) {
-            versions.getOrNull()?.doesServerSupportQrCodeLogin().orFalse()
-        } else {
-            false
-        }
-    }
-
     override suspend fun loginUsingQrLoginToken(
             homeServerConnectionConfig: HomeServerConnectionConfig,
             loginToken: String,
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/version/Versions.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/version/Versions.kt
index 1245d8df4b..f4de6a9ae9 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/version/Versions.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/auth/version/Versions.kt
@@ -18,6 +18,7 @@ package org.matrix.android.sdk.internal.auth.version
 
 import com.squareup.moshi.Json
 import com.squareup.moshi.JsonClass
+import org.matrix.android.sdk.api.extensions.orFalse
 
 /**
  * Model for https://matrix.org/docs/spec/client_server/latest#get-matrix-client-versions.
@@ -56,6 +57,7 @@ private const val FEATURE_THREADS_MSC3440_STABLE = "org.matrix.msc3440.stable"
 private const val FEATURE_QR_CODE_LOGIN = "org.matrix.msc3882"
 private const val FEATURE_THREADS_MSC3771 = "org.matrix.msc3771"
 private const val FEATURE_THREADS_MSC3773 = "org.matrix.msc3773"
+private const val FEATURE_REMOTE_TOGGLE_PUSH_NOTIFICATIONS_MSC3881 = "org.matrix.msc3881"
 
 /**
  * Return true if the SDK supports this homeserver version.
@@ -142,3 +144,12 @@ private fun Versions.getMaxVersion(): HomeServerVersion {
             ?.maxOrNull()
             ?: HomeServerVersion.r0_0_0
 }
+
+/**
+ * Indicate if the server supports MSC3881: https://github.com/matrix-org/matrix-spec-proposals/pull/3881.
+ *
+ * @return true if remote toggle of push notifications is supported
+ */
+internal fun Versions.doesServerSupportRemoteToggleOfPushNotifications(): Boolean {
+    return unstableFeatures?.get(FEATURE_REMOTE_TOGGLE_PUSH_NOTIFICATIONS_MSC3881).orFalse()
+}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/DefaultCryptoService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/DefaultCryptoService.kt
index 9c3e0ba1c5..7862da1c17 100755
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/DefaultCryptoService.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/DefaultCryptoService.kt
@@ -242,8 +242,12 @@ internal class DefaultCryptoService @Inject constructor(
     }
 
     override fun deleteDevice(deviceId: String, userInteractiveAuthInterceptor: UserInteractiveAuthInterceptor, callback: MatrixCallback) {
+        deleteDevices(listOf(deviceId), userInteractiveAuthInterceptor, callback)
+    }
+
+    override fun deleteDevices(deviceIds: List, userInteractiveAuthInterceptor: UserInteractiveAuthInterceptor, callback: MatrixCallback) {
         deleteDeviceTask
-                .configureWith(DeleteDeviceTask.Params(deviceId, userInteractiveAuthInterceptor, null)) {
+                .configureWith(DeleteDeviceTask.Params(deviceIds, userInteractiveAuthInterceptor, null)) {
                     this.executionThread = TaskThread.CRYPTO
                     this.callback = callback
                 }
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/DeviceListManager.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/DeviceListManager.kt
index 2ac6b8c854..7e9e156003 100755
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/DeviceListManager.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/DeviceListManager.kt
@@ -355,7 +355,7 @@ internal class DeviceListManager @Inject constructor(
         val relevantPlugins = metricPlugins.filterIsInstance()
 
         val response: KeysQueryResponse
-        measureMetric(relevantPlugins) {
+        relevantPlugins.measureMetric {
             response = try {
                 downloadKeysForUsersTask.execute(params)
             } catch (throwable: Throwable) {
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/EventDecryptor.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/EventDecryptor.kt
index bc3309132a..c9eabeab48 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/EventDecryptor.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/EventDecryptor.kt
@@ -24,10 +24,12 @@ import kotlinx.coroutines.withContext
 import org.matrix.android.sdk.api.MatrixCallback
 import org.matrix.android.sdk.api.MatrixCoroutineDispatchers
 import org.matrix.android.sdk.api.crypto.MXCRYPTO_ALGORITHM_OLM
+import org.matrix.android.sdk.api.extensions.tryOrNull
 import org.matrix.android.sdk.api.logger.LoggerTag
 import org.matrix.android.sdk.api.session.crypto.MXCryptoError
 import org.matrix.android.sdk.api.session.crypto.model.MXEventDecryptionResult
 import org.matrix.android.sdk.api.session.crypto.model.MXUsersDevicesMap
+import org.matrix.android.sdk.api.session.crypto.model.OlmDecryptionResult
 import org.matrix.android.sdk.api.session.events.model.Event
 import org.matrix.android.sdk.api.session.events.model.EventType
 import org.matrix.android.sdk.api.session.events.model.content.OlmEventContent
@@ -85,6 +87,27 @@ internal class EventDecryptor @Inject constructor(
         return internalDecryptEvent(event, timeline)
     }
 
+    /**
+     * Decrypt an event and save the result in the given event.
+     *
+     * @param event the raw event.
+     * @param timeline the id of the timeline where the event is decrypted. It is used to prevent replay attack.
+     */
+    suspend fun decryptEventAndSaveResult(event: Event, timeline: String) {
+        tryOrNull(message = "Unable to decrypt the event") {
+            decryptEvent(event, timeline)
+        }
+                ?.let { result ->
+                    event.mxDecryptionResult = OlmDecryptionResult(
+                            payload = result.clearEvent,
+                            senderKey = result.senderCurve25519Key,
+                            keysClaimed = result.claimedEd25519Key?.let { mapOf("ed25519" to it) },
+                            forwardingCurve25519KeyChain = result.forwardingCurve25519KeyChain,
+                            isSafe = result.isSafe
+                    )
+                }
+    }
+
     /**
      * Decrypt an event asynchronously.
      *
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/api/CryptoApi.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/api/CryptoApi.kt
index d5a8bdfd7c..cfe4681bfd 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/api/CryptoApi.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/api/CryptoApi.kt
@@ -18,6 +18,7 @@ package org.matrix.android.sdk.internal.crypto.api
 import org.matrix.android.sdk.api.session.crypto.model.DeviceInfo
 import org.matrix.android.sdk.api.session.crypto.model.DevicesListResponse
 import org.matrix.android.sdk.internal.crypto.model.rest.DeleteDeviceParams
+import org.matrix.android.sdk.internal.crypto.model.rest.DeleteDevicesParams
 import org.matrix.android.sdk.internal.crypto.model.rest.KeyChangesResponse
 import org.matrix.android.sdk.internal.crypto.model.rest.KeysClaimBody
 import org.matrix.android.sdk.internal.crypto.model.rest.KeysClaimResponse
@@ -136,6 +137,17 @@ internal interface CryptoApi {
             @Body params: DeleteDeviceParams
     )
 
+    /**
+     * Deletes the given devices, and invalidates any access token associated with them.
+     * Doc: https://spec.matrix.org/v1.4/client-server-api/#post_matrixclientv3delete_devices
+     *
+     * @param params the deletion parameters
+     */
+    @POST(NetworkConstants.URI_API_PREFIX_PATH_V3 + "delete_devices")
+    suspend fun deleteDevices(
+            @Body params: DeleteDevicesParams
+    )
+
     /**
      * Update the device information.
      * Doc: https://matrix.org/docs/spec/client_server/r0.4.0.html#put-matrix-client-r0-devices-deviceid
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/crosssigning/CrossSigningOlm.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/crosssigning/CrossSigningOlm.kt
index 3218b99948..0f29404d4f 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/crosssigning/CrossSigningOlm.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/crosssigning/CrossSigningOlm.kt
@@ -83,9 +83,7 @@ internal class CrossSigningOlm @Inject constructor(
         val signaturesMadeByMyKey = signatures[myUserID] // Signatures made by me
                 ?.get("ed25519:$pubKey")
 
-        if (signaturesMadeByMyKey.isNullOrBlank()) {
-            throw IllegalArgumentException("Not signed with my key $type")
-        }
+        require(signaturesMadeByMyKey.orEmpty().isNotBlank()) { "Not signed with my key $type" }
 
         // Check that Alice USK signature of Bob MSK is valid
         olmUtility.verifyEd25519Signature(signaturesMadeByMyKey, pubKey, JsonCanonicalizer.getCanonicalJson(Map::class.java, signable))
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/model/rest/DeleteDeviceParams.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/model/rest/DeleteDeviceParams.kt
index c26c6107c4..24dccc4d90 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/model/rest/DeleteDeviceParams.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/model/rest/DeleteDeviceParams.kt
@@ -23,6 +23,9 @@ import com.squareup.moshi.JsonClass
  */
 @JsonClass(generateAdapter = true)
 internal data class DeleteDeviceParams(
+        /**
+         * Additional authentication information for the user-interactive authentication API.
+         */
         @Json(name = "auth")
-        val auth: Map? = null
+        val auth: Map? = null,
 )
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/model/rest/DeleteDevicesParams.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/model/rest/DeleteDevicesParams.kt
new file mode 100644
index 0000000000..19b33b2a69
--- /dev/null
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/model/rest/DeleteDevicesParams.kt
@@ -0,0 +1,37 @@
+/*
+ * Copyright 2022 The Matrix.org Foundation C.I.C.
+ *
+ * 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 org.matrix.android.sdk.internal.crypto.model.rest
+
+import com.squareup.moshi.Json
+import com.squareup.moshi.JsonClass
+
+/**
+ * This class provides the parameter to delete several devices.
+ */
+@JsonClass(generateAdapter = true)
+internal data class DeleteDevicesParams(
+        /**
+         * Additional authentication information for the user-interactive authentication API.
+         */
+        @Json(name = "auth")
+        val auth: Map? = null,
+
+        /**
+         * Required: The list of device IDs to delete.
+         */
+        @Json(name = "devices")
+        val deviceIds: List,
+)
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/tasks/DeleteDeviceTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/tasks/DeleteDeviceTask.kt
index 0a77d33acc..549122447e 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/tasks/DeleteDeviceTask.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/tasks/DeleteDeviceTask.kt
@@ -16,12 +16,14 @@
 
 package org.matrix.android.sdk.internal.crypto.tasks
 
+import androidx.annotation.Size
 import org.matrix.android.sdk.api.auth.UIABaseAuth
 import org.matrix.android.sdk.api.auth.UserInteractiveAuthInterceptor
 import org.matrix.android.sdk.api.session.uia.UiaResult
 import org.matrix.android.sdk.internal.auth.registration.handleUIA
 import org.matrix.android.sdk.internal.crypto.api.CryptoApi
 import org.matrix.android.sdk.internal.crypto.model.rest.DeleteDeviceParams
+import org.matrix.android.sdk.internal.crypto.model.rest.DeleteDevicesParams
 import org.matrix.android.sdk.internal.network.GlobalErrorReceiver
 import org.matrix.android.sdk.internal.network.executeRequest
 import org.matrix.android.sdk.internal.task.Task
@@ -30,7 +32,7 @@ import javax.inject.Inject
 
 internal interface DeleteDeviceTask : Task {
     data class Params(
-            val deviceId: String,
+            @Size(min = 1) val deviceIds: List,
             val userInteractiveAuthInterceptor: UserInteractiveAuthInterceptor?,
             val userAuthParam: UIABaseAuth?
     )
@@ -42,9 +44,24 @@ internal class DefaultDeleteDeviceTask @Inject constructor(
 ) : DeleteDeviceTask {
 
     override suspend fun execute(params: DeleteDeviceTask.Params) {
+        require(params.deviceIds.isNotEmpty())
+
         try {
             executeRequest(globalErrorReceiver) {
-                cryptoApi.deleteDevice(params.deviceId, DeleteDeviceParams(params.userAuthParam?.asMap()))
+                val userAuthParam = params.userAuthParam?.asMap()
+                if (params.deviceIds.size == 1) {
+                    cryptoApi.deleteDevice(
+                            deviceId = params.deviceIds.first(),
+                            DeleteDeviceParams(auth = userAuthParam)
+                    )
+                } else {
+                    cryptoApi.deleteDevices(
+                            DeleteDevicesParams(
+                                    auth = userAuthParam,
+                                    deviceIds = params.deviceIds
+                            )
+                    )
+                }
             }
         } catch (throwable: Throwable) {
             if (params.userInteractiveAuthInterceptor == null ||
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/tasks/EncryptEventTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/tasks/EncryptEventTask.kt
index f93da74507..5d2797a6af 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/tasks/EncryptEventTask.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/tasks/EncryptEventTask.kt
@@ -47,9 +47,8 @@ internal class DefaultEncryptEventTask @Inject constructor(
         // don't want to wait for any query
         // if (!params.crypto.isRoomEncrypted(params.roomId)) return params.event
         val localEvent = params.event
-        if (localEvent.eventId == null || localEvent.type == null) {
-            throw IllegalArgumentException()
-        }
+        require(localEvent.eventId != null)
+        require(localEvent.type != null)
 
         localEchoRepository.updateSendState(localEvent.eventId, localEvent.roomId, SendState.ENCRYPTING)
 
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/tasks/SendToDeviceTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/tasks/SendToDeviceTask.kt
index fc4d422360..a7e93202ef 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/tasks/SendToDeviceTask.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/tasks/SendToDeviceTask.kt
@@ -17,14 +17,22 @@
 package org.matrix.android.sdk.internal.crypto.tasks
 
 import org.matrix.android.sdk.api.session.crypto.model.MXUsersDevicesMap
+import org.matrix.android.sdk.api.session.events.model.Event
+import org.matrix.android.sdk.api.session.events.model.EventType
+import org.matrix.android.sdk.api.session.events.model.toContent
 import org.matrix.android.sdk.internal.crypto.api.CryptoApi
 import org.matrix.android.sdk.internal.crypto.model.rest.SendToDeviceBody
 import org.matrix.android.sdk.internal.network.GlobalErrorReceiver
 import org.matrix.android.sdk.internal.network.executeRequest
 import org.matrix.android.sdk.internal.task.Task
+import timber.log.Timber
 import java.util.UUID
 import javax.inject.Inject
 
+const val TO_DEVICE_TRACING_ID_KEY = "org.matrix.msgid"
+
+fun Event.toDeviceTracingId(): String? = content?.get(TO_DEVICE_TRACING_ID_KEY) as? String
+
 internal interface SendToDeviceTask : Task {
     data class Params(
             // the type of event to send
@@ -32,7 +40,9 @@ internal interface SendToDeviceTask : Task {
             // the content to send. Map from user_id to device_id to content dictionary.
             val contentMap: MXUsersDevicesMap,
             // the transactionId. If not provided, a transactionId will be created by the task
-            val transactionId: String? = null
+            val transactionId: String? = null,
+            // add tracing id, notice that to device events that do signature on content might be broken by it
+            val addTracingIds: Boolean = !EventType.isVerificationEvent(eventType),
     )
 }
 
@@ -42,15 +52,22 @@ internal class DefaultSendToDeviceTask @Inject constructor(
 ) : SendToDeviceTask {
 
     override suspend fun execute(params: SendToDeviceTask.Params) {
-        val sendToDeviceBody = SendToDeviceBody(
-                messages = params.contentMap.map
-        )
-
         // If params.transactionId is not provided, we create a unique txnId.
         // It's important to do that outside the requestBlock parameter of executeRequest()
         // to use the same value if the request is retried
         val txnId = params.transactionId ?: createUniqueTxnId()
 
+        // add id tracing to debug
+        val decorated = if (params.addTracingIds) {
+            decorateWithToDeviceTracingIds(params)
+        } else {
+            params.contentMap.map to emptyList()
+        }
+
+        val sendToDeviceBody = SendToDeviceBody(
+                messages = decorated.first
+        )
+
         return executeRequest(
                 globalErrorReceiver,
                 canRetry = true,
@@ -61,8 +78,35 @@ internal class DefaultSendToDeviceTask @Inject constructor(
                     transactionId = txnId,
                     body = sendToDeviceBody
             )
+            Timber.i("Sent to device type=${params.eventType} txnid=$txnId [${decorated.second.joinToString(",")}]")
         }
     }
+
+    /**
+     * To make it easier to track down where to-device messages are getting lost,
+     * add a custom property to each one, and that will be logged after sent and on reception. Synapse will also log
+     * this property.
+     * @return A pair, first is the decorated content, and second info to log out after sending
+     */
+    private fun decorateWithToDeviceTracingIds(params: SendToDeviceTask.Params): Pair>, List> {
+        val tracingInfo = mutableListOf()
+        val decoratedContent = params.contentMap.map.map { userToDeviceMap ->
+            val userId = userToDeviceMap.key
+            userId to userToDeviceMap.value.map {
+                val deviceId = it.key
+                deviceId to it.value.toContent().toMutableMap().apply {
+                    put(
+                            TO_DEVICE_TRACING_ID_KEY,
+                            UUID.randomUUID().toString().also {
+                                tracingInfo.add("$userId/$deviceId (msgid $it)")
+                            }
+                    )
+                }
+            }.toMap()
+        }.toMap()
+
+        return decoratedContent to tracingInfo
+    }
 }
 
 internal fun createUniqueTxnId() = UUID.randomUUID().toString()
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/verification/DefaultVerificationService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/verification/DefaultVerificationService.kt
index 1a04ee0302..5b400aa63f 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/verification/DefaultVerificationService.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/crypto/verification/DefaultVerificationService.kt
@@ -1140,28 +1140,25 @@ internal class DefaultVerificationService @Inject constructor(
     override fun beginKeyVerification(method: VerificationMethod, otherUserId: String, otherDeviceId: String, transactionId: String?): String? {
         val txID = transactionId?.takeIf { it.isNotEmpty() } ?: createUniqueIDForTransaction(otherUserId, otherDeviceId)
         // should check if already one (and cancel it)
-        if (method == VerificationMethod.SAS) {
-            val tx = DefaultOutgoingSASDefaultVerificationTransaction(
-                    setDeviceVerificationAction,
-                    userId,
-                    deviceId,
-                    cryptoStore,
-                    crossSigningService,
-                    outgoingKeyRequestManager,
-                    secretShareManager,
-                    myDeviceInfoHolder.get().myDevice.fingerprint()!!,
-                    txID,
-                    otherUserId,
-                    otherDeviceId
-            )
-            tx.transport = verificationTransportToDeviceFactory.createTransport(tx)
-            addTransaction(tx)
+        require(method == VerificationMethod.SAS) { "Unknown verification method" }
+        val tx = DefaultOutgoingSASDefaultVerificationTransaction(
+                setDeviceVerificationAction,
+                userId,
+                deviceId,
+                cryptoStore,
+                crossSigningService,
+                outgoingKeyRequestManager,
+                secretShareManager,
+                myDeviceInfoHolder.get().myDevice.fingerprint()!!,
+                txID,
+                otherUserId,
+                otherDeviceId
+        )
+        tx.transport = verificationTransportToDeviceFactory.createTransport(tx)
+        addTransaction(tx)
 
-            tx.start()
-            return txID
-        } else {
-            throw IllegalArgumentException("Unknown verification method")
-        }
+        tx.start()
+        return txID
     }
 
     override fun requestKeyVerificationInDMs(
@@ -1343,28 +1340,25 @@ internal class DefaultVerificationService @Inject constructor(
             otherUserId: String,
             otherDeviceId: String
     ): String {
-        if (method == VerificationMethod.SAS) {
-            val tx = DefaultOutgoingSASDefaultVerificationTransaction(
-                    setDeviceVerificationAction,
-                    userId,
-                    deviceId,
-                    cryptoStore,
-                    crossSigningService,
-                    outgoingKeyRequestManager,
-                    secretShareManager,
-                    myDeviceInfoHolder.get().myDevice.fingerprint()!!,
-                    transactionId,
-                    otherUserId,
-                    otherDeviceId
-            )
-            tx.transport = verificationTransportRoomMessageFactory.createTransport(roomId, tx)
-            addTransaction(tx)
+        require(method == VerificationMethod.SAS) { "Unknown verification method" }
+        val tx = DefaultOutgoingSASDefaultVerificationTransaction(
+                setDeviceVerificationAction,
+                userId,
+                deviceId,
+                cryptoStore,
+                crossSigningService,
+                outgoingKeyRequestManager,
+                secretShareManager,
+                myDeviceInfoHolder.get().myDevice.fingerprint()!!,
+                transactionId,
+                otherUserId,
+                otherDeviceId
+        )
+        tx.transport = verificationTransportRoomMessageFactory.createTransport(roomId, tx)
+        addTransaction(tx)
 
-            tx.start()
-            return transactionId
-        } else {
-            throw IllegalArgumentException("Unknown verification method")
-        }
+        tx.start()
+        return transactionId
     }
 
     override fun readyPendingVerificationInDMs(
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/AsyncTransaction.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/AsyncTransaction.kt
index 7d263f1937..a1ea88a70c 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/AsyncTransaction.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/AsyncTransaction.kt
@@ -26,17 +26,17 @@ import kotlinx.coroutines.withContext
 import timber.log.Timber
 import kotlin.system.measureTimeMillis
 
-internal fun  CoroutineScope.asyncTransaction(monarchy: Monarchy, transaction: suspend (realm: Realm) -> T) {
+internal fun  CoroutineScope.asyncTransaction(monarchy: Monarchy, transaction: (realm: Realm) -> T) {
     asyncTransaction(monarchy.realmConfiguration, transaction)
 }
 
-internal fun  CoroutineScope.asyncTransaction(realmConfiguration: RealmConfiguration, transaction: suspend (realm: Realm) -> T) {
+internal fun  CoroutineScope.asyncTransaction(realmConfiguration: RealmConfiguration, transaction: (realm: Realm) -> T) {
     launch {
         awaitTransaction(realmConfiguration, transaction)
     }
 }
 
-internal suspend fun  awaitTransaction(config: RealmConfiguration, transaction: suspend (realm: Realm) -> T): T {
+internal suspend fun  awaitTransaction(config: RealmConfiguration, transaction: (realm: Realm) -> T): T {
     return withContext(Realm.WRITE_EXECUTOR.asCoroutineDispatcher()) {
         Realm.getInstance(config).use { bgRealm ->
             bgRealm.beginTransaction()
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/RealmSessionStoreMigration.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/RealmSessionStoreMigration.kt
index 49b4154df3..3d39fdb801 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/RealmSessionStoreMigration.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/RealmSessionStoreMigration.kt
@@ -59,6 +59,12 @@ import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo038
 import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo039
 import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo040
 import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo041
+import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo042
+import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo043
+import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo044
+import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo045
+import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo046
+import org.matrix.android.sdk.internal.database.migration.MigrateSessionTo047
 import org.matrix.android.sdk.internal.util.Normalizer
 import org.matrix.android.sdk.internal.util.database.MatrixRealmMigration
 import javax.inject.Inject
@@ -67,7 +73,7 @@ internal class RealmSessionStoreMigration @Inject constructor(
         private val normalizer: Normalizer
 ) : MatrixRealmMigration(
         dbName = "Session",
-        schemaVersion = 41L,
+        schemaVersion = 47L,
 ) {
     /**
      * Forces all RealmSessionStoreMigration instances to be equal.
@@ -121,5 +127,11 @@ internal class RealmSessionStoreMigration @Inject constructor(
         if (oldVersion < 39) MigrateSessionTo039(realm).perform()
         if (oldVersion < 40) MigrateSessionTo040(realm).perform()
         if (oldVersion < 41) MigrateSessionTo041(realm).perform()
+        if (oldVersion < 42) MigrateSessionTo042(realm).perform()
+        if (oldVersion < 43) MigrateSessionTo043(realm).perform()
+        if (oldVersion < 44) MigrateSessionTo044(realm).perform()
+        if (oldVersion < 45) MigrateSessionTo045(realm).perform()
+        if (oldVersion < 46) MigrateSessionTo046(realm).perform()
+        if (oldVersion < 47) MigrateSessionTo047(realm).perform()
     }
 }
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/helper/ChunkEntityHelper.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/helper/ChunkEntityHelper.kt
index 221abe0df5..43f84e771a 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/helper/ChunkEntityHelper.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/helper/ChunkEntityHelper.kt
@@ -83,7 +83,6 @@ internal fun ChunkEntity.addTimelineEvent(
         this.eventId = eventId
         this.roomId = roomId
         this.annotations = EventAnnotationsSummaryEntity.where(realm, roomId, eventId).findFirst()
-                ?.also { it.cleanUp(eventEntity.sender) }
         this.readReceipts = readReceiptsSummaryEntity
         this.displayIndex = displayIndex
         this.ownedByThreadChunk = ownedByThreadChunk
@@ -133,7 +132,7 @@ private fun handleReadReceipts(realm: Realm, roomId: String, eventEntity: EventE
     val originServerTs = eventEntity.originServerTs
     if (originServerTs != null) {
         val timestampOfEvent = originServerTs.toDouble()
-        val readReceiptOfSender = ReadReceiptEntity.getOrCreate(realm, roomId = roomId, userId = senderId)
+        val readReceiptOfSender = ReadReceiptEntity.getOrCreate(realm, roomId = roomId, userId = senderId, threadId = eventEntity.rootThreadEventId)
         // If the synced RR is older, update
         if (timestampOfEvent > readReceiptOfSender.originServerTs) {
             val previousReceiptsSummary = ReadReceiptsSummaryEntity.where(realm, eventId = readReceiptOfSender.eventId).findFirst()
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/helper/ThreadEventsHelper.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/helper/ThreadEventsHelper.kt
index dfac7f6708..7999a2ea14 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/helper/ThreadEventsHelper.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/helper/ThreadEventsHelper.kt
@@ -65,11 +65,11 @@ internal fun Map.updateThreadSummaryIfNeeded(
                     inThreadMessages = inThreadMessages,
                     latestMessageTimelineEventEntity = latestEventInThread
             )
-        }
-    }
 
-    if (shouldUpdateNotifications) {
-        updateNotificationsNew(roomId, realm, currentUserId)
+            if (shouldUpdateNotifications) {
+                updateThreadNotifications(roomId, realm, currentUserId, rootThreadEventId)
+            }
+        }
     }
 }
 
@@ -273,8 +273,8 @@ internal fun TimelineEventEntity.Companion.isUserMentionedInThread(realm: Realm,
 /**
  * Find the read receipt for the current user.
  */
-internal fun findMyReadReceipt(realm: Realm, roomId: String, userId: String): String? =
-        ReadReceiptEntity.where(realm, roomId = roomId, userId = userId)
+internal fun findMyReadReceipt(realm: Realm, roomId: String, userId: String, threadId: String?): String? =
+        ReadReceiptEntity.where(realm, roomId = roomId, userId = userId, threadId = threadId)
                 .findFirst()
                 ?.eventId
 
@@ -293,28 +293,29 @@ internal fun isUserMentioned(currentUserId: String, timelineEventEntity: Timelin
  * Important: It will work only with the latest chunk, while read marker will be changed
  * immediately so we should not display wrong notifications
  */
-internal fun updateNotificationsNew(roomId: String, realm: Realm, currentUserId: String) {
-    val readReceipt = findMyReadReceipt(realm, roomId, currentUserId) ?: return
+internal fun updateThreadNotifications(roomId: String, realm: Realm, currentUserId: String, rootThreadEventId: String) {
+    val readReceipt = findMyReadReceipt(realm, roomId, currentUserId, threadId = rootThreadEventId) ?: return
 
     val readReceiptChunk = ChunkEntity
             .findIncludingEvent(realm, readReceipt) ?: return
 
-    val readReceiptChunkTimelineEvents = readReceiptChunk
+    val readReceiptChunkThreadEvents = readReceiptChunk
             .timelineEvents
             .where()
             .equalTo(TimelineEventEntityFields.ROOM_ID, roomId)
+            .equalTo(TimelineEventEntityFields.ROOT.ROOT_THREAD_EVENT_ID, rootThreadEventId)
             .sort(TimelineEventEntityFields.DISPLAY_INDEX, Sort.ASCENDING)
             .findAll() ?: return
 
-    val readReceiptChunkPosition = readReceiptChunkTimelineEvents.indexOfFirst { it.eventId == readReceipt }
+    val readReceiptChunkPosition = readReceiptChunkThreadEvents.indexOfFirst { it.eventId == readReceipt }
 
     if (readReceiptChunkPosition == -1) return
 
-    if (readReceiptChunkPosition < readReceiptChunkTimelineEvents.lastIndex) {
+    if (readReceiptChunkPosition < readReceiptChunkThreadEvents.lastIndex) {
         // If the read receipt is found inside the chunk
 
-        val threadEventsAfterReadReceipt = readReceiptChunkTimelineEvents
-                .slice(readReceiptChunkPosition..readReceiptChunkTimelineEvents.lastIndex)
+        val threadEventsAfterReadReceipt = readReceiptChunkThreadEvents
+                .slice(readReceiptChunkPosition..readReceiptChunkThreadEvents.lastIndex)
                 .filter { it.root?.isThread() == true }
 
         // In order for the below code to work for old events, we should save the previous read receipt
@@ -343,26 +344,21 @@ internal fun updateNotificationsNew(roomId: String, realm: Realm, currentUserId:
                     it.root?.rootThreadEventId
                 }
 
-        // Find the root events in the new thread events
-        val rootThreads = threadEventsAfterReadReceipt.distinctBy { it.root?.rootThreadEventId }.mapNotNull { it.root?.rootThreadEventId }
-
-        // Update root thread events only if the user have participated in
-        rootThreads.forEach { eventId ->
-            val isUserParticipating = TimelineEventEntity.isUserParticipatingInThread(
-                    realm = realm,
-                    roomId = roomId,
-                    rootThreadEventId = eventId,
-                    senderId = currentUserId
-            )
-            val rootThreadEventEntity = EventEntity.where(realm, eventId).findFirst()
-
-            if (isUserParticipating) {
-                rootThreadEventEntity?.threadNotificationState = ThreadNotificationState.NEW_MESSAGE
-            }
+        // Update root thread event only if the user have participated in
+        val isUserParticipating = TimelineEventEntity.isUserParticipatingInThread(
+                realm = realm,
+                roomId = roomId,
+                rootThreadEventId = rootThreadEventId,
+                senderId = currentUserId
+        )
+        val rootThreadEventEntity = EventEntity.where(realm, rootThreadEventId).findFirst()
+
+        if (isUserParticipating) {
+            rootThreadEventEntity?.threadNotificationState = ThreadNotificationState.NEW_MESSAGE
+        }
 
-            if (userMentionsList.contains(eventId)) {
-                rootThreadEventEntity?.threadNotificationState = ThreadNotificationState.NEW_HIGHLIGHTED_MESSAGE
-            }
+        if (userMentionsList.contains(rootThreadEventId)) {
+            rootThreadEventEntity?.threadNotificationState = ThreadNotificationState.NEW_HIGHLIGHTED_MESSAGE
         }
     }
 }
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/helper/ThreadSummaryHelper.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/helper/ThreadSummaryHelper.kt
index 193710f962..908c710df4 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/helper/ThreadSummaryHelper.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/helper/ThreadSummaryHelper.kt
@@ -19,7 +19,6 @@ package org.matrix.android.sdk.internal.database.helper
 import io.realm.Realm
 import io.realm.RealmQuery
 import io.realm.Sort
-import io.realm.kotlin.createObject
 import kotlinx.coroutines.runBlocking
 import org.matrix.android.sdk.api.session.crypto.CryptoService
 import org.matrix.android.sdk.api.session.crypto.MXCryptoError
@@ -38,9 +37,11 @@ import org.matrix.android.sdk.internal.database.model.EventEntity
 import org.matrix.android.sdk.internal.database.model.EventInsertType
 import org.matrix.android.sdk.internal.database.model.RoomEntity
 import org.matrix.android.sdk.internal.database.model.TimelineEventEntity
+import org.matrix.android.sdk.internal.database.model.threads.ThreadListPageEntity
 import org.matrix.android.sdk.internal.database.model.threads.ThreadSummaryEntity
 import org.matrix.android.sdk.internal.database.model.threads.ThreadSummaryEntityFields
 import org.matrix.android.sdk.internal.database.query.copyToRealmOrIgnore
+import org.matrix.android.sdk.internal.database.query.get
 import org.matrix.android.sdk.internal.database.query.getOrCreate
 import org.matrix.android.sdk.internal.database.query.getOrNull
 import org.matrix.android.sdk.internal.database.query.where
@@ -103,32 +104,6 @@ internal fun ThreadSummaryEntity.updateThreadSummaryLatestEvent(
     }
 }
 
-private fun EventEntity.toTimelineEventEntity(roomMemberContentsByUser: HashMap): TimelineEventEntity {
-    val roomId = roomId
-    val eventId = eventId
-    val localId = TimelineEventEntity.nextId(realm)
-    val senderId = sender ?: ""
-
-    val timelineEventEntity = realm.createObject().apply {
-        this.localId = localId
-        this.root = this@toTimelineEventEntity
-        this.eventId = eventId
-        this.roomId = roomId
-        this.annotations = EventAnnotationsSummaryEntity.where(realm, roomId, eventId).findFirst()
-                ?.also { it.cleanUp(sender) }
-        this.ownedByThreadChunk = true  // To skip it from the original event flow
-        val roomMemberContent = roomMemberContentsByUser[senderId]
-        this.senderAvatar = roomMemberContent?.avatarUrl
-        this.senderName = roomMemberContent?.displayName
-        isUniqueDisplayName = if (roomMemberContent?.displayName != null) {
-            computeIsUnique(realm, roomId, false, roomMemberContent, roomMemberContentsByUser)
-        } else {
-            true
-        }
-    }
-    return timelineEventEntity
-}
-
 internal fun ThreadSummaryEntity.Companion.createOrUpdate(
         threadSummaryType: ThreadSummaryUpdateType,
         realm: Realm,
@@ -140,16 +115,16 @@ internal fun ThreadSummaryEntity.Companion.createOrUpdate(
         userId: String,
         cryptoService: CryptoService? = null,
         currentTimeMillis: Long,
-) {
+): ThreadSummaryEntity? {
     when (threadSummaryType) {
         ThreadSummaryUpdateType.REPLACE -> {
-            rootThreadEvent?.eventId ?: return
-            rootThreadEvent.senderId ?: return
+            rootThreadEvent?.eventId ?: return null
+            rootThreadEvent.senderId ?: return null
 
-            val numberOfThreads = rootThreadEvent.unsignedData?.relations?.latestThread?.count ?: return
+            val numberOfThreads = rootThreadEvent.unsignedData?.relations?.latestThread?.count ?: return null
 
             // Something is wrong with the server return
-            if (numberOfThreads <= 0) return
+            if (numberOfThreads <= 0) return null
 
             val threadSummary = ThreadSummaryEntity.getOrCreate(realm, roomId, rootThreadEvent.eventId).also {
                 Timber.i("###THREADS ThreadSummaryHelper REPLACE eventId:${it.rootThreadEventId} ")
@@ -180,12 +155,13 @@ internal fun ThreadSummaryEntity.Companion.createOrUpdate(
             )
 
             roomEntity.addIfNecessary(threadSummary)
+            return threadSummary
         }
         ThreadSummaryUpdateType.ADD -> {
-            val rootThreadEventId = threadEventEntity?.rootThreadEventId ?: return
+            val rootThreadEventId = threadEventEntity?.rootThreadEventId ?: return null
             Timber.i("###THREADS ThreadSummaryHelper ADD for root eventId:$rootThreadEventId")
 
-            val threadSummary = ThreadSummaryEntity.getOrNull(realm, roomId, rootThreadEventId)
+            var threadSummary = ThreadSummaryEntity.getOrNull(realm, roomId, rootThreadEventId)
             if (threadSummary != null) {
                 // ThreadSummary exists so lets add the latest event
                 Timber.i("###THREADS ThreadSummaryHelper ADD root eventId:$rootThreadEventId exists, lets update latest thread event.")
@@ -199,7 +175,7 @@ internal fun ThreadSummaryEntity.Companion.createOrUpdate(
                 Timber.i("###THREADS ThreadSummaryHelper ADD root eventId:$rootThreadEventId do not exists, lets try to create one")
                 threadEventEntity.findRootThreadEvent()?.let { rootThreadEventEntity ->
                     // Root thread event entity exists so lets create a new record
-                    ThreadSummaryEntity.getOrCreate(realm, roomId, rootThreadEventEntity.eventId).let {
+                    threadSummary = ThreadSummaryEntity.getOrCreate(realm, roomId, rootThreadEventEntity.eventId).also {
                         it.updateThreadSummary(
                                 rootThreadEventEntity = rootThreadEventEntity,
                                 numberOfThreads = 1,
@@ -210,7 +186,12 @@ internal fun ThreadSummaryEntity.Companion.createOrUpdate(
                         roomEntity.addIfNecessary(it)
                     }
                 }
+
+                threadSummary?.let {
+                    ThreadListPageEntity.get(realm, roomId)?.threadSummaries?.add(it)
+                }
             }
+            return threadSummary
         }
     }
 }
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/EditAggregatedSummaryEntityMapper.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/EditAggregatedSummaryEntityMapper.kt
new file mode 100644
index 0000000000..8c209f2f2a
--- /dev/null
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/EditAggregatedSummaryEntityMapper.kt
@@ -0,0 +1,45 @@
+/*
+ * Copyright 2020 The Matrix.org Foundation C.I.C.
+ *
+ * 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 org.matrix.android.sdk.internal.database.mapper
+
+import org.matrix.android.sdk.api.session.room.model.EditAggregatedSummary
+import org.matrix.android.sdk.internal.database.model.EditAggregatedSummaryEntity
+import org.matrix.android.sdk.internal.database.model.EditionOfEvent
+
+internal object EditAggregatedSummaryEntityMapper {
+
+    fun map(summary: EditAggregatedSummaryEntity?): EditAggregatedSummary? {
+        summary ?: return null
+        /**
+         * The most recent event is determined by comparing origin_server_ts;
+         * if two or more replacement events have identical origin_server_ts,
+         * the event with the lexicographically largest event_id is treated as more recent.
+         */
+        val latestEdition = summary.editions.sortedWith(compareBy { it.timestamp }.thenBy { it.eventId })
+                .lastOrNull() ?: return null
+        val editEvent = latestEdition.event
+
+        return EditAggregatedSummary(
+                latestEdit = editEvent?.asDomain(),
+                sourceEvents = summary.editions.filter { editionOfEvent -> !editionOfEvent.isLocalEcho }
+                        .map { editionOfEvent -> editionOfEvent.eventId },
+                localEchos = summary.editions.filter { editionOfEvent -> editionOfEvent.isLocalEcho }
+                        .map { editionOfEvent -> editionOfEvent.eventId },
+                lastEditTs = latestEdition.timestamp
+        )
+    }
+}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/EventAnnotationsSummaryMapper.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/EventAnnotationsSummaryMapper.kt
index 6bbeb17fdd..d4bb5791a0 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/EventAnnotationsSummaryMapper.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/EventAnnotationsSummaryMapper.kt
@@ -16,7 +16,6 @@
 
 package org.matrix.android.sdk.internal.database.mapper
 
-import org.matrix.android.sdk.api.session.room.model.EditAggregatedSummary
 import org.matrix.android.sdk.api.session.room.model.EventAnnotationsSummary
 import org.matrix.android.sdk.api.session.room.model.ReactionAggregatedSummary
 import org.matrix.android.sdk.api.session.room.model.ReferencesAggregatedSummary
@@ -35,18 +34,7 @@ internal object EventAnnotationsSummaryMapper {
                             it.sourceLocalEcho.toList()
                     )
                 },
-                editSummary = annotationsSummary.editSummary
-                        ?.let {
-                            val latestEdition = it.editions.maxByOrNull { editionOfEvent -> editionOfEvent.timestamp } ?: return@let null
-                            EditAggregatedSummary(
-                                    latestContent = ContentMapper.map(latestEdition.content),
-                                    sourceEvents = it.editions.filter { editionOfEvent -> !editionOfEvent.isLocalEcho }
-                                            .map { editionOfEvent -> editionOfEvent.eventId },
-                                    localEchos = it.editions.filter { editionOfEvent -> editionOfEvent.isLocalEcho }
-                                            .map { editionOfEvent -> editionOfEvent.eventId },
-                                    lastEditTs = latestEdition.timestamp
-                            )
-                        },
+                editSummary = EditAggregatedSummaryEntityMapper.map(annotationsSummary.editSummary),
                 referencesAggregatedSummary = annotationsSummary.referencesSummaryEntity?.let {
                     ReferencesAggregatedSummary(
                             ContentMapper.map(it.content),
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/HomeServerCapabilitiesMapper.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/HomeServerCapabilitiesMapper.kt
index 3528ca0051..89657ad882 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/HomeServerCapabilitiesMapper.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/HomeServerCapabilitiesMapper.kt
@@ -45,7 +45,8 @@ internal object HomeServerCapabilitiesMapper {
                 canUseThreading = entity.canUseThreading,
                 canControlLogoutDevices = entity.canControlLogoutDevices,
                 canLoginWithQrCode = entity.canLoginWithQrCode,
-                canUseThreadReadReceiptsAndNotifications = entity.canUseThreadReadReceiptsAndNotifications
+                canUseThreadReadReceiptsAndNotifications = entity.canUseThreadReadReceiptsAndNotifications,
+                canRemotelyTogglePushNotificationsOfDevices = entity.canRemotelyTogglePushNotificationsOfDevices,
         )
     }
 
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/ReadReceiptsSummaryMapper.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/ReadReceiptsSummaryMapper.kt
index 2be4510b6f..3b71ae3dea 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/ReadReceiptsSummaryMapper.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/ReadReceiptsSummaryMapper.kt
@@ -50,7 +50,7 @@ internal class ReadReceiptsSummaryMapper @Inject constructor(
                 .mapNotNull {
                     val roomMember = RoomMemberSummaryEntity.where(realm, roomId = it.roomId, userId = it.userId).findFirst()
                             ?: return@mapNotNull null
-                    ReadReceipt(roomMember.asDomain(), it.originServerTs.toLong())
+                    ReadReceipt(roomMember.asDomain(), it.originServerTs.toLong(), it.threadId)
                 }
     }
 }
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo008.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo008.kt
index b61bf7e6fa..f85a0661c2 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo008.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo008.kt
@@ -25,11 +25,11 @@ internal class MigrateSessionTo008(realm: DynamicRealm) : RealmMigrator(realm, 8
 
     override fun doMigrate(realm: DynamicRealm) {
         val editionOfEventSchema = realm.schema.create("EditionOfEvent")
-                .addField(EditionOfEventFields.CONTENT, String::class.java)
+                .addField("content", String::class.java)
                 .addField(EditionOfEventFields.EVENT_ID, String::class.java)
                 .setRequired(EditionOfEventFields.EVENT_ID, true)
-                .addField(EditionOfEventFields.SENDER_ID, String::class.java)
-                .setRequired(EditionOfEventFields.SENDER_ID, true)
+                .addField("senderId", String::class.java)
+                .setRequired("senderId", true)
                 .addField(EditionOfEventFields.TIMESTAMP, Long::class.java)
                 .addField(EditionOfEventFields.IS_LOCAL_ECHO, Boolean::class.java)
 
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo042.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo042.kt
new file mode 100644
index 0000000000..8826d894c1
--- /dev/null
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo042.kt
@@ -0,0 +1,31 @@
+/*
+ * Copyright (c) 2022 The Matrix.org Foundation C.I.C.
+ *
+ * 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 org.matrix.android.sdk.internal.database.migration
+
+import io.realm.DynamicRealm
+import org.matrix.android.sdk.internal.database.model.HomeServerCapabilitiesEntityFields
+import org.matrix.android.sdk.internal.extensions.forceRefreshOfHomeServerCapabilities
+import org.matrix.android.sdk.internal.util.database.RealmMigrator
+
+internal class MigrateSessionTo042(realm: DynamicRealm) : RealmMigrator(realm, 42) {
+
+    override fun doMigrate(realm: DynamicRealm) {
+        realm.schema.get("HomeServerCapabilitiesEntity")
+                ?.addField(HomeServerCapabilitiesEntityFields.CAN_REMOTELY_TOGGLE_PUSH_NOTIFICATIONS_OF_DEVICES, Boolean::class.java)
+                ?.forceRefreshOfHomeServerCapabilities()
+    }
+}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo043.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo043.kt
new file mode 100644
index 0000000000..49e9bac18c
--- /dev/null
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo043.kt
@@ -0,0 +1,42 @@
+/*
+ * Copyright (c) 2022 The Matrix.org Foundation C.I.C.
+ *
+ * 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 org.matrix.android.sdk.internal.database.migration
+
+import io.realm.DynamicRealm
+import org.matrix.android.sdk.internal.database.model.EditionOfEventFields
+import org.matrix.android.sdk.internal.database.model.EventEntityFields
+import org.matrix.android.sdk.internal.util.database.RealmMigrator
+
+internal class MigrateSessionTo043(realm: DynamicRealm) : RealmMigrator(realm, 43) {
+
+    override fun doMigrate(realm: DynamicRealm) {
+        // content(string) & senderId(string) have been removed and replaced by a link to the actual event
+        realm.schema.get("EditionOfEvent")
+                ?.addRealmObjectField(EditionOfEventFields.EVENT.`$`, realm.schema.get("EventEntity")!!)
+                ?.transform { dynamicObject ->
+                    realm.where("EventEntity")
+                            .equalTo(EventEntityFields.EVENT_ID, dynamicObject.getString(EditionOfEventFields.EVENT_ID))
+                            .equalTo(EventEntityFields.SENDER, dynamicObject.getString("senderId"))
+                            .findFirst()
+                            .let {
+                                dynamicObject.setObject(EditionOfEventFields.EVENT.`$`, it)
+                            }
+                }
+                ?.removeField("senderId")
+                ?.removeField("content")
+    }
+}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo044.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo044.kt
new file mode 100644
index 0000000000..2d3efc8338
--- /dev/null
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo044.kt
@@ -0,0 +1,29 @@
+/*
+ * Copyright (c) 2022 The Matrix.org Foundation C.I.C.
+ *
+ * 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 org.matrix.android.sdk.internal.database.migration
+
+import io.realm.DynamicRealm
+import org.matrix.android.sdk.internal.database.model.ReadReceiptEntityFields
+import org.matrix.android.sdk.internal.util.database.RealmMigrator
+
+internal class MigrateSessionTo044(realm: DynamicRealm) : RealmMigrator(realm, 44) {
+
+    override fun doMigrate(realm: DynamicRealm) {
+        realm.schema.get("ReadReceiptEntity")
+                ?.addField(ReadReceiptEntityFields.THREAD_ID, String::class.java)
+    }
+}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo045.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo045.kt
new file mode 100644
index 0000000000..d2b43ded28
--- /dev/null
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo045.kt
@@ -0,0 +1,38 @@
+/*
+ * Copyright (c) 2022 The Matrix.org Foundation C.I.C.
+ *
+ * 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 org.matrix.android.sdk.internal.database.migration
+
+import io.realm.DynamicRealm
+import org.matrix.android.sdk.internal.database.model.SyncFilterParamsEntityFields
+import org.matrix.android.sdk.internal.util.database.RealmMigrator
+
+internal class MigrateSessionTo045(realm: DynamicRealm) : RealmMigrator(realm, 45) {
+
+    override fun doMigrate(realm: DynamicRealm) {
+        realm.schema.create("SyncFilterParamsEntity")
+                .addField(SyncFilterParamsEntityFields.LAZY_LOAD_MEMBERS_FOR_STATE_EVENTS, Boolean::class.java)
+                .setNullable(SyncFilterParamsEntityFields.LAZY_LOAD_MEMBERS_FOR_STATE_EVENTS, true)
+                .addField(SyncFilterParamsEntityFields.LAZY_LOAD_MEMBERS_FOR_MESSAGE_EVENTS, Boolean::class.java)
+                .setNullable(SyncFilterParamsEntityFields.LAZY_LOAD_MEMBERS_FOR_MESSAGE_EVENTS, true)
+                .addField(SyncFilterParamsEntityFields.LIST_OF_SUPPORTED_EVENT_TYPES_HAS_BEEN_SET, Boolean::class.java)
+                .addField(SyncFilterParamsEntityFields.LIST_OF_SUPPORTED_STATE_EVENT_TYPES_HAS_BEEN_SET, Boolean::class.java)
+                .addField(SyncFilterParamsEntityFields.USE_THREAD_NOTIFICATIONS, Boolean::class.java)
+                .setNullable(SyncFilterParamsEntityFields.USE_THREAD_NOTIFICATIONS, true)
+                .addRealmListField(SyncFilterParamsEntityFields.LIST_OF_SUPPORTED_EVENT_TYPES.`$`, String::class.java)
+                .addRealmListField(SyncFilterParamsEntityFields.LIST_OF_SUPPORTED_STATE_EVENT_TYPES.`$`, String::class.java)
+    }
+}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo046.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo046.kt
new file mode 100644
index 0000000000..4b1d2059a4
--- /dev/null
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo046.kt
@@ -0,0 +1,32 @@
+/*
+ * Copyright (c) 2022 The Matrix.org Foundation C.I.C.
+ *
+ * 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 org.matrix.android.sdk.internal.database.migration
+
+import io.realm.DynamicRealm
+import org.matrix.android.sdk.internal.database.model.threads.ThreadListPageEntityFields
+import org.matrix.android.sdk.internal.util.database.RealmMigrator
+
+internal class MigrateSessionTo046(realm: DynamicRealm) : RealmMigrator(realm, 46) {
+
+    override fun doMigrate(realm: DynamicRealm) {
+        realm.schema.create("ThreadListPageEntity")
+                .addField(ThreadListPageEntityFields.ROOM_ID, String::class.java)
+                .addPrimaryKey(ThreadListPageEntityFields.ROOM_ID)
+                .setRequired(ThreadListPageEntityFields.ROOM_ID, true)
+                .addRealmListField(ThreadListPageEntityFields.THREAD_SUMMARIES.`$`, realm.schema.get("ThreadSummaryEntity")!!)
+    }
+}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo047.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo047.kt
new file mode 100644
index 0000000000..5bfaaa760c
--- /dev/null
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/migration/MigrateSessionTo047.kt
@@ -0,0 +1,27 @@
+/*
+ * Copyright (c) 2022 The Matrix.org Foundation C.I.C.
+ *
+ * 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 org.matrix.android.sdk.internal.database.migration
+
+import io.realm.DynamicRealm
+import org.matrix.android.sdk.internal.util.database.RealmMigrator
+
+internal class MigrateSessionTo047(realm: DynamicRealm) : RealmMigrator(realm, 47) {
+
+    override fun doMigrate(realm: DynamicRealm) {
+        realm.schema.remove("SyncFilterParamsEntity")
+    }
+}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/EditAggregatedSummaryEntity.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/EditAggregatedSummaryEntity.kt
index 61acd51dd4..7b7b90f82d 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/EditAggregatedSummaryEntity.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/EditAggregatedSummaryEntity.kt
@@ -32,9 +32,8 @@ internal open class EditAggregatedSummaryEntity(
 
 @RealmClass(embedded = true)
 internal open class EditionOfEvent(
-        var senderId: String = "",
         var eventId: String = "",
-        var content: String? = null,
         var timestamp: Long = 0,
-        var isLocalEcho: Boolean = false
+        var isLocalEcho: Boolean = false,
+        var event: EventEntity? = null,
 ) : RealmObject()
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/EventAnnotationsSummaryEntity.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/EventAnnotationsSummaryEntity.kt
index 645998d0c0..9a201ab4e8 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/EventAnnotationsSummaryEntity.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/EventAnnotationsSummaryEntity.kt
@@ -19,7 +19,6 @@ import io.realm.RealmList
 import io.realm.RealmObject
 import io.realm.annotations.PrimaryKey
 import org.matrix.android.sdk.internal.database.model.livelocation.LiveLocationShareAggregatedSummaryEntity
-import timber.log.Timber
 
 internal open class EventAnnotationsSummaryEntity(
         @PrimaryKey
@@ -32,21 +31,6 @@ internal open class EventAnnotationsSummaryEntity(
         var liveLocationShareAggregatedSummary: LiveLocationShareAggregatedSummaryEntity? = null,
 ) : RealmObject() {
 
-    /**
-     * Cleanup undesired editions, done by users different from the originalEventSender.
-     */
-    fun cleanUp(originalEventSenderId: String?) {
-        originalEventSenderId ?: return
-
-        editSummary?.editions?.filter {
-            it.senderId != originalEventSenderId
-        }
-                ?.forEach {
-                    Timber.w("Deleting an edition from ${it.senderId} of event sent by $originalEventSenderId")
-                    it.deleteFromRealm()
-                }
-    }
-
     companion object
 }
 
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/HomeServerCapabilitiesEntity.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/HomeServerCapabilitiesEntity.kt
index 89f1e50b30..2b60f7723c 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/HomeServerCapabilitiesEntity.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/HomeServerCapabilitiesEntity.kt
@@ -33,6 +33,7 @@ internal open class HomeServerCapabilitiesEntity(
         var canControlLogoutDevices: Boolean = false,
         var canLoginWithQrCode: Boolean = false,
         var canUseThreadReadReceiptsAndNotifications: Boolean = false,
+        var canRemotelyTogglePushNotificationsOfDevices: Boolean = false,
 ) : RealmObject() {
 
     companion object
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/ReadReceiptEntity.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/ReadReceiptEntity.kt
index 9623c95359..cedd5e7424 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/ReadReceiptEntity.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/ReadReceiptEntity.kt
@@ -26,6 +26,7 @@ internal open class ReadReceiptEntity(
         var eventId: String = "",
         var roomId: String = "",
         var userId: String = "",
+        var threadId: String? = null,
         var originServerTs: Double = 0.0
 ) : RealmObject() {
     companion object
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/SessionRealmModule.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/SessionRealmModule.kt
index b222bcb710..0d998e8fe1 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/SessionRealmModule.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/SessionRealmModule.kt
@@ -19,6 +19,7 @@ package org.matrix.android.sdk.internal.database.model
 import io.realm.annotations.RealmModule
 import org.matrix.android.sdk.internal.database.model.livelocation.LiveLocationShareAggregatedSummaryEntity
 import org.matrix.android.sdk.internal.database.model.presence.UserPresenceEntity
+import org.matrix.android.sdk.internal.database.model.threads.ThreadListPageEntity
 import org.matrix.android.sdk.internal.database.model.threads.ThreadSummaryEntity
 
 /**
@@ -70,7 +71,8 @@ import org.matrix.android.sdk.internal.database.model.threads.ThreadSummaryEntit
             SpaceChildSummaryEntity::class,
             SpaceParentSummaryEntity::class,
             UserPresenceEntity::class,
-            ThreadSummaryEntity::class
+            ThreadSummaryEntity::class,
+            ThreadListPageEntity::class
         ]
 )
 internal class SessionRealmModule
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/SyncFilterParamsEntity.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/SyncFilterParamsEntity.kt
new file mode 100644
index 0000000000..e4b62f28e8
--- /dev/null
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/SyncFilterParamsEntity.kt
@@ -0,0 +1,36 @@
+/*
+ * Copyright 2022 The Matrix.org Foundation C.I.C.
+ *
+ * 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 org.matrix.android.sdk.internal.database.model
+
+import io.realm.RealmList
+import io.realm.RealmObject
+
+/**
+ * This entity stores Sync Filter configuration data, provided by the client.
+ */
+internal open class SyncFilterParamsEntity(
+        var lazyLoadMembersForStateEvents: Boolean? = null,
+        var lazyLoadMembersForMessageEvents: Boolean? = null,
+        var useThreadNotifications: Boolean? = null,
+        var listOfSupportedEventTypes: RealmList? = null,
+        var listOfSupportedEventTypesHasBeenSet: Boolean = false,
+        var listOfSupportedStateEventTypes: RealmList? = null,
+        var listOfSupportedStateEventTypesHasBeenSet: Boolean = false,
+) : RealmObject() {
+
+    companion object
+}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/TimelineEventEntity.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/TimelineEventEntity.kt
index c8f22dc2cc..1deca47b70 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/TimelineEventEntity.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/TimelineEventEntity.kt
@@ -20,6 +20,7 @@ import io.realm.RealmObject
 import io.realm.RealmResults
 import io.realm.annotations.Index
 import io.realm.annotations.LinkingObjects
+import org.matrix.android.sdk.api.session.room.read.ReadService
 import org.matrix.android.sdk.internal.extensions.assertIsManaged
 
 internal open class TimelineEventEntity(
@@ -52,3 +53,7 @@ internal fun TimelineEventEntity.deleteOnCascade(canDeleteRoot: Boolean) {
     }
     deleteFromRealm()
 }
+
+internal fun TimelineEventEntity.getThreadId(): String {
+    return root?.rootThreadEventId ?: ReadService.THREAD_ID_MAIN
+}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/threads/ThreadListPageEntity.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/threads/ThreadListPageEntity.kt
new file mode 100644
index 0000000000..1d64c64ddf
--- /dev/null
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/threads/ThreadListPageEntity.kt
@@ -0,0 +1,28 @@
+/*
+ * Copyright 2022 The Matrix.org Foundation C.I.C.
+ *
+ * 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 org.matrix.android.sdk.internal.database.model.threads
+
+import io.realm.RealmList
+import io.realm.RealmObject
+import io.realm.annotations.PrimaryKey
+
+internal open class ThreadListPageEntity(
+        @PrimaryKey var roomId: String = "",
+        var threadSummaries: RealmList = RealmList()
+) : RealmObject() {
+    companion object
+}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/threads/ThreadSummaryEntity.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/threads/ThreadSummaryEntity.kt
index 45f9e3aa20..487be3747a 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/threads/ThreadSummaryEntity.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/model/threads/ThreadSummaryEntity.kt
@@ -40,5 +40,8 @@ internal open class ThreadSummaryEntity(
     @LinkingObjects("threadSummaries")
     val room: RealmResults? = null
 
+    @LinkingObjects("threadSummaries")
+    val page: RealmResults? = null
+
     companion object
 }
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/ReadQueries.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/ReadQueries.kt
index 0b0f01a67d..ebfe23105e 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/ReadQueries.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/ReadQueries.kt
@@ -18,17 +18,20 @@ package org.matrix.android.sdk.internal.database.query
 import io.realm.Realm
 import io.realm.RealmConfiguration
 import org.matrix.android.sdk.api.session.events.model.LocalEcho
+import org.matrix.android.sdk.api.session.room.read.ReadService
 import org.matrix.android.sdk.internal.database.helper.isMoreRecentThan
 import org.matrix.android.sdk.internal.database.model.ChunkEntity
 import org.matrix.android.sdk.internal.database.model.ReadMarkerEntity
 import org.matrix.android.sdk.internal.database.model.ReadReceiptEntity
 import org.matrix.android.sdk.internal.database.model.TimelineEventEntity
+import org.matrix.android.sdk.internal.database.model.getThreadId
 
 internal fun isEventRead(
         realmConfiguration: RealmConfiguration,
         userId: String?,
         roomId: String?,
-        eventId: String?
+        eventId: String?,
+        shouldCheckIfReadInEventsThread: Boolean
 ): Boolean {
     if (userId.isNullOrBlank() || roomId.isNullOrBlank() || eventId.isNullOrBlank()) {
         return false
@@ -45,7 +48,8 @@ internal fun isEventRead(
             eventToCheck.root?.sender == userId -> true
             // If new event exists and the latest event is from ourselves we can infer the event is read
             latestEventIsFromSelf(realm, roomId, userId) -> true
-            eventToCheck.isBeforeLatestReadReceipt(realm, roomId, userId) -> true
+            eventToCheck.isBeforeLatestReadReceipt(realm, roomId, userId, null) -> true
+            (shouldCheckIfReadInEventsThread && eventToCheck.isBeforeLatestReadReceipt(realm, roomId, userId, eventToCheck.getThreadId())) -> true
             else -> false
         }
     }
@@ -54,27 +58,33 @@ internal fun isEventRead(
 private fun latestEventIsFromSelf(realm: Realm, roomId: String, userId: String) = TimelineEventEntity.latestEvent(realm, roomId, true)
         ?.root?.sender == userId
 
-private fun TimelineEventEntity.isBeforeLatestReadReceipt(realm: Realm, roomId: String, userId: String): Boolean {
-    return ReadReceiptEntity.where(realm, roomId, userId).findFirst()?.let { readReceipt ->
+private fun TimelineEventEntity.isBeforeLatestReadReceipt(realm: Realm, roomId: String, userId: String, threadId: String?): Boolean {
+    val isMoreRecent = ReadReceiptEntity.where(realm, roomId, userId, threadId).findFirst()?.let { readReceipt ->
         val readReceiptEvent = TimelineEventEntity.where(realm, roomId, readReceipt.eventId).findFirst()
         readReceiptEvent?.isMoreRecentThan(this)
     } ?: false
+    return isMoreRecent
 }
 
 /**
  * Missing events can be caused by the latest timeline chunk no longer contain an older event or
  * by fast lane eagerly displaying events before the database has finished updating.
  */
-private fun hasReadMissingEvent(realm: Realm, latestChunkEntity: ChunkEntity, roomId: String, userId: String, eventId: String): Boolean {
-    return realm.doesEventExistInChunkHistory(eventId) && realm.hasReadReceiptInLatestChunk(latestChunkEntity, roomId, userId)
+private fun hasReadMissingEvent(realm: Realm,
+                                latestChunkEntity: ChunkEntity,
+                                roomId: String,
+                                userId: String,
+                                eventId: String,
+                                threadId: String? = ReadService.THREAD_ID_MAIN): Boolean {
+    return realm.doesEventExistInChunkHistory(eventId) && realm.hasReadReceiptInLatestChunk(latestChunkEntity, roomId, userId, threadId)
 }
 
 private fun Realm.doesEventExistInChunkHistory(eventId: String): Boolean {
     return ChunkEntity.findIncludingEvent(this, eventId) != null
 }
 
-private fun Realm.hasReadReceiptInLatestChunk(latestChunkEntity: ChunkEntity, roomId: String, userId: String): Boolean {
-    return ReadReceiptEntity.where(this, roomId = roomId, userId = userId).findFirst()?.let {
+private fun Realm.hasReadReceiptInLatestChunk(latestChunkEntity: ChunkEntity, roomId: String, userId: String, threadId: String?): Boolean {
+    return ReadReceiptEntity.where(this, roomId = roomId, userId = userId, threadId = threadId).findFirst()?.let {
         latestChunkEntity.timelineEvents.find(it.eventId)
     } != null
 }
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/ReadReceiptEntityQueries.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/ReadReceiptEntityQueries.kt
index 170814d3f2..0f9f56b938 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/ReadReceiptEntityQueries.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/ReadReceiptEntityQueries.kt
@@ -20,12 +20,20 @@ import io.realm.Realm
 import io.realm.RealmQuery
 import io.realm.kotlin.createObject
 import io.realm.kotlin.where
+import org.matrix.android.sdk.api.session.room.read.ReadService
 import org.matrix.android.sdk.internal.database.model.ReadReceiptEntity
 import org.matrix.android.sdk.internal.database.model.ReadReceiptEntityFields
 
-internal fun ReadReceiptEntity.Companion.where(realm: Realm, roomId: String, userId: String): RealmQuery {
+internal fun ReadReceiptEntity.Companion.where(realm: Realm, roomId: String, userId: String, threadId: String?): RealmQuery {
     return realm.where()
-            .equalTo(ReadReceiptEntityFields.PRIMARY_KEY, buildPrimaryKey(roomId, userId))
+            .equalTo(ReadReceiptEntityFields.PRIMARY_KEY, buildPrimaryKey(roomId, userId, threadId))
+}
+
+internal fun ReadReceiptEntity.Companion.forMainTimelineWhere(realm: Realm, roomId: String, userId: String): RealmQuery {
+    return realm.where()
+            .equalTo(ReadReceiptEntityFields.PRIMARY_KEY, buildPrimaryKey(roomId, userId, ReadService.THREAD_ID_MAIN))
+            .or()
+            .equalTo(ReadReceiptEntityFields.PRIMARY_KEY, buildPrimaryKey(roomId, userId, null))
 }
 
 internal fun ReadReceiptEntity.Companion.whereUserId(realm: Realm, userId: String): RealmQuery {
@@ -38,23 +46,37 @@ internal fun ReadReceiptEntity.Companion.whereRoomId(realm: Realm, roomId: Strin
             .equalTo(ReadReceiptEntityFields.ROOM_ID, roomId)
 }
 
-internal fun ReadReceiptEntity.Companion.createUnmanaged(roomId: String, eventId: String, userId: String, originServerTs: Double): ReadReceiptEntity {
+internal fun ReadReceiptEntity.Companion.createUnmanaged(
+        roomId: String,
+        eventId: String,
+        userId: String,
+        threadId: String?,
+        originServerTs: Double
+): ReadReceiptEntity {
     return ReadReceiptEntity().apply {
-        this.primaryKey = "${roomId}_$userId"
+        this.primaryKey = buildPrimaryKey(roomId, userId, threadId)
         this.eventId = eventId
         this.roomId = roomId
         this.userId = userId
+        this.threadId = threadId
         this.originServerTs = originServerTs
     }
 }
 
-internal fun ReadReceiptEntity.Companion.getOrCreate(realm: Realm, roomId: String, userId: String): ReadReceiptEntity {
-    return ReadReceiptEntity.where(realm, roomId, userId).findFirst()
-            ?: realm.createObject(buildPrimaryKey(roomId, userId))
+internal fun ReadReceiptEntity.Companion.getOrCreate(realm: Realm, roomId: String, userId: String, threadId: String?): ReadReceiptEntity {
+    return ReadReceiptEntity.where(realm, roomId, userId, threadId).findFirst()
+            ?: realm.createObject(buildPrimaryKey(roomId, userId, threadId))
                     .apply {
                         this.roomId = roomId
                         this.userId = userId
+                        this.threadId = threadId
                     }
 }
 
-private fun buildPrimaryKey(roomId: String, userId: String) = "${roomId}_$userId"
+private fun buildPrimaryKey(roomId: String, userId: String, threadId: String?): String {
+    return if (threadId == null) {
+        "${roomId}_${userId}"
+    } else {
+        "${roomId}_${userId}_${threadId}"
+    }
+}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/RoomEntityQueries.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/RoomEntityQueries.kt
index 08bb9e7ff3..0489fe690f 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/RoomEntityQueries.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/RoomEntityQueries.kt
@@ -21,6 +21,7 @@ import io.realm.RealmQuery
 import io.realm.kotlin.where
 import org.matrix.android.sdk.api.session.room.model.Membership
 import org.matrix.android.sdk.internal.database.model.EventEntity
+import org.matrix.android.sdk.internal.database.model.RoomAccountDataEntityFields
 import org.matrix.android.sdk.internal.database.model.RoomEntity
 import org.matrix.android.sdk.internal.database.model.RoomEntityFields
 
@@ -44,3 +45,11 @@ internal fun RoomEntity.Companion.where(realm: Realm, membership: Membership? =
 internal fun RoomEntity.fastContains(eventId: String): Boolean {
     return EventEntity.where(realm, eventId = eventId).findFirst() != null
 }
+
+internal fun RoomEntity.removeAccountData(type: String) {
+    accountData
+            .where()
+            .equalTo(RoomAccountDataEntityFields.TYPE, type)
+            .findFirst()
+            ?.deleteFromRealm()
+}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/ThreadSummaryPageEntityQueries.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/ThreadSummaryPageEntityQueries.kt
new file mode 100644
index 0000000000..9525e55787
--- /dev/null
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/ThreadSummaryPageEntityQueries.kt
@@ -0,0 +1,31 @@
+/*
+ * Copyright 2022 The Matrix.org Foundation C.I.C.
+ *
+ * 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 org.matrix.android.sdk.internal.database.query
+
+import io.realm.Realm
+import io.realm.kotlin.createObject
+import io.realm.kotlin.where
+import org.matrix.android.sdk.internal.database.model.threads.ThreadListPageEntity
+import org.matrix.android.sdk.internal.database.model.threads.ThreadListPageEntityFields
+
+internal fun ThreadListPageEntity.Companion.get(realm: Realm, roomId: String): ThreadListPageEntity? {
+    return realm.where().equalTo(ThreadListPageEntityFields.ROOM_ID, roomId).findFirst()
+}
+
+internal fun ThreadListPageEntity.Companion.getOrCreate(realm: Realm, roomId: String): ThreadListPageEntity {
+    return get(realm, roomId) ?: realm.createObject(roomId)
+}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/TimelineEventEntityQueries.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/TimelineEventEntityQueries.kt
index 1b4b359916..ab90801b7f 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/TimelineEventEntityQueries.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/TimelineEventEntityQueries.kt
@@ -22,6 +22,7 @@ import io.realm.RealmQuery
 import io.realm.RealmResults
 import io.realm.Sort
 import io.realm.kotlin.where
+import org.matrix.android.sdk.api.session.events.model.EventType
 import org.matrix.android.sdk.api.session.room.send.SendState
 import org.matrix.android.sdk.api.session.room.timeline.TimelineEventFilters
 import org.matrix.android.sdk.internal.database.model.ChunkEntity
@@ -94,14 +95,27 @@ internal fun RealmQuery.filterEvents(filters: TimelineEvent
     if (filters.filterTypes && filters.allowedTypes.isNotEmpty()) {
         beginGroup()
         filters.allowedTypes.forEachIndexed { index, filter ->
-            if (filter.stateKey == null) {
-                equalTo(TimelineEventEntityFields.ROOT.TYPE, filter.eventType)
+            if (filter.eventType == EventType.ENCRYPTED) {
+                val otherTypes = filters.allowedTypes.minus(filter).map { it.eventType }
+                if (filter.stateKey == null) {
+                    filterEncryptedTypes(otherTypes)
+                } else {
+                    beginGroup()
+                    filterEncryptedTypes(otherTypes)
+                    and()
+                    equalTo(TimelineEventEntityFields.ROOT.STATE_KEY, filter.stateKey)
+                    endGroup()
+                }
             } else {
-                beginGroup()
-                equalTo(TimelineEventEntityFields.ROOT.TYPE, filter.eventType)
-                and()
-                equalTo(TimelineEventEntityFields.ROOT.STATE_KEY, filter.stateKey)
-                endGroup()
+                if (filter.stateKey == null) {
+                    equalTo(TimelineEventEntityFields.ROOT.TYPE, filter.eventType)
+                } else {
+                    beginGroup()
+                    equalTo(TimelineEventEntityFields.ROOT.TYPE, filter.eventType)
+                    and()
+                    equalTo(TimelineEventEntityFields.ROOT.STATE_KEY, filter.stateKey)
+                    endGroup()
+                }
             }
             if (index != filters.allowedTypes.size - 1) {
                 or()
@@ -115,7 +129,6 @@ internal fun RealmQuery.filterEvents(filters: TimelineEvent
     if (filters.filterEdits) {
         not().like(TimelineEventEntityFields.ROOT.CONTENT, TimelineEventFilter.Content.EDIT)
         not().like(TimelineEventEntityFields.ROOT.CONTENT, TimelineEventFilter.Content.RESPONSE)
-        not().like(TimelineEventEntityFields.ROOT.CONTENT, TimelineEventFilter.Content.REFERENCE)
     }
     if (filters.filterRedacted) {
         not().like(TimelineEventEntityFields.ROOT.UNSIGNED_DATA, TimelineEventFilter.Unsigned.REDACTED)
@@ -124,6 +137,21 @@ internal fun RealmQuery.filterEvents(filters: TimelineEvent
     return this
 }
 
+internal fun RealmQuery.filterEncryptedTypes(allowedTypes: List): RealmQuery {
+    beginGroup()
+    equalTo(TimelineEventEntityFields.ROOT.TYPE, EventType.ENCRYPTED)
+    and()
+    beginGroup()
+    isNull(TimelineEventEntityFields.ROOT.DECRYPTION_RESULT_JSON)
+    allowedTypes.forEach { eventType ->
+        or()
+        like(TimelineEventEntityFields.ROOT.DECRYPTION_RESULT_JSON, TimelineEventFilter.DecryptedContent.type(eventType))
+    }
+    endGroup()
+    endGroup()
+    return this
+}
+
 internal fun RealmQuery.filterTypes(filterTypes: List): RealmQuery {
     return if (filterTypes.isEmpty()) {
         this
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/TimelineEventFilter.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/TimelineEventFilter.kt
index 7a65623b76..b8baeb0b33 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/TimelineEventFilter.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/TimelineEventFilter.kt
@@ -34,6 +34,7 @@ internal object TimelineEventFilter {
      */
     internal object DecryptedContent {
         internal const val URL = """{*"file":*"url":*}"""
+        fun type(type: String) = """{*"type":*"$type"*}"""
     }
 
     /**
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/UserAccountDataEntityQueries.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/UserAccountDataEntityQueries.kt
new file mode 100644
index 0000000000..b28965aeca
--- /dev/null
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/UserAccountDataEntityQueries.kt
@@ -0,0 +1,33 @@
+/*
+ * Copyright 2022 The Matrix.org Foundation C.I.C.
+ *
+ * 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 org.matrix.android.sdk.internal.database.query
+
+import io.realm.Realm
+import io.realm.kotlin.where
+import org.matrix.android.sdk.internal.database.model.UserAccountDataEntity
+import org.matrix.android.sdk.internal.database.model.UserAccountDataEntityFields
+
+/**
+ * Delete an account_data event.
+ */
+internal fun UserAccountDataEntity.Companion.delete(realm: Realm, type: String) {
+    realm
+            .where()
+            .equalTo(UserAccountDataEntityFields.TYPE, type)
+            .findFirst()
+            ?.deleteFromRealm()
+}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/network/NetworkConstants.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/network/NetworkConstants.kt
index 5aec7db66c..4bfda0bf3c 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/network/NetworkConstants.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/network/NetworkConstants.kt
@@ -22,6 +22,7 @@ internal object NetworkConstants {
     const val URI_API_PREFIX_PATH_ = "$URI_API_PREFIX_PATH/"
     const val URI_API_PREFIX_PATH_R0 = "$URI_API_PREFIX_PATH/r0/"
     const val URI_API_PREFIX_PATH_V1 = "$URI_API_PREFIX_PATH/v1/"
+    const val URI_API_PREFIX_PATH_V3 = "$URI_API_PREFIX_PATH/v3/"
     const val URI_API_PREFIX_PATH_UNSTABLE = "$URI_API_PREFIX_PATH/unstable/"
 
     // Media
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/DefaultSession.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/DefaultSession.kt
index 4204f61586..1017b53e05 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/DefaultSession.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/DefaultSession.kt
@@ -59,7 +59,6 @@ import org.matrix.android.sdk.api.session.search.SearchService
 import org.matrix.android.sdk.api.session.securestorage.SharedSecretStorageService
 import org.matrix.android.sdk.api.session.signout.SignOutService
 import org.matrix.android.sdk.api.session.space.SpaceService
-import org.matrix.android.sdk.api.session.sync.FilterService
 import org.matrix.android.sdk.api.session.sync.SyncService
 import org.matrix.android.sdk.api.session.terms.TermsService
 import org.matrix.android.sdk.api.session.thirdparty.ThirdPartyService
@@ -99,7 +98,6 @@ internal class DefaultSession @Inject constructor(
         private val roomService: Lazy,
         private val roomDirectoryService: Lazy,
         private val userService: Lazy,
-        private val filterService: Lazy,
         private val federationService: Lazy,
         private val cacheService: Lazy,
         private val signOutService: Lazy,
@@ -213,7 +211,6 @@ internal class DefaultSession @Inject constructor(
     override fun roomDirectoryService(): RoomDirectoryService = roomDirectoryService.get()
     override fun userService(): UserService = userService.get()
     override fun signOutService(): SignOutService = signOutService.get()
-    override fun filterService(): FilterService = filterService.get()
     override fun pushRuleService(): PushRuleService = pushRuleService.get()
     override fun pushersService(): PushersService = pushersService.get()
     override fun eventService(): EventService = eventService.get()
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/EventInsertLiveProcessor.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/EventInsertLiveProcessor.kt
index a650fa2d64..9741a7bd15 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/EventInsertLiveProcessor.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/EventInsertLiveProcessor.kt
@@ -24,7 +24,7 @@ internal interface EventInsertLiveProcessor {
 
     fun shouldProcess(eventId: String, eventType: String, insertType: EventInsertType): Boolean
 
-    suspend fun process(realm: Realm, event: Event)
+    fun process(realm: Realm, event: Event)
 
     /**
      * Called after transaction.
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/call/CallEventProcessor.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/call/CallEventProcessor.kt
index b15a647421..b6ad7581fe 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/call/CallEventProcessor.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/call/CallEventProcessor.kt
@@ -41,9 +41,8 @@ internal class CallEventProcessor @Inject constructor(private val callSignalingH
             EventType.CALL_INVITE,
             EventType.CALL_HANGUP,
             EventType.ENCRYPTED,
-            EventType.CALL_ASSERTED_IDENTITY,
-            EventType.CALL_ASSERTED_IDENTITY_PREFIX
-    )
+    ) +
+            EventType.CALL_ASSERTED_IDENTITY.values
 
     private val eventsToPostProcess = mutableListOf()
 
@@ -54,7 +53,7 @@ internal class CallEventProcessor @Inject constructor(private val callSignalingH
         return allowedTypes.contains(eventType)
     }
 
-    override suspend fun process(realm: Realm, event: Event) {
+    override fun process(realm: Realm, event: Event) {
         eventsToPostProcess.add(event)
     }
 
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/call/CallSignalingHandler.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/call/CallSignalingHandler.kt
index 48a9dfd3da..d824aaa51a 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/call/CallSignalingHandler.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/call/CallSignalingHandler.kt
@@ -84,8 +84,7 @@ internal class CallSignalingHandler @Inject constructor(
             EventType.CALL_NEGOTIATE -> {
                 handleCallNegotiateEvent(event)
             }
-            EventType.CALL_ASSERTED_IDENTITY,
-            EventType.CALL_ASSERTED_IDENTITY_PREFIX -> {
+            in EventType.CALL_ASSERTED_IDENTITY.values -> {
                 handleCallAssertedIdentityEvent(event)
             }
         }
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/content/UploadContentWorker.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/content/UploadContentWorker.kt
index db1cd1b33b..3dd440737a 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/content/UploadContentWorker.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/content/UploadContentWorker.kt
@@ -408,7 +408,7 @@ internal class UploadContentWorker(val context: Context, params: WorkerParameter
             newAttachmentAttributes: NewAttachmentAttributes
     ) {
         localEchoRepository.updateEcho(eventId) { _, event ->
-            val content: Content? = event.asDomain().content
+            val content: Content? = event.asDomain(castJsonNumbers = true).content
             val messageContent: MessageContent? = content.toModel()
             // Retrieve potential additional content from the original event
             val additionalContent = content.orEmpty() - messageContent?.toContent().orEmpty().keys
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/filter/DefaultFilterRepository.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/filter/DefaultFilterRepository.kt
index 1d1bb0e715..f70b4d1799 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/filter/DefaultFilterRepository.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/filter/DefaultFilterRepository.kt
@@ -17,72 +17,49 @@
 package org.matrix.android.sdk.internal.session.filter
 
 import com.zhuinden.monarchy.Monarchy
-import io.realm.Realm
-import io.realm.kotlin.where
 import org.matrix.android.sdk.internal.database.model.FilterEntity
-import org.matrix.android.sdk.internal.database.model.FilterEntityFields
 import org.matrix.android.sdk.internal.database.query.get
 import org.matrix.android.sdk.internal.database.query.getOrCreate
 import org.matrix.android.sdk.internal.di.SessionDatabase
 import org.matrix.android.sdk.internal.util.awaitTransaction
 import javax.inject.Inject
 
-internal class DefaultFilterRepository @Inject constructor(@SessionDatabase private val monarchy: Monarchy) : FilterRepository {
+internal class DefaultFilterRepository @Inject constructor(
+        @SessionDatabase private val monarchy: Monarchy,
+) : FilterRepository {
 
-    override suspend fun storeFilter(filter: Filter, roomEventFilter: RoomEventFilter): Boolean {
-        return Realm.getInstance(monarchy.realmConfiguration).use { realm ->
-            val filterEntity = FilterEntity.get(realm)
-            // Filter has changed, or no filter Id yet
-            filterEntity == null ||
-                    filterEntity.filterBodyJson != filter.toJSONString() ||
-                    filterEntity.filterId.isBlank()
-        }.also { hasChanged ->
-            if (hasChanged) {
-                // Filter is new or has changed, store it and reset the filter Id.
-                // This has to be done outside of the Realm.use(), because awaitTransaction change the current thread
-                monarchy.awaitTransaction { realm ->
-                    // We manage only one filter for now
-                    val filterJson = filter.toJSONString()
-                    val roomEventFilterJson = roomEventFilter.toJSONString()
+    override suspend fun storeSyncFilter(filter: Filter, filterId: String, roomEventFilter: RoomEventFilter) {
+        monarchy.awaitTransaction { realm ->
+            // We manage only one filter for now
+            val filterJson = filter.toJSONString()
+            val roomEventFilterJson = roomEventFilter.toJSONString()
 
-                    val filterEntity = FilterEntity.getOrCreate(realm)
+            val filterEntity = FilterEntity.getOrCreate(realm)
 
-                    filterEntity.filterBodyJson = filterJson
-                    filterEntity.roomEventFilterJson = roomEventFilterJson
-                    // Reset filterId
-                    filterEntity.filterId = ""
-                }
-            }
+            filterEntity.filterBodyJson = filterJson
+            filterEntity.roomEventFilterJson = roomEventFilterJson
+            filterEntity.filterId = filterId
         }
     }
 
-    override suspend fun storeFilterId(filter: Filter, filterId: String) {
-        monarchy.awaitTransaction {
-            // We manage only one filter for now
-            val filterJson = filter.toJSONString()
-
-            // Update the filter id, only if the filter body matches
-            it.where()
-                    .equalTo(FilterEntityFields.FILTER_BODY_JSON, filterJson)
-                    ?.findFirst()
-                    ?.filterId = filterId
+    override suspend fun getStoredSyncFilterBody(): String {
+        return monarchy.awaitTransaction {
+            FilterEntity.getOrCreate(it).filterBodyJson
         }
     }
 
-    override suspend fun getFilter(): String {
+    override suspend fun getStoredSyncFilterId(): String? {
         return monarchy.awaitTransaction {
-            val filter = FilterEntity.getOrCreate(it)
-            if (filter.filterId.isBlank()) {
-                // Use the Json format
-                filter.filterBodyJson
+            val id = FilterEntity.get(it)?.filterId
+            if (id.isNullOrBlank()) {
+                null
             } else {
-                // Use FilterId
-                filter.filterId
+                id
             }
         }
     }
 
-    override suspend fun getRoomFilter(): String {
+    override suspend fun getRoomFilterBody(): String {
         return monarchy.awaitTransaction {
             FilterEntity.getOrCreate(it).roomEventFilterJson
         }
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/filter/DefaultFilterService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/filter/DefaultFilterService.kt
deleted file mode 100644
index 2e68d02d8c..0000000000
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/filter/DefaultFilterService.kt
+++ /dev/null
@@ -1,35 +0,0 @@
-/*
- * Copyright 2020 The Matrix.org Foundation C.I.C.
- *
- * 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 org.matrix.android.sdk.internal.session.filter
-
-import org.matrix.android.sdk.api.session.sync.FilterService
-import org.matrix.android.sdk.internal.task.TaskExecutor
-import org.matrix.android.sdk.internal.task.configureWith
-import javax.inject.Inject
-
-internal class DefaultFilterService @Inject constructor(
-        private val saveFilterTask: SaveFilterTask,
-        private val taskExecutor: TaskExecutor
-) : FilterService {
-
-    // TODO Pass a list of support events instead
-    override fun setFilter(filterPreset: FilterService.FilterPreset) {
-        saveFilterTask
-                .configureWith(SaveFilterTask.Params(filterPreset))
-                .executeBy(taskExecutor)
-    }
-}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/filter/FilterFactory.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/filter/FilterFactory.kt
index e0919c52e3..1bd2e59e59 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/filter/FilterFactory.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/filter/FilterFactory.kt
@@ -45,46 +45,7 @@ internal object FilterFactory {
         return FilterUtil.enableLazyLoading(Filter(), true)
     }
 
-    fun createElementFilter(): Filter {
-        return Filter(
-                room = RoomFilter(
-                        timeline = createElementTimelineFilter(),
-                        state = createElementStateFilter()
-                )
-        )
-    }
-
     fun createDefaultRoomFilter(): RoomEventFilter {
         return RoomEventFilter(lazyLoadMembers = true)
     }
-
-    fun createElementRoomFilter(): RoomEventFilter {
-        return RoomEventFilter(
-                lazyLoadMembers = true,
-                // TODO Enable this for optimization
-                // types = (listOfSupportedEventTypes + listOfSupportedStateEventTypes).toMutableList()
-        )
-    }
-
-    private fun createElementTimelineFilter(): RoomEventFilter? {
-//        we need to check if homeserver supports thread notifications before setting this param
-//        return RoomEventFilter(enableUnreadThreadNotifications = true)
-        return null
-    }
-
-    private fun createElementStateFilter(): RoomEventFilter {
-        return RoomEventFilter(lazyLoadMembers = true)
-    }
-
-    // Get only managed types by Element
-    private val listOfSupportedEventTypes = listOf(
-            // TODO Complete the list
-            EventType.MESSAGE
-    )
-
-    // Get only managed types by Element
-    private val listOfSupportedStateEventTypes = listOf(
-            // TODO Complete the list
-            EventType.STATE_ROOM_MEMBER
-    )
 }
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/filter/FilterModule.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/filter/FilterModule.kt
index 8531bed1ff..5ae2c2a47d 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/filter/FilterModule.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/filter/FilterModule.kt
@@ -19,7 +19,6 @@ package org.matrix.android.sdk.internal.session.filter
 import dagger.Binds
 import dagger.Module
 import dagger.Provides
-import org.matrix.android.sdk.api.session.sync.FilterService
 import org.matrix.android.sdk.internal.session.SessionScope
 import retrofit2.Retrofit
 
@@ -40,8 +39,8 @@ internal abstract class FilterModule {
     abstract fun bindFilterRepository(repository: DefaultFilterRepository): FilterRepository
 
     @Binds
-    abstract fun bindFilterService(service: DefaultFilterService): FilterService
+    abstract fun bindSaveFilterTask(task: DefaultSaveFilterTask): SaveFilterTask
 
     @Binds
-    abstract fun bindSaveFilterTask(task: DefaultSaveFilterTask): SaveFilterTask
+    abstract fun bindGetCurrentFilterTask(task: DefaultGetCurrentFilterTask): GetCurrentFilterTask
 }
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/filter/FilterRepository.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/filter/FilterRepository.kt
index f40231c8cf..d0ec4b98bb 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/filter/FilterRepository.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/filter/FilterRepository.kt
@@ -16,25 +16,30 @@
 
 package org.matrix.android.sdk.internal.session.filter
 
+/**
+ * Repository for request filters.
+ */
 internal interface FilterRepository {
 
     /**
-     * Return true if the filterBody has changed, or need to be sent to the server.
+     * Stores sync filter and room filter.
+     * Note: It looks like we could use [Filter.room.timeline] instead of a separate [RoomEventFilter], but it's not clear if it's safe, so research is needed
+     * @return true if the filterBody has changed, or need to be sent to the server.
      */
-    suspend fun storeFilter(filter: Filter, roomEventFilter: RoomEventFilter): Boolean
+    suspend fun storeSyncFilter(filter: Filter, filterId: String, roomEventFilter: RoomEventFilter)
 
     /**
-     * Set the filterId of this filter.
+     * Returns stored sync filter's JSON body if it exists.
      */
-    suspend fun storeFilterId(filter: Filter, filterId: String)
+    suspend fun getStoredSyncFilterBody(): String?
 
     /**
-     * Return filter json or filter id.
+     * Returns stored sync filter's ID if it exists.
      */
-    suspend fun getFilter(): String
+    suspend fun getStoredSyncFilterId(): String?
 
     /**
      * Return the room filter.
      */
-    suspend fun getRoomFilter(): String
+    suspend fun getRoomFilterBody(): String
 }
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/filter/GetCurrentFilterTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/filter/GetCurrentFilterTask.kt
new file mode 100644
index 0000000000..5c7027f8b3
--- /dev/null
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/filter/GetCurrentFilterTask.kt
@@ -0,0 +1,57 @@
+/*
+ * Copyright 2022 The Matrix.org Foundation C.I.C.
+ *
+ * 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 org.matrix.android.sdk.internal.session.filter
+
+import org.matrix.android.sdk.api.MatrixConfiguration
+import org.matrix.android.sdk.api.session.homeserver.HomeServerCapabilities
+import org.matrix.android.sdk.internal.session.homeserver.HomeServerCapabilitiesDataSource
+import org.matrix.android.sdk.internal.sync.filter.SyncFilterBuilder
+import org.matrix.android.sdk.internal.task.Task
+import javax.inject.Inject
+
+internal interface GetCurrentFilterTask : Task
+
+internal class DefaultGetCurrentFilterTask @Inject constructor(
+        private val filterRepository: FilterRepository,
+        private val homeServerCapabilitiesDataSource: HomeServerCapabilitiesDataSource,
+        private val saveFilterTask: SaveFilterTask,
+        private val matrixConfiguration: MatrixConfiguration
+) : GetCurrentFilterTask {
+
+    override suspend fun execute(params: Unit): String {
+        val storedFilterId = filterRepository.getStoredSyncFilterId()
+        val storedFilterBody = filterRepository.getStoredSyncFilterBody()
+        val homeServerCapabilities = homeServerCapabilitiesDataSource.getHomeServerCapabilities() ?: HomeServerCapabilities()
+        val currentFilter = SyncFilterBuilder()
+                .with(matrixConfiguration.syncConfig.syncFilterParams)
+                .build(homeServerCapabilities)
+
+        val currentFilterBody = currentFilter.toJSONString()
+
+        return when (storedFilterBody) {
+            currentFilterBody -> storedFilterId ?: storedFilterBody
+            else -> saveFilter(currentFilter) ?: currentFilterBody
+        }
+    }
+
+    private suspend fun saveFilter(filter: Filter) = saveFilterTask
+            .execute(
+                    SaveFilterTask.Params(
+                            filter = filter
+                    )
+            )
+}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/filter/SaveFilterTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/filter/SaveFilterTask.kt
index 63afa1bbbc..0223cd3ee7 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/filter/SaveFilterTask.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/filter/SaveFilterTask.kt
@@ -16,7 +16,7 @@
 
 package org.matrix.android.sdk.internal.session.filter
 
-import org.matrix.android.sdk.api.session.sync.FilterService
+import org.matrix.android.sdk.api.extensions.tryOrNull
 import org.matrix.android.sdk.internal.di.UserId
 import org.matrix.android.sdk.internal.network.GlobalErrorReceiver
 import org.matrix.android.sdk.internal.network.executeRequest
@@ -25,11 +25,12 @@ import javax.inject.Inject
 
 /**
  * Save a filter, in db and if any changes, upload to the server.
+ * Return the filterId if uploading to the server is successful, else return null.
  */
-internal interface SaveFilterTask : Task {
+internal interface SaveFilterTask : Task {
 
     data class Params(
-            val filterPreset: FilterService.FilterPreset
+            val filter: Filter
     )
 }
 
@@ -37,33 +38,23 @@ internal class DefaultSaveFilterTask @Inject constructor(
         @UserId private val userId: String,
         private val filterAPI: FilterApi,
         private val filterRepository: FilterRepository,
-        private val globalErrorReceiver: GlobalErrorReceiver
+        private val globalErrorReceiver: GlobalErrorReceiver,
 ) : SaveFilterTask {
 
-    override suspend fun execute(params: SaveFilterTask.Params) {
-        val filterBody = when (params.filterPreset) {
-            FilterService.FilterPreset.ElementFilter -> {
-                FilterFactory.createElementFilter()
-            }
-            FilterService.FilterPreset.NoFilter -> {
-                FilterFactory.createDefaultFilter()
-            }
-        }
-        val roomFilter = when (params.filterPreset) {
-            FilterService.FilterPreset.ElementFilter -> {
-                FilterFactory.createElementRoomFilter()
-            }
-            FilterService.FilterPreset.NoFilter -> {
-                FilterFactory.createDefaultRoomFilter()
+    override suspend fun execute(params: SaveFilterTask.Params): String? {
+        val filter = params.filter
+        val filterResponse = tryOrNull {
+            executeRequest(globalErrorReceiver) {
+                filterAPI.uploadFilter(userId, filter)
             }
         }
-        val updated = filterRepository.storeFilter(filterBody, roomFilter)
-        if (updated) {
-            val filterResponse = executeRequest(globalErrorReceiver) {
-                // TODO auto retry
-                filterAPI.uploadFilter(userId, filterBody)
-            }
-            filterRepository.storeFilterId(filterBody, filterResponse.filterId)
-        }
+
+        val filterId = filterResponse?.filterId
+        filterRepository.storeSyncFilter(
+                filter = filter,
+                filterId = filterId.orEmpty(),
+                roomEventFilter = FilterFactory.createDefaultRoomFilter()
+        )
+        return filterId
     }
 }
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/homeserver/DefaultHomeServerCapabilitiesService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/homeserver/DefaultHomeServerCapabilitiesService.kt
index 4c755b54b5..eb9e862de2 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/homeserver/DefaultHomeServerCapabilitiesService.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/homeserver/DefaultHomeServerCapabilitiesService.kt
@@ -16,8 +16,10 @@
 
 package org.matrix.android.sdk.internal.session.homeserver
 
+import androidx.lifecycle.LiveData
 import org.matrix.android.sdk.api.session.homeserver.HomeServerCapabilities
 import org.matrix.android.sdk.api.session.homeserver.HomeServerCapabilitiesService
+import org.matrix.android.sdk.api.util.Optional
 import javax.inject.Inject
 
 internal class DefaultHomeServerCapabilitiesService @Inject constructor(
@@ -33,4 +35,8 @@ internal class DefaultHomeServerCapabilitiesService @Inject constructor(
         return homeServerCapabilitiesDataSource.getHomeServerCapabilities()
                 ?: HomeServerCapabilities()
     }
+
+    override fun getHomeServerCapabilitiesLive(): LiveData> {
+        return homeServerCapabilitiesDataSource.getHomeServerCapabilitiesLive()
+    }
 }
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/homeserver/GetHomeServerCapabilitiesTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/homeserver/GetHomeServerCapabilitiesTask.kt
index a5953d870c..11e86a5c51 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/homeserver/GetHomeServerCapabilitiesTask.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/homeserver/GetHomeServerCapabilitiesTask.kt
@@ -25,6 +25,7 @@ import org.matrix.android.sdk.api.session.homeserver.HomeServerCapabilities
 import org.matrix.android.sdk.internal.auth.version.Versions
 import org.matrix.android.sdk.internal.auth.version.doesServerSupportLogoutDevices
 import org.matrix.android.sdk.internal.auth.version.doesServerSupportQrCodeLogin
+import org.matrix.android.sdk.internal.auth.version.doesServerSupportRemoteToggleOfPushNotifications
 import org.matrix.android.sdk.internal.auth.version.doesServerSupportThreadUnreadNotifications
 import org.matrix.android.sdk.internal.auth.version.doesServerSupportThreads
 import org.matrix.android.sdk.internal.auth.version.isLoginAndRegistrationSupportedBySdk
@@ -141,13 +142,18 @@ internal class DefaultGetHomeServerCapabilitiesTask @Inject constructor(
             }
 
             if (getVersionResult != null) {
-                homeServerCapabilitiesEntity.lastVersionIdentityServerSupported = getVersionResult.isLoginAndRegistrationSupportedBySdk()
-                homeServerCapabilitiesEntity.canControlLogoutDevices = getVersionResult.doesServerSupportLogoutDevices()
+                homeServerCapabilitiesEntity.lastVersionIdentityServerSupported =
+                        getVersionResult.isLoginAndRegistrationSupportedBySdk()
+                homeServerCapabilitiesEntity.canControlLogoutDevices =
+                        getVersionResult.doesServerSupportLogoutDevices()
                 homeServerCapabilitiesEntity.canUseThreading = /* capabilities?.threads?.enabled.orFalse() || */
                         getVersionResult.doesServerSupportThreads()
                 homeServerCapabilitiesEntity.canUseThreadReadReceiptsAndNotifications =
                         getVersionResult.doesServerSupportThreadUnreadNotifications()
-                homeServerCapabilitiesEntity.canLoginWithQrCode = getVersionResult.doesServerSupportQrCodeLogin()
+                homeServerCapabilitiesEntity.canLoginWithQrCode =
+                        getVersionResult.doesServerSupportQrCodeLogin()
+                homeServerCapabilitiesEntity.canRemotelyTogglePushNotificationsOfDevices =
+                        getVersionResult.doesServerSupportRemoteToggleOfPushNotifications()
             }
 
             if (getWellknownResult != null && getWellknownResult is WellknownResult.Prompt) {
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/homeserver/HomeServerCapabilitiesDataSource.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/homeserver/HomeServerCapabilitiesDataSource.kt
index 6c913fa41e..beb1e67e40 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/homeserver/HomeServerCapabilitiesDataSource.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/homeserver/HomeServerCapabilitiesDataSource.kt
@@ -16,9 +16,14 @@
 
 package org.matrix.android.sdk.internal.session.homeserver
 
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.Transformations
 import com.zhuinden.monarchy.Monarchy
 import io.realm.Realm
+import io.realm.kotlin.where
 import org.matrix.android.sdk.api.session.homeserver.HomeServerCapabilities
+import org.matrix.android.sdk.api.util.Optional
+import org.matrix.android.sdk.api.util.toOptional
 import org.matrix.android.sdk.internal.database.mapper.HomeServerCapabilitiesMapper
 import org.matrix.android.sdk.internal.database.model.HomeServerCapabilitiesEntity
 import org.matrix.android.sdk.internal.database.query.get
@@ -26,7 +31,7 @@ import org.matrix.android.sdk.internal.di.SessionDatabase
 import javax.inject.Inject
 
 internal class HomeServerCapabilitiesDataSource @Inject constructor(
-        @SessionDatabase private val monarchy: Monarchy
+        @SessionDatabase private val monarchy: Monarchy,
 ) {
     fun getHomeServerCapabilities(): HomeServerCapabilities? {
         return Realm.getInstance(monarchy.realmConfiguration).use { realm ->
@@ -35,4 +40,14 @@ internal class HomeServerCapabilitiesDataSource @Inject constructor(
             }
         }
     }
+
+    fun getHomeServerCapabilitiesLive(): LiveData> {
+        val liveData = monarchy.findAllMappedWithChanges(
+                { realm: Realm -> realm.where() },
+                { HomeServerCapabilitiesMapper.map(it) }
+        )
+        return Transformations.map(liveData) {
+            it.firstOrNull().toOptional()
+        }
+    }
 }
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/pushrules/ProcessEventForPushTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/pushrules/ProcessEventForPushTask.kt
index 09d7d50ecb..9fe93d8262 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/pushrules/ProcessEventForPushTask.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/pushrules/ProcessEventForPushTask.kt
@@ -56,8 +56,8 @@ internal class DefaultProcessEventForPushTask @Inject constructor(
 
         val allEvents = (newJoinEvents + inviteEvents).filter { event ->
             when (event.type) {
-                in EventType.POLL_START,
-                in EventType.STATE_ROOM_BEACON_INFO,
+                in EventType.POLL_START.values,
+                in EventType.STATE_ROOM_BEACON_INFO.values,
                 EventType.MESSAGE,
                 EventType.REDACTION,
                 EventType.ENCRYPTED,
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/EventEditValidator.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/EventEditValidator.kt
new file mode 100644
index 0000000000..41d0c3f6ab
--- /dev/null
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/EventEditValidator.kt
@@ -0,0 +1,126 @@
+/*
+ * Copyright 2022 The Matrix.org Foundation C.I.C.
+ *
+ * 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 org.matrix.android.sdk.internal.session.room
+
+import org.matrix.android.sdk.api.session.events.model.Event
+import org.matrix.android.sdk.api.session.events.model.LocalEcho
+import org.matrix.android.sdk.api.session.events.model.RelationType
+import org.matrix.android.sdk.api.session.events.model.getRelationContent
+import org.matrix.android.sdk.api.session.events.model.toModel
+import org.matrix.android.sdk.api.session.events.model.toValidDecryptedEvent
+import org.matrix.android.sdk.api.session.room.model.message.MessageContent
+import org.matrix.android.sdk.internal.crypto.store.IMXCryptoStore
+import timber.log.Timber
+import javax.inject.Inject
+
+internal class EventEditValidator @Inject constructor(val cryptoStore: IMXCryptoStore) {
+
+    sealed class EditValidity {
+        object Valid : EditValidity()
+        data class Invalid(val reason: String) : EditValidity()
+        object Unknown : EditValidity()
+    }
+
+    /**
+     * There are a number of requirements on replacement events, which must be satisfied for the replacement
+     * to be considered valid:
+     * As with all event relationships, the original event and replacement event must have the same room_id
+     * (i.e. you cannot send an event in one room and then an edited version in a different room).
+     * The original event and replacement event must have the same sender (i.e. you cannot edit someone else’s messages).
+     * The replacement and original events must have the same type (i.e. you cannot change the original event’s type).
+     * The replacement and original events must not have a state_key property (i.e. you cannot edit state events at all).
+     * The original event must not, itself, have a rel_type of m.replace
+     * (i.e. you cannot edit an edit — though you can send multiple edits for a single original event).
+     * The replacement event (once decrypted, if appropriate) must have an m.new_content property.
+     *
+     * If the original event was encrypted, the replacement should be too.
+     */
+    fun validateEdit(originalEvent: Event?, replaceEvent: Event): EditValidity {
+        Timber.v("###REPLACE valide event $originalEvent replaced $replaceEvent")
+        // we might not know the original event at that time. In this case we can't perform the validation
+        // Edits should be revalidated when the original event is received
+        if (originalEvent == null) {
+            return EditValidity.Unknown
+        }
+
+        if (LocalEcho.isLocalEchoId(replaceEvent.eventId.orEmpty())) {
+            // Don't validate local echo
+            return EditValidity.Unknown
+        }
+
+        if (originalEvent.roomId != replaceEvent.roomId) {
+            return EditValidity.Invalid("original event and replacement event must have the same room_id")
+        }
+        if (originalEvent.isStateEvent() || replaceEvent.isStateEvent()) {
+            return EditValidity.Invalid("replacement and original events must not have a state_key property")
+        }
+        // check it's from same sender
+
+        if (originalEvent.isEncrypted()) {
+            if (!replaceEvent.isEncrypted()) return EditValidity.Invalid("If the original event was encrypted, the replacement should be too")
+            val originalDecrypted = originalEvent.toValidDecryptedEvent()
+                    ?: return EditValidity.Unknown // UTD can't decide
+            val replaceDecrypted = replaceEvent.toValidDecryptedEvent()
+                    ?: return EditValidity.Unknown // UTD can't decide
+
+            val originalCryptoSenderId = cryptoStore.deviceWithIdentityKey(originalDecrypted.cryptoSenderKey)?.userId
+            val editCryptoSenderId = cryptoStore.deviceWithIdentityKey(replaceDecrypted.cryptoSenderKey)?.userId
+
+            if (originalDecrypted.getRelationContent()?.type == RelationType.REPLACE) {
+                return EditValidity.Invalid("The original event must not, itself, have a rel_type of m.replace ")
+            }
+
+            if (originalCryptoSenderId == null || editCryptoSenderId == null) {
+                // mm what can we do? we don't know if it's cryptographically from same user?
+                // let valid and UI should display send by deleted device warning?
+                val bestEffortOriginal = originalCryptoSenderId ?: originalEvent.senderId
+                val bestEffortEdit = editCryptoSenderId ?: replaceEvent.senderId
+                if (bestEffortOriginal != bestEffortEdit) {
+                    return EditValidity.Invalid("original event and replacement event must have the same sender")
+                }
+            } else {
+                if (originalCryptoSenderId != editCryptoSenderId) {
+                    return EditValidity.Invalid("Crypto: original event and replacement event must have the same sender")
+                }
+            }
+
+            if (originalDecrypted.type != replaceDecrypted.type) {
+                return EditValidity.Invalid("replacement and original events must have the same type")
+            }
+            if (replaceDecrypted.clearContent.toModel()?.newContent == null) {
+                return EditValidity.Invalid("replacement event must have an m.new_content property")
+            }
+        } else {
+            if (originalEvent.getRelationContent()?.type == RelationType.REPLACE) {
+                return EditValidity.Invalid("The original event must not, itself, have a rel_type of m.replace ")
+            }
+
+            // check the sender
+            if (originalEvent.senderId != replaceEvent.senderId) {
+                return EditValidity.Invalid("original event and replacement event must have the same sender")
+            }
+            if (originalEvent.type != replaceEvent.type) {
+                return EditValidity.Invalid("replacement and original events must have the same type")
+            }
+            if (replaceEvent.content.toModel()?.newContent == null) {
+                return EditValidity.Invalid("replacement event must have an m.new_content property")
+            }
+        }
+
+        return EditValidity.Valid
+    }
+}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/EventRelationsAggregationProcessor.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/EventRelationsAggregationProcessor.kt
index 24d4975eb9..be73309837 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/EventRelationsAggregationProcessor.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/EventRelationsAggregationProcessor.kt
@@ -42,6 +42,7 @@ import org.matrix.android.sdk.internal.crypto.verification.toState
 import org.matrix.android.sdk.internal.database.helper.findRootThreadEvent
 import org.matrix.android.sdk.internal.database.mapper.ContentMapper
 import org.matrix.android.sdk.internal.database.mapper.EventMapper
+import org.matrix.android.sdk.internal.database.mapper.asDomain
 import org.matrix.android.sdk.internal.database.model.EditAggregatedSummaryEntity
 import org.matrix.android.sdk.internal.database.model.EditionOfEvent
 import org.matrix.android.sdk.internal.database.model.EventAnnotationsSummaryEntity
@@ -72,6 +73,7 @@ internal class EventRelationsAggregationProcessor @Inject constructor(
         private val sessionManager: SessionManager,
         private val liveLocationAggregationProcessor: LiveLocationAggregationProcessor,
         private val pollAggregationProcessor: PollAggregationProcessor,
+        private val editValidator: EventEditValidator,
         private val clock: Clock,
 ) : EventInsertLiveProcessor {
 
@@ -79,22 +81,28 @@ internal class EventRelationsAggregationProcessor @Inject constructor(
             EventType.MESSAGE,
             EventType.REDACTION,
             EventType.REACTION,
+            // The aggregator handles verification events but just to render tiles in the timeline
+            // It's not participating in verification itself, just timeline display
             EventType.KEY_VERIFICATION_DONE,
             EventType.KEY_VERIFICATION_CANCEL,
             EventType.KEY_VERIFICATION_ACCEPT,
             EventType.KEY_VERIFICATION_START,
             EventType.KEY_VERIFICATION_MAC,
-            // TODO Add ?
-            // EventType.KEY_VERIFICATION_READY,
+            EventType.KEY_VERIFICATION_READY,
             EventType.KEY_VERIFICATION_KEY,
             EventType.ENCRYPTED
-    ) + EventType.POLL_START + EventType.POLL_RESPONSE + EventType.POLL_END + EventType.STATE_ROOM_BEACON_INFO + EventType.BEACON_LOCATION_DATA
+    ) +
+            EventType.POLL_START.values +
+            EventType.POLL_RESPONSE.values +
+            EventType.POLL_END.values +
+            EventType.STATE_ROOM_BEACON_INFO.values +
+            EventType.BEACON_LOCATION_DATA.values
 
     override fun shouldProcess(eventId: String, eventType: String, insertType: EventInsertType): Boolean {
         return allowedTypes.contains(eventType)
     }
 
-    override suspend fun process(realm: Realm, event: Event) {
+    override fun process(realm: Realm, event: Event) {
         try { // Temporary catch, should be removed
             val roomId = event.roomId
             if (roomId == null) {
@@ -102,7 +110,31 @@ internal class EventRelationsAggregationProcessor @Inject constructor(
                 return
             }
             val isLocalEcho = LocalEcho.isLocalEchoId(event.eventId ?: "")
-            when (event.type) {
+
+            // It might be a late decryption of the original event or a event received when back paginating?
+            // let's check if there is already a summary for it and do some cleaning
+            if (!isLocalEcho) {
+                EventAnnotationsSummaryEntity.where(realm, roomId, event.eventId.orEmpty())
+                        .findFirst()
+                        ?.editSummary
+                        ?.editions
+                        ?.forEach { editionOfEvent ->
+                            EventEntity.where(realm, editionOfEvent.eventId).findFirst()?.asDomain()?.let { editEvent ->
+                                when (editValidator.validateEdit(event, editEvent)) {
+                                    is EventEditValidator.EditValidity.Invalid -> {
+                                        // delete it, it was invalid
+                                        Timber.v("## Replace: Removing a previously accepted edit for event ${event.eventId}")
+                                        editionOfEvent.deleteFromRealm()
+                                    }
+                                    else -> {
+                                        // nop
+                                    }
+                                }
+                            }
+                        }
+            }
+
+            when (event.getClearType()) {
                 EventType.REACTION -> {
                     // we got a reaction!!
                     Timber.v("###REACTION in room $roomId , reaction eventID ${event.eventId}")
@@ -113,21 +145,17 @@ internal class EventRelationsAggregationProcessor @Inject constructor(
                         Timber.v("###REACTION Aggregation in room $roomId for event ${event.eventId}")
                         handleInitialAggregatedRelations(realm, event, roomId, event.unsignedData.relations.annotations)
 
-                        EventAnnotationsSummaryEntity.where(realm, roomId, event.eventId ?: "").findFirst()
-                                ?.let {
-                                    TimelineEventEntity.where(realm, roomId = roomId, eventId = event.eventId ?: "").findAll()
-                                            ?.forEach { tet -> tet.annotations = it }
-                                }
+                        // XXX do something for aggregated edits?
+                        // it's a bit strange as it would require to do a server query to get the edition?
                     }
 
-                    val content: MessageContent? = event.content.toModel()
-                    if (content?.relatesTo?.type == RelationType.REPLACE) {
+                    val relationContent = event.getRelationContent()
+                    if (relationContent?.type == RelationType.REPLACE) {
                         Timber.v("###REPLACE in room $roomId for event ${event.eventId}")
                         // A replace!
-                        handleReplace(realm, event, content, roomId, isLocalEcho)
+                        handleReplace(realm, event, roomId, isLocalEcho, relationContent.eventId)
                     }
                 }
-
                 EventType.KEY_VERIFICATION_DONE,
                 EventType.KEY_VERIFICATION_CANCEL,
                 EventType.KEY_VERIFICATION_ACCEPT,
@@ -142,74 +170,31 @@ internal class EventRelationsAggregationProcessor @Inject constructor(
                         }
                     }
                 }
-
+                // As for now Live event processors are not receiving UTD events.
+                // They will get an update if the event is decrypted later
                 EventType.ENCRYPTED -> {
-                    // Relation type is in clear
+                    // Relation type is in clear, it might be possible to do some things?
+                    // Notice that if the event is decrypted later, process be called again
                     val encryptedEventContent = event.content.toModel()
-                    if (encryptedEventContent?.relatesTo?.type == RelationType.REPLACE ||
-                            encryptedEventContent?.relatesTo?.type == RelationType.RESPONSE
-                    ) {
-                        event.getClearContent().toModel()?.let {
-                            if (encryptedEventContent.relatesTo.type == RelationType.REPLACE) {
-                                Timber.v("###REPLACE in room $roomId for event ${event.eventId}")
-                                // A replace!
-                                handleReplace(realm, event, it, roomId, isLocalEcho, encryptedEventContent.relatesTo.eventId)
-                            } else if (event.getClearType() in EventType.POLL_RESPONSE) {
-                                sessionManager.getSessionComponent(sessionId)?.session()?.let { session ->
-                                    pollAggregationProcessor.handlePollResponseEvent(session, realm, event)
-                                }
-                            }
+                    when (encryptedEventContent?.relatesTo?.type) {
+                        RelationType.REPLACE -> {
+                            Timber.v("###REPLACE in room $roomId for event ${event.eventId}")
+                            // A replace!
+                            handleReplace(realm, event, roomId, isLocalEcho, encryptedEventContent.relatesTo.eventId)
                         }
-                    } else if (encryptedEventContent?.relatesTo?.type == RelationType.REFERENCE) {
-                        when (event.getClearType()) {
-                            EventType.KEY_VERIFICATION_DONE,
-                            EventType.KEY_VERIFICATION_CANCEL,
-                            EventType.KEY_VERIFICATION_ACCEPT,
-                            EventType.KEY_VERIFICATION_START,
-                            EventType.KEY_VERIFICATION_MAC,
-                            EventType.KEY_VERIFICATION_READY,
-                            EventType.KEY_VERIFICATION_KEY -> {
-                                Timber.v("## SAS REF in room $roomId for event ${event.eventId}")
-                                encryptedEventContent.relatesTo.eventId?.let {
-                                    handleVerification(realm, event, roomId, isLocalEcho, it)
-                                }
-                            }
-                            in EventType.POLL_RESPONSE -> {
-                                event.getClearContent().toModel(catchError = true)?.let {
-                                    sessionManager.getSessionComponent(sessionId)?.session()?.let { session ->
-                                        pollAggregationProcessor.handlePollResponseEvent(session, realm, event)
-                                    }
-                                }
-                            }
-                            in EventType.POLL_END -> {
-                                sessionManager.getSessionComponent(sessionId)?.session()?.let { session ->
-                                    getPowerLevelsHelper(event.roomId)?.let {
-                                        pollAggregationProcessor.handlePollEndEvent(session, it, realm, event)
-                                    }
-                                }
-                            }
-                            in EventType.BEACON_LOCATION_DATA -> {
-                                handleBeaconLocationData(event, realm, roomId, isLocalEcho)
-                            }
+                        RelationType.RESPONSE -> {
+                            // can we / should we do we something for UTD response??
+                            Timber.w("## UTD response in room $roomId related to ${encryptedEventContent.relatesTo.eventId}")
                         }
-                    } else if (encryptedEventContent?.relatesTo?.type == RelationType.ANNOTATION) {
-                        // Reaction
-                        if (event.getClearType() == EventType.REACTION) {
-                            // we got a reaction!!
-                            Timber.v("###REACTION e2e in room $roomId , reaction eventID ${event.eventId}")
-                            handleReaction(realm, event, roomId, isLocalEcho)
+                        RelationType.REFERENCE -> {
+                            // can we / should we do we something for UTD reference??
+                            Timber.w("## UTD reference in room $roomId related to ${encryptedEventContent.relatesTo.eventId}")
+                        }
+                        RelationType.ANNOTATION -> {
+                            // can we / should we do we something for UTD annotation??
+                            Timber.w("## UTD annotation in room $roomId related to ${encryptedEventContent.relatesTo.eventId}")
                         }
                     }
-                    // HandleInitialAggregatedRelations should also be applied in encrypted messages with annotations
-//                    else if (event.unsignedData?.relations?.annotations != null) {
-//                        Timber.v("###REACTION e2e Aggregation in room $roomId for event ${event.eventId}")
-//                        handleInitialAggregatedRelations(realm, event, roomId, event.unsignedData.relations.annotations)
-//                         EventAnnotationsSummaryEntity.where(realm, roomId, event.eventId ?: "").findFirst()
-//                                 ?.let {
-//                                     TimelineEventEntity.where(realm, roomId = roomId, eventId = event.eventId ?: "").findAll()
-//                                             ?.forEach { tet -> tet.annotations = it }
-//                                 }
-//                    }
                 }
                 EventType.REDACTION -> {
                     val eventToPrune = event.redacts?.let { EventEntity.where(realm, eventId = it).findFirst() }
@@ -217,9 +202,6 @@ internal class EventRelationsAggregationProcessor @Inject constructor(
                     when (eventToPrune.type) {
                         EventType.MESSAGE -> {
                             Timber.d("REDACTION for message ${eventToPrune.eventId}")
-//                                val unsignedData = EventMapper.map(eventToPrune).unsignedData
-//                                        ?: UnsignedData(null, null)
-
                             // was this event a m.replace
                             val contentModel = ContentMapper.map(eventToPrune.content)?.toModel()
                             if (RelationType.REPLACE == contentModel?.relatesTo?.type && contentModel.relatesTo?.eventId != null) {
@@ -231,34 +213,34 @@ internal class EventRelationsAggregationProcessor @Inject constructor(
                         }
                     }
                 }
-                in EventType.POLL_START -> {
+                in EventType.POLL_START.values -> {
                     val content: MessagePollContent? = event.content.toModel()
                     if (content?.relatesTo?.type == RelationType.REPLACE) {
                         Timber.v("###REPLACE in room $roomId for event ${event.eventId}")
                         // A replace!
-                        handleReplace(realm, event, content, roomId, isLocalEcho)
+                        handleReplace(realm, event, roomId, isLocalEcho, content.relatesTo.eventId)
                     }
                 }
-                in EventType.POLL_RESPONSE -> {
+                in EventType.POLL_RESPONSE.values -> {
                     event.content.toModel(catchError = true)?.let {
                         sessionManager.getSessionComponent(sessionId)?.session()?.let { session ->
                             pollAggregationProcessor.handlePollResponseEvent(session, realm, event)
                         }
                     }
                 }
-                in EventType.POLL_END -> {
+                in EventType.POLL_END.values -> {
                     sessionManager.getSessionComponent(sessionId)?.session()?.let { session ->
                         getPowerLevelsHelper(event.roomId)?.let {
                             pollAggregationProcessor.handlePollEndEvent(session, it, realm, event)
                         }
                     }
                 }
-                in EventType.STATE_ROOM_BEACON_INFO -> {
+                in EventType.STATE_ROOM_BEACON_INFO.values -> {
                     event.content.toModel(catchError = true)?.let {
                         liveLocationAggregationProcessor.handleBeaconInfo(realm, event, it, roomId, isLocalEcho)
                     }
                 }
-                in EventType.BEACON_LOCATION_DATA -> {
+                in EventType.BEACON_LOCATION_DATA.values -> {
                     handleBeaconLocationData(event, realm, roomId, isLocalEcho)
                 }
                 else -> Timber.v("UnHandled event ${event.eventId}")
@@ -274,23 +256,22 @@ internal class EventRelationsAggregationProcessor @Inject constructor(
     private fun handleReplace(
             realm: Realm,
             event: Event,
-            content: MessageContent,
             roomId: String,
             isLocalEcho: Boolean,
-            relatedEventId: String? = null
+            relatedEventId: String?
     ) {
         val eventId = event.eventId ?: return
-        val targetEventId = relatedEventId ?: content.relatesTo?.eventId ?: return
-        val newContent = content.newContent ?: return
-
-        // Check that the sender is the same
+        val targetEventId = relatedEventId ?: return
         val editedEvent = EventEntity.where(realm, targetEventId).findFirst()
-        if (editedEvent == null) {
-            // We do not know yet about the edited event
-        } else if (editedEvent.sender != event.senderId) {
-            // Edited by someone else, ignore
-            Timber.w("Ignore edition by someone else")
-            return
+
+        when (val validity = editValidator.validateEdit(editedEvent?.asDomain(), event)) {
+            is EventEditValidator.EditValidity.Invalid -> return Unit.also {
+                Timber.w("Dropping invalid edit ${event.eventId}, reason:${validity.reason}")
+            }
+            EventEditValidator.EditValidity.Unknown, // we can't drop the source event might be unknown, will be validated later
+            EventEditValidator.EditValidity.Valid -> {
+                // continue
+            }
         }
 
         // ok, this is a replace
@@ -305,11 +286,10 @@ internal class EventRelationsAggregationProcessor @Inject constructor(
                     .also { editSummary ->
                         editSummary.editions.add(
                                 EditionOfEvent(
-                                        senderId = event.senderId ?: "",
                                         eventId = event.eventId,
-                                        content = ContentMapper.map(newContent),
-                                        timestamp = if (isLocalEcho) 0 else event.originServerTs ?: 0,
-                                        isLocalEcho = isLocalEcho
+                                        event = EventEntity.where(realm, eventId).findFirst(),
+                                        timestamp = if (isLocalEcho) clock.epochMillis() else event.originServerTs ?: clock.epochMillis(),
+                                        isLocalEcho = isLocalEcho,
                                 )
                         )
                     }
@@ -326,17 +306,17 @@ internal class EventRelationsAggregationProcessor @Inject constructor(
                 // ok it has already been managed
                 Timber.v("###REPLACE Receiving remote echo of edit (edit already done)")
                 existingSummary.editions.firstOrNull { it.eventId == txId }?.let {
-                    it.eventId = event.eventId
+                    it.eventId = eventId
                     it.timestamp = event.originServerTs ?: clock.epochMillis()
                     it.isLocalEcho = false
+                    it.event = EventEntity.where(realm, eventId).findFirst()
                 }
             } else {
                 Timber.v("###REPLACE Computing aggregated edit summary (isLocalEcho:$isLocalEcho)")
                 existingSummary.editions.add(
                         EditionOfEvent(
-                                senderId = event.senderId ?: "",
-                                eventId = event.eventId,
-                                content = ContentMapper.map(newContent),
+                                eventId = eventId,
+                                event = EventEntity.where(realm, eventId).findFirst(),
                                 timestamp = if (isLocalEcho) {
                                     clock.epochMillis()
                                 } else {
@@ -349,7 +329,7 @@ internal class EventRelationsAggregationProcessor @Inject constructor(
             }
         }
 
-        if (event.getClearType() in EventType.POLL_START) {
+        if (event.getClearType() in EventType.POLL_START.values) {
             pollAggregationProcessor.handlePollStartEvent(realm, event)
         }
 
@@ -501,7 +481,7 @@ internal class EventRelationsAggregationProcessor @Inject constructor(
         }
         val sourceToDiscard = eventSummary.editSummary?.editions?.firstOrNull { it.eventId == redacted.eventId }
         if (sourceToDiscard == null) {
-            Timber.w("Redaction of a replace that was not known in aggregation $sourceToDiscard")
+            Timber.w("Redaction of a replace that was not known in aggregation")
             return
         }
         // Need to remove this event from the edition list
@@ -599,12 +579,12 @@ internal class EventRelationsAggregationProcessor @Inject constructor(
     private fun handleBeaconLocationData(event: Event, realm: Realm, roomId: String, isLocalEcho: Boolean) {
         event.getClearContent().toModel(catchError = true)?.let {
             liveLocationAggregationProcessor.handleBeaconLocationData(
-                    realm,
-                    event,
-                    it,
-                    roomId,
-                    event.getRelationContent()?.eventId,
-                    isLocalEcho
+                    realm = realm,
+                    event = event,
+                    content = it,
+                    roomId = roomId,
+                    relatedEventId = event.getRelationContent()?.eventId,
+                    isLocalEcho = isLocalEcho
             )
         }
     }
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomAPI.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomAPI.kt
index 9bcb7b8e4c..34b6ee525d 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomAPI.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomAPI.kt
@@ -18,7 +18,6 @@ package org.matrix.android.sdk.internal.session.room
 
 import org.matrix.android.sdk.api.session.events.model.Content
 import org.matrix.android.sdk.api.session.events.model.Event
-import org.matrix.android.sdk.api.session.events.model.RelationType
 import org.matrix.android.sdk.api.session.room.model.Membership
 import org.matrix.android.sdk.api.session.room.model.RoomStrippedState
 import org.matrix.android.sdk.api.session.room.model.roomdirectory.PublicRoomsParams
@@ -33,7 +32,9 @@ import org.matrix.android.sdk.internal.session.room.membership.RoomMembersRespon
 import org.matrix.android.sdk.internal.session.room.membership.admin.UserIdAndReason
 import org.matrix.android.sdk.internal.session.room.membership.joining.InviteBody
 import org.matrix.android.sdk.internal.session.room.membership.threepid.ThreePidInviteBody
+import org.matrix.android.sdk.internal.session.room.read.ReadBody
 import org.matrix.android.sdk.internal.session.room.relation.RelationsResponse
+import org.matrix.android.sdk.internal.session.room.relation.threads.ThreadSummariesResponse
 import org.matrix.android.sdk.internal.session.room.reporting.ReportContentBody
 import org.matrix.android.sdk.internal.session.room.send.SendResponse
 import org.matrix.android.sdk.internal.session.room.tags.TagBody
@@ -173,7 +174,7 @@ internal interface RoomAPI {
             @Path("roomId") roomId: String,
             @Path("receiptType") receiptType: String,
             @Path("eventId") eventId: String,
-            @Body body: JsonDict = emptyMap()
+            @Body body: ReadBody
     )
 
     /**
@@ -249,7 +250,7 @@ internal interface RoomAPI {
      * @param limit max number of Event to retrieve
      */
     @GET(NetworkConstants.URI_API_PREFIX_PATH_UNSTABLE + "rooms/{roomId}/relations/{eventId}/{relationType}/{eventType}")
-    suspend fun getRelations(
+    suspend fun getRelationsWithEventType(
             @Path("roomId") roomId: String,
             @Path("eventId") eventId: String,
             @Path("relationType") relationType: String,
@@ -260,7 +261,7 @@ internal interface RoomAPI {
     ): RelationsResponse
 
     /**
-     * Paginate relations for thread events based in normal topological order.
+     * Paginate relations for events based in normal topological order.
      *
      * @param roomId the room Id
      * @param eventId the event Id
@@ -270,10 +271,10 @@ internal interface RoomAPI {
      * @param limit max number of Event to retrieve
      */
     @GET(NetworkConstants.URI_API_PREFIX_PATH_UNSTABLE + "rooms/{roomId}/relations/{eventId}/{relationType}")
-    suspend fun getThreadsRelations(
+    suspend fun getRelations(
             @Path("roomId") roomId: String,
             @Path("eventId") eventId: String,
-            @Path("relationType") relationType: String = RelationType.THREAD,
+            @Path("relationType") relationType: String,
             @Query("from") from: String? = null,
             @Query("to") to: String? = null,
             @Query("limit") limit: Int? = null
@@ -426,6 +427,19 @@ internal interface RoomAPI {
             @Body content: JsonDict
     )
 
+    /**
+     * Remove an account_data event from the room.
+     * @param userId the user id
+     * @param roomId the room id
+     * @param type the type
+     */
+    @DELETE(NetworkConstants.URI_API_PREFIX_PATH_UNSTABLE + "org.matrix.msc3391/user/{userId}/rooms/{roomId}/account_data/{type}")
+    suspend fun deleteRoomAccountData(
+            @Path("userId") userId: String,
+            @Path("roomId") roomId: String,
+            @Path("type") type: String
+    )
+
     /**
      * Upgrades the given room to a particular room version.
      * Errors:
@@ -450,4 +464,12 @@ internal interface RoomAPI {
             @Path("roomIdOrAlias") roomidOrAlias: String,
             @Query("via") viaServers: List?
     ): RoomStrippedState
+
+    @GET(NetworkConstants.URI_API_PREFIX_PATH_V1 + "rooms/{roomId}/threads")
+    suspend fun getThreadsList(
+            @Path("roomId") roomId: String,
+            @Query("include") include: String? = "all",
+            @Query("from") from: String? = null,
+            @Query("limit") limit: Int? = null
+    ): ThreadSummariesResponse
 }
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomModule.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomModule.kt
index 681d65b6c6..98adbf8700 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomModule.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/RoomModule.kt
@@ -100,6 +100,8 @@ import org.matrix.android.sdk.internal.session.room.relation.DefaultUpdateQuickR
 import org.matrix.android.sdk.internal.session.room.relation.FetchEditHistoryTask
 import org.matrix.android.sdk.internal.session.room.relation.FindReactionEventForUndoTask
 import org.matrix.android.sdk.internal.session.room.relation.UpdateQuickReactionTask
+import org.matrix.android.sdk.internal.session.room.relation.poll.DefaultFetchPollResponseEventsTask
+import org.matrix.android.sdk.internal.session.room.relation.poll.FetchPollResponseEventsTask
 import org.matrix.android.sdk.internal.session.room.relation.threads.DefaultFetchThreadSummariesTask
 import org.matrix.android.sdk.internal.session.room.relation.threads.DefaultFetchThreadTimelineTask
 import org.matrix.android.sdk.internal.session.room.relation.threads.FetchThreadSummariesTask
@@ -355,4 +357,7 @@ internal abstract class RoomModule {
 
     @Binds
     abstract fun bindRedactLiveLocationShareTask(task: DefaultRedactLiveLocationShareTask): RedactLiveLocationShareTask
+
+    @Binds
+    abstract fun bindFetchPollResponseEventsTask(task: DefaultFetchPollResponseEventsTask): FetchPollResponseEventsTask
 }
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/aggregation/poll/DefaultPollAggregationProcessor.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/aggregation/poll/DefaultPollAggregationProcessor.kt
index 90d8e02c39..a424becbd6 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/aggregation/poll/DefaultPollAggregationProcessor.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/aggregation/poll/DefaultPollAggregationProcessor.kt
@@ -17,6 +17,7 @@
 package org.matrix.android.sdk.internal.session.room.aggregation.poll
 
 import io.realm.Realm
+import kotlinx.coroutines.launch
 import org.matrix.android.sdk.api.extensions.orFalse
 import org.matrix.android.sdk.api.session.Session
 import org.matrix.android.sdk.api.session.events.model.Event
@@ -29,7 +30,6 @@ import org.matrix.android.sdk.api.session.room.getTimelineEvent
 import org.matrix.android.sdk.api.session.room.model.PollSummaryContent
 import org.matrix.android.sdk.api.session.room.model.VoteInfo
 import org.matrix.android.sdk.api.session.room.model.VoteSummary
-import org.matrix.android.sdk.api.session.room.model.message.MessageEndPollContent
 import org.matrix.android.sdk.api.session.room.model.message.MessagePollContent
 import org.matrix.android.sdk.api.session.room.model.message.MessagePollResponseContent
 import org.matrix.android.sdk.api.session.room.powerlevels.PowerLevelsHelper
@@ -41,9 +41,14 @@ import org.matrix.android.sdk.internal.database.model.PollResponseAggregatedSumm
 import org.matrix.android.sdk.internal.database.query.create
 import org.matrix.android.sdk.internal.database.query.getOrCreate
 import org.matrix.android.sdk.internal.database.query.where
+import org.matrix.android.sdk.internal.session.room.relation.poll.FetchPollResponseEventsTask
+import org.matrix.android.sdk.internal.task.TaskExecutor
 import javax.inject.Inject
 
-class DefaultPollAggregationProcessor @Inject constructor() : PollAggregationProcessor {
+internal class DefaultPollAggregationProcessor @Inject constructor(
+        private val taskExecutor: TaskExecutor,
+        private val fetchPollResponseEventsTask: FetchPollResponseEventsTask,
+) : PollAggregationProcessor {
 
     override fun handlePollStartEvent(realm: Realm, event: Event): Boolean {
         val content = event.getClearContent()?.toModel()
@@ -78,7 +83,7 @@ class DefaultPollAggregationProcessor @Inject constructor() : PollAggregationPro
         val content = event.getClearContent()?.toModel() ?: return false
         val roomId = event.roomId ?: return false
         val senderId = event.senderId ?: return false
-        val targetEventId = (event.getRelationContent() ?: content.relatesTo)?.eventId ?: return false
+        val targetEventId = event.getRelationContent()?.eventId ?: return false
         val targetPollContent = getPollContent(session, roomId, targetEventId) ?: return false
 
         val annotationsSummaryEntity = getAnnotationsSummaryEntity(realm, roomId, targetEventId)
@@ -154,9 +159,8 @@ class DefaultPollAggregationProcessor @Inject constructor() : PollAggregationPro
     }
 
     override fun handlePollEndEvent(session: Session, powerLevelsHelper: PowerLevelsHelper, realm: Realm, event: Event): Boolean {
-        val content = event.getClearContent()?.toModel() ?: return false
         val roomId = event.roomId ?: return false
-        val pollEventId = content.relatesTo?.eventId ?: return false
+        val pollEventId = event.getRelationContent()?.eventId ?: return false
         val pollOwnerId = getPollEvent(session, roomId, pollEventId)?.root?.senderId
         val isPollOwner = pollOwnerId == event.senderId
 
@@ -176,6 +180,10 @@ class DefaultPollAggregationProcessor @Inject constructor() : PollAggregationPro
             aggregatedPollSummaryEntity.sourceEvents.add(event.eventId)
         }
 
+        if (!isLocalEcho) {
+            ensurePollIsFullyAggregated(roomId, pollEventId)
+        }
+
         return true
     }
 
@@ -202,4 +210,20 @@ class DefaultPollAggregationProcessor @Inject constructor() : PollAggregationPro
                     eventAnnotationsSummaryEntity.pollResponseSummary = it
                 }
     }
+
+    /**
+     * Check that all related votes to a given poll are all retrieved and aggregated.
+     */
+    private fun ensurePollIsFullyAggregated(
+            roomId: String,
+            pollEventId: String
+    ) {
+        taskExecutor.executorScope.launch {
+            val params = FetchPollResponseEventsTask.Params(
+                    roomId = roomId,
+                    startPollEventId = pollEventId,
+            )
+            fetchPollResponseEventsTask.execute(params)
+        }
+    }
 }
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/create/CreateLocalRoomTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/create/CreateLocalRoomTask.kt
index 03c2b2a47e..793c2573be 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/create/CreateLocalRoomTask.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/create/CreateLocalRoomTask.kt
@@ -21,6 +21,7 @@ import io.realm.Realm
 import io.realm.RealmConfiguration
 import io.realm.kotlin.createObject
 import kotlinx.coroutines.TimeoutCancellationException
+import org.matrix.android.sdk.api.session.events.model.Event
 import org.matrix.android.sdk.api.session.events.model.EventType
 import org.matrix.android.sdk.api.session.room.failure.CreateRoomFailure
 import org.matrix.android.sdk.api.session.room.model.Membership
@@ -72,8 +73,9 @@ internal class DefaultCreateLocalRoomTask @Inject constructor(
     override suspend fun execute(params: CreateRoomParams): String {
         val createRoomBody = createRoomBodyBuilder.build(params)
         val roomId = RoomLocalEcho.createLocalEchoId()
+        val eventList = createLocalRoomStateEventsTask.execute(CreateLocalRoomStateEventsTask.Params(createRoomBody))
         monarchy.awaitTransaction { realm ->
-            createLocalRoomEntity(realm, roomId, createRoomBody)
+            createLocalRoomEntity(realm, roomId, eventList)
             createLocalRoomSummaryEntity(realm, roomId, params, createRoomBody)
         }
 
@@ -95,10 +97,10 @@ internal class DefaultCreateLocalRoomTask @Inject constructor(
      * Create a local room entity from the given room creation params.
      * This will also generate and store in database the chunk and the events related to the room params in order to retrieve and display the local room.
      */
-    private suspend fun createLocalRoomEntity(realm: Realm, roomId: String, createRoomBody: CreateRoomBody) {
+    private fun createLocalRoomEntity(realm: Realm, roomId: String, localStateEventList: List) {
         RoomEntity.getOrCreate(realm, roomId).apply {
             membership = Membership.JOIN
-            chunks.add(createLocalRoomChunk(realm, roomId, createRoomBody))
+            chunks.add(createLocalRoomChunk(realm, roomId, localStateEventList))
             membersLoadStatus = RoomMembersLoadStatusType.LOADED
         }
     }
@@ -144,20 +146,19 @@ internal class DefaultCreateLocalRoomTask @Inject constructor(
      *
      * @param realm the current instance of realm
      * @param roomId the id of the local room
-     * @param createRoomBody the room creation params
+     * @param localStateEventList list of local state events for that room
      *
      * @return a chunk entity
      */
-    private suspend fun createLocalRoomChunk(realm: Realm, roomId: String, createRoomBody: CreateRoomBody): ChunkEntity {
+    private fun createLocalRoomChunk(realm: Realm, roomId: String, localStateEventList: List): ChunkEntity {
         val chunkEntity = realm.createObject().apply {
             isLastBackward = true
             isLastForward = true
         }
 
-        val eventList = createLocalRoomStateEventsTask.execute(CreateLocalRoomStateEventsTask.Params(createRoomBody))
         val roomMemberContentsByUser = HashMap()
 
-        for (event in eventList) {
+        for (event in localStateEventList) {
             if (event.eventId == null || event.senderId == null || event.type == null) {
                 continue
             }
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/create/RoomCreateEventProcessor.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/create/RoomCreateEventProcessor.kt
index eb966b684c..8b5fde6ab7 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/create/RoomCreateEventProcessor.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/create/RoomCreateEventProcessor.kt
@@ -30,7 +30,7 @@ import javax.inject.Inject
 
 internal class RoomCreateEventProcessor @Inject constructor() : EventInsertLiveProcessor {
 
-    override suspend fun process(realm: Realm, event: Event) {
+    override fun process(realm: Realm, event: Event) {
         val createRoomContent = event.getClearContent().toModel()
         val predecessorRoomId = createRoomContent?.predecessor?.roomId ?: return
 
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/location/DefaultLocationSharingService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/location/DefaultLocationSharingService.kt
index 60312071d7..c36efa064f 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/location/DefaultLocationSharingService.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/location/DefaultLocationSharingService.kt
@@ -73,7 +73,7 @@ internal class DefaultLocationSharingService @AssistedInject constructor(
         return sendLiveLocationTask.execute(params)
     }
 
-    override suspend fun startLiveLocationShare(timeoutMillis: Long, description: String): UpdateLiveLocationShareResult {
+    override suspend fun startLiveLocationShare(timeoutMillis: Long): UpdateLiveLocationShareResult {
         // Ensure to stop any active live before starting a new one
         if (checkIfExistingActiveLive()) {
             val result = stopLiveLocationShare()
@@ -84,7 +84,6 @@ internal class DefaultLocationSharingService @AssistedInject constructor(
         val params = StartLiveLocationShareTask.Params(
                 roomId = roomId,
                 timeoutMillis = timeoutMillis,
-                description = description
         )
         return startLiveLocationShareTask.execute(params)
     }
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/location/GetActiveBeaconInfoForUserTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/location/GetActiveBeaconInfoForUserTask.kt
index a8d955af1d..ae7022a204 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/location/GetActiveBeaconInfoForUserTask.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/location/GetActiveBeaconInfoForUserTask.kt
@@ -39,7 +39,7 @@ internal class DefaultGetActiveBeaconInfoForUserTask @Inject constructor(
 ) : GetActiveBeaconInfoForUserTask {
 
     override suspend fun execute(params: GetActiveBeaconInfoForUserTask.Params): Event? {
-        return EventType.STATE_ROOM_BEACON_INFO
+        return EventType.STATE_ROOM_BEACON_INFO.values
                 .mapNotNull {
                     stateEventDataSource.getStateEvent(
                             roomId = params.roomId,
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/location/LiveLocationShareRedactionEventProcessor.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/location/LiveLocationShareRedactionEventProcessor.kt
index fa3479ed3c..dbdc5dc228 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/location/LiveLocationShareRedactionEventProcessor.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/location/LiveLocationShareRedactionEventProcessor.kt
@@ -40,7 +40,7 @@ internal class LiveLocationShareRedactionEventProcessor @Inject constructor() :
         return eventType == EventType.REDACTION && insertType != EventInsertType.LOCAL_ECHO
     }
 
-    override suspend fun process(realm: Realm, event: Event) {
+    override fun process(realm: Realm, event: Event) {
         if (event.redacts.isNullOrBlank() || LocalEcho.isLocalEchoId(event.eventId.orEmpty())) {
             return
         }
@@ -48,7 +48,7 @@ internal class LiveLocationShareRedactionEventProcessor @Inject constructor() :
         val redactedEvent = EventEntity.where(realm, eventId = event.redacts).findFirst()
                 ?: return
 
-        if (redactedEvent.type in EventType.STATE_ROOM_BEACON_INFO) {
+        if (redactedEvent.type in EventType.STATE_ROOM_BEACON_INFO.values) {
             val liveSummary = LiveLocationShareAggregatedSummaryEntity.get(realm, eventId = redactedEvent.eventId)
 
             if (liveSummary != null) {
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/location/StartLiveLocationShareTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/location/StartLiveLocationShareTask.kt
index 79019e4765..dd409fe3a7 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/location/StartLiveLocationShareTask.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/location/StartLiveLocationShareTask.kt
@@ -30,7 +30,6 @@ internal interface StartLiveLocationShareTask : Task allowedKeys.contains(key) }
-            eventToPrune.content = ContentMapper.map(prunedContent)
-        } else {
-            when (typeToPrune) {
-                EventType.ENCRYPTED,
-                EventType.MESSAGE,
-                in EventType.STATE_ROOM_BEACON_INFO,
-                in EventType.BEACON_LOCATION_DATA,
-                in EventType.POLL_START -> {
-                    Timber.d("REDACTION for message ${eventToPrune.eventId}")
-                    val unsignedData = EventMapper.map(eventToPrune).unsignedData
-                            ?: UnsignedData(null, null)
-
-                    // was this event a m.replace
+        when {
+            allowedKeys.isNotEmpty() -> {
+                val prunedContent = ContentMapper.map(eventToPrune.content)?.filterKeys { key -> allowedKeys.contains(key) }
+                eventToPrune.content = ContentMapper.map(prunedContent)
+            }
+            canPruneEventType(typeToPrune) -> {
+                Timber.d("REDACTION for message ${eventToPrune.eventId}")
+                val unsignedData = EventMapper.map(eventToPrune).unsignedData ?: UnsignedData(null, null)
+
+                // was this event a m.replace
 //                    val contentModel = ContentMapper.map(eventToPrune.content)?.toModel()
 //                    if (RelationType.REPLACE == contentModel?.relatesTo?.type && contentModel.relatesTo?.eventId != null) {
 //                        eventRelationsAggregationUpdater.handleRedactionOfReplace(eventToPrune, contentModel.relatesTo!!.eventId!!, realm)
 //                    }
 
-                    val modified = unsignedData.copy(redactedEvent = redactionEvent)
-                    // Deleting the content of a thread message will result to delete the thread relation, however threads are now dynamic
-                    // so there is not much of a problem
-                    eventToPrune.content = ContentMapper.map(emptyMap())
-                    eventToPrune.unsignedData = MoshiProvider.providesMoshi().adapter(UnsignedData::class.java).toJson(modified)
-                    eventToPrune.decryptionResultJson = null
-                    eventToPrune.decryptionErrorCode = null
-
-                    handleTimelineThreadSummaryIfNeeded(realm, eventToPrune, isLocalEcho)
-                }
-//                EventType.REACTION -> {
-//                    eventRelationsAggregationUpdater.handleReactionRedact(eventToPrune, realm, userId)
-//                }
+                val modified = unsignedData.copy(redactedEvent = redactionEvent)
+                eventToPrune.content = ContentMapper.map(emptyMap())
+                eventToPrune.unsignedData = MoshiProvider.providesMoshi().adapter(UnsignedData::class.java).toJson(modified)
+                eventToPrune.decryptionResultJson = null
+                eventToPrune.decryptionErrorCode = null
+
+                handleTimelineThreadSummaryIfNeeded(realm, eventToPrune, isLocalEcho)
+            }
+            else -> {
+                Timber.w("Not pruning event (type $typeToPrune)")
             }
         }
         if (typeToPrune == EventType.STATE_ROOM_MEMBER && stateKey != null) {
@@ -167,4 +158,28 @@ internal class RedactionEventProcessor @Inject constructor() : EventInsertLivePr
             else -> emptyList()
         }
     }
+
+    private fun canPruneEventType(eventType: String): Boolean {
+        return when {
+            EventType.isCallEvent(eventType) -> false
+            EventType.isVerificationEvent(eventType) -> false
+            eventType == EventType.STATE_ROOM_WIDGET_LEGACY ||
+                    eventType == EventType.STATE_ROOM_WIDGET ||
+                    eventType == EventType.STATE_ROOM_NAME ||
+                    eventType == EventType.STATE_ROOM_TOPIC ||
+                    eventType == EventType.STATE_ROOM_AVATAR ||
+                    eventType == EventType.STATE_ROOM_THIRD_PARTY_INVITE ||
+                    eventType == EventType.STATE_ROOM_GUEST_ACCESS ||
+                    eventType == EventType.STATE_SPACE_CHILD ||
+                    eventType == EventType.STATE_SPACE_PARENT ||
+                    eventType == EventType.STATE_ROOM_TOMBSTONE ||
+                    eventType == EventType.STATE_ROOM_HISTORY_VISIBILITY ||
+                    eventType == EventType.STATE_ROOM_RELATED_GROUPS ||
+                    eventType == EventType.STATE_ROOM_PINNED_EVENT ||
+                    eventType == EventType.STATE_ROOM_ENCRYPTION ||
+                    eventType == EventType.STATE_ROOM_SERVER_ACL ||
+                    eventType == EventType.REACTION -> false
+            else -> true
+        }
+    }
 }
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/read/DefaultReadService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/read/DefaultReadService.kt
index b30c66c82e..36ec5e8dac 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/read/DefaultReadService.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/read/DefaultReadService.kt
@@ -30,17 +30,20 @@ import org.matrix.android.sdk.internal.database.mapper.ReadReceiptsSummaryMapper
 import org.matrix.android.sdk.internal.database.model.ReadMarkerEntity
 import org.matrix.android.sdk.internal.database.model.ReadReceiptEntity
 import org.matrix.android.sdk.internal.database.model.ReadReceiptsSummaryEntity
+import org.matrix.android.sdk.internal.database.query.forMainTimelineWhere
 import org.matrix.android.sdk.internal.database.query.isEventRead
 import org.matrix.android.sdk.internal.database.query.where
 import org.matrix.android.sdk.internal.di.SessionDatabase
 import org.matrix.android.sdk.internal.di.UserId
+import org.matrix.android.sdk.internal.session.homeserver.HomeServerCapabilitiesDataSource
 
 internal class DefaultReadService @AssistedInject constructor(
         @Assisted private val roomId: String,
         @SessionDatabase private val monarchy: Monarchy,
         private val setReadMarkersTask: SetReadMarkersTask,
         private val readReceiptsSummaryMapper: ReadReceiptsSummaryMapper,
-        @UserId private val userId: String
+        @UserId private val userId: String,
+        private val homeServerCapabilitiesDataSource: HomeServerCapabilitiesDataSource
 ) : ReadService {
 
     @AssistedFactory
@@ -48,17 +51,28 @@ internal class DefaultReadService @AssistedInject constructor(
         fun create(roomId: String): DefaultReadService
     }
 
-    override suspend fun markAsRead(params: ReadService.MarkAsReadParams) {
+    override suspend fun markAsRead(params: ReadService.MarkAsReadParams, mainTimeLineOnly: Boolean) {
+        val readReceiptThreadId = if (homeServerCapabilitiesDataSource.getHomeServerCapabilities()?.canUseThreadReadReceiptsAndNotifications == true) {
+            if (mainTimeLineOnly) ReadService.THREAD_ID_MAIN else null
+        } else {
+            null
+        }
         val taskParams = SetReadMarkersTask.Params(
                 roomId = roomId,
                 forceReadMarker = params.forceReadMarker(),
-                forceReadReceipt = params.forceReadReceipt()
+                forceReadReceipt = params.forceReadReceipt(),
+                readReceiptThreadId = readReceiptThreadId
         )
         setReadMarkersTask.execute(taskParams)
     }
 
-    override suspend fun setReadReceipt(eventId: String) {
-        val params = SetReadMarkersTask.Params(roomId, fullyReadEventId = null, readReceiptEventId = eventId)
+    override suspend fun setReadReceipt(eventId: String, threadId: String) {
+        val readReceiptThreadId = if (homeServerCapabilitiesDataSource.getHomeServerCapabilities()?.canUseThreadReadReceiptsAndNotifications == true) {
+            threadId
+        } else {
+            null
+        }
+        val params = SetReadMarkersTask.Params(roomId, fullyReadEventId = null, readReceiptEventId = eventId, readReceiptThreadId = readReceiptThreadId)
         setReadMarkersTask.execute(params)
     }
 
@@ -68,7 +82,8 @@ internal class DefaultReadService @AssistedInject constructor(
     }
 
     override fun isEventRead(eventId: String): Boolean {
-        return isEventRead(monarchy.realmConfiguration, userId, roomId, eventId)
+        val shouldCheckIfReadInEventsThread = homeServerCapabilitiesDataSource.getHomeServerCapabilities()?.canUseThreadReadReceiptsAndNotifications == true
+        return isEventRead(monarchy.realmConfiguration, userId, roomId, eventId, shouldCheckIfReadInEventsThread)
     }
 
     override fun getReadMarkerLive(): LiveData> {
@@ -81,9 +96,9 @@ internal class DefaultReadService @AssistedInject constructor(
         }
     }
 
-    override fun getMyReadReceiptLive(): LiveData> {
+    override fun getMyReadReceiptLive(threadId: String?): LiveData> {
         val liveRealmData = monarchy.findAllMappedWithChanges(
-                { ReadReceiptEntity.where(it, roomId = roomId, userId = userId) },
+                { ReadReceiptEntity.where(it, roomId = roomId, userId = userId, threadId = threadId) },
                 { it.eventId }
         )
         return Transformations.map(liveRealmData) {
@@ -94,10 +109,11 @@ internal class DefaultReadService @AssistedInject constructor(
     override fun getUserReadReceipt(userId: String): String? {
         var eventId: String? = null
         monarchy.doWithRealm {
-            eventId = ReadReceiptEntity.where(it, roomId = roomId, userId = userId)
+            eventId = ReadReceiptEntity.forMainTimelineWhere(it, roomId = roomId, userId = userId)
                     .findFirst()
                     ?.eventId
         }
+
         return eventId
     }
 
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/read/ReadBody.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/read/ReadBody.kt
new file mode 100644
index 0000000000..9374de5d5f
--- /dev/null
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/read/ReadBody.kt
@@ -0,0 +1,25 @@
+/*
+ * Copyright 2020 The Matrix.org Foundation C.I.C.
+ *
+ * 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 org.matrix.android.sdk.internal.session.room.read
+
+import com.squareup.moshi.Json
+import com.squareup.moshi.JsonClass
+
+@JsonClass(generateAdapter = true)
+internal data class ReadBody(
+        @Json(name = "thread_id") val threadId: String?,
+)
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/read/SetReadMarkersTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/read/SetReadMarkersTask.kt
index a124a8a4c2..8e7592a8b4 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/read/SetReadMarkersTask.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/read/SetReadMarkersTask.kt
@@ -19,6 +19,7 @@ package org.matrix.android.sdk.internal.session.room.read
 import com.zhuinden.monarchy.Monarchy
 import io.realm.Realm
 import org.matrix.android.sdk.api.session.events.model.LocalEcho
+import org.matrix.android.sdk.api.session.homeserver.HomeServerCapabilitiesService
 import org.matrix.android.sdk.internal.database.model.RoomSummaryEntity
 import org.matrix.android.sdk.internal.database.model.TimelineEventEntity
 import org.matrix.android.sdk.internal.database.query.isEventRead
@@ -45,8 +46,9 @@ internal interface SetReadMarkersTask : Task {
             val roomId: String,
             val fullyReadEventId: String? = null,
             val readReceiptEventId: String? = null,
+            val readReceiptThreadId: String? = null,
             val forceReadReceipt: Boolean = false,
-            val forceReadMarker: Boolean = false
+            val forceReadMarker: Boolean = false,
     )
 }
 
@@ -61,12 +63,14 @@ internal class DefaultSetReadMarkersTask @Inject constructor(
         @UserId private val userId: String,
         private val globalErrorReceiver: GlobalErrorReceiver,
         private val clock: Clock,
+        private val homeServerCapabilitiesService: HomeServerCapabilitiesService,
 ) : SetReadMarkersTask {
 
     override suspend fun execute(params: SetReadMarkersTask.Params) {
         val markers = mutableMapOf()
         Timber.v("Execute set read marker with params: $params")
         val latestSyncedEventId = latestSyncedEventId(params.roomId)
+        val readReceiptThreadId = params.readReceiptThreadId
         val fullyReadEventId = if (params.forceReadMarker) {
             latestSyncedEventId
         } else {
@@ -77,6 +81,7 @@ internal class DefaultSetReadMarkersTask @Inject constructor(
         } else {
             params.readReceiptEventId
         }
+
         if (fullyReadEventId != null && !isReadMarkerMoreRecent(monarchy.realmConfiguration, params.roomId, fullyReadEventId)) {
             if (LocalEcho.isLocalEchoId(fullyReadEventId)) {
                 Timber.w("Can't set read marker for local event $fullyReadEventId")
@@ -84,8 +89,12 @@ internal class DefaultSetReadMarkersTask @Inject constructor(
                 markers[READ_MARKER] = fullyReadEventId
             }
         }
+
+        val shouldCheckIfReadInEventsThread = readReceiptThreadId != null &&
+                homeServerCapabilitiesService.getHomeServerCapabilities().canUseThreadReadReceiptsAndNotifications
+
         if (readReceiptEventId != null &&
-                !isEventRead(monarchy.realmConfiguration, userId, params.roomId, readReceiptEventId)) {
+                !isEventRead(monarchy.realmConfiguration, userId, params.roomId, readReceiptEventId, shouldCheckIfReadInEventsThread)) {
             if (LocalEcho.isLocalEchoId(readReceiptEventId)) {
                 Timber.w("Can't set read receipt for local event $readReceiptEventId")
             } else {
@@ -95,7 +104,7 @@ internal class DefaultSetReadMarkersTask @Inject constructor(
 
         val shouldUpdateRoomSummary = readReceiptEventId != null && readReceiptEventId == latestSyncedEventId
         if (markers.isNotEmpty() || shouldUpdateRoomSummary) {
-            updateDatabase(params.roomId, markers, shouldUpdateRoomSummary)
+            updateDatabase(params.roomId, readReceiptThreadId, markers, shouldUpdateRoomSummary)
         }
         if (markers.isNotEmpty()) {
             executeRequest(
@@ -104,7 +113,8 @@ internal class DefaultSetReadMarkersTask @Inject constructor(
             ) {
                 if (markers[READ_MARKER] == null) {
                     if (readReceiptEventId != null) {
-                        roomAPI.sendReceipt(params.roomId, READ_RECEIPT, readReceiptEventId)
+                        val readBody = ReadBody(threadId = params.readReceiptThreadId)
+                        roomAPI.sendReceipt(params.roomId, READ_RECEIPT, readReceiptEventId, readBody)
                     }
                 } else {
                     // "m.fully_read" value is mandatory to make this call
@@ -119,7 +129,7 @@ internal class DefaultSetReadMarkersTask @Inject constructor(
                 TimelineEventEntity.latestEvent(realm, roomId = roomId, includesSending = false)?.eventId
             }
 
-    private suspend fun updateDatabase(roomId: String, markers: Map, shouldUpdateRoomSummary: Boolean) {
+    private suspend fun updateDatabase(roomId: String, readReceiptThreadId: String?, markers: Map, shouldUpdateRoomSummary: Boolean) {
         monarchy.awaitTransaction { realm ->
             val readMarkerId = markers[READ_MARKER]
             val readReceiptId = markers[READ_RECEIPT]
@@ -127,7 +137,7 @@ internal class DefaultSetReadMarkersTask @Inject constructor(
                 roomFullyReadHandler.handle(realm, roomId, FullyReadContent(readMarkerId))
             }
             if (readReceiptId != null) {
-                val readReceiptContent = ReadReceiptHandler.createContent(userId, readReceiptId, clock.epochMillis())
+                val readReceiptContent = ReadReceiptHandler.createContent(userId, readReceiptId, readReceiptThreadId, clock.epochMillis())
                 readReceiptHandler.handle(realm, roomId, readReceiptContent, false, null)
             }
             if (shouldUpdateRoomSummary) {
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/FetchEditHistoryTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/FetchEditHistoryTask.kt
index 93c7f143fd..50439f51eb 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/FetchEditHistoryTask.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/FetchEditHistoryTask.kt
@@ -43,7 +43,7 @@ internal class DefaultFetchEditHistoryTask @Inject constructor(
     override suspend fun execute(params: FetchEditHistoryTask.Params): List {
         val isRoomEncrypted = cryptoSessionInfoProvider.isRoomEncrypted(params.roomId)
         val response = executeRequest(globalErrorReceiver) {
-            roomAPI.getRelations(
+            roomAPI.getRelationsWithEventType(
                     roomId = params.roomId,
                     eventId = params.eventId,
                     relationType = RelationType.REPLACE,
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/poll/FetchPollResponseEventsTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/poll/FetchPollResponseEventsTask.kt
new file mode 100644
index 0000000000..e7dd8c57eb
--- /dev/null
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/poll/FetchPollResponseEventsTask.kt
@@ -0,0 +1,118 @@
+/*
+ * Copyright (c) 2022 The Matrix.org Foundation C.I.C.
+ *
+ * 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 org.matrix.android.sdk.internal.session.room.relation.poll
+
+import androidx.annotation.VisibleForTesting
+import com.zhuinden.monarchy.Monarchy
+import org.matrix.android.sdk.api.session.events.model.Event
+import org.matrix.android.sdk.api.session.events.model.RelationType
+import org.matrix.android.sdk.api.session.events.model.isPollResponse
+import org.matrix.android.sdk.api.session.room.send.SendState
+import org.matrix.android.sdk.internal.crypto.EventDecryptor
+import org.matrix.android.sdk.internal.database.mapper.toEntity
+import org.matrix.android.sdk.internal.database.model.EventEntity
+import org.matrix.android.sdk.internal.database.model.EventInsertType
+import org.matrix.android.sdk.internal.database.query.copyToRealmOrIgnore
+import org.matrix.android.sdk.internal.database.query.where
+import org.matrix.android.sdk.internal.di.SessionDatabase
+import org.matrix.android.sdk.internal.network.GlobalErrorReceiver
+import org.matrix.android.sdk.internal.network.executeRequest
+import org.matrix.android.sdk.internal.session.room.RoomAPI
+import org.matrix.android.sdk.internal.session.room.relation.RelationsResponse
+import org.matrix.android.sdk.internal.task.Task
+import org.matrix.android.sdk.internal.util.awaitTransaction
+import org.matrix.android.sdk.internal.util.time.Clock
+import javax.inject.Inject
+
+@VisibleForTesting
+const val FETCH_RELATED_EVENTS_LIMIT = 50
+
+/**
+ * Task to fetch all the vote events to ensure full aggregation for a given poll.
+ */
+internal interface FetchPollResponseEventsTask : Task> {
+    data class Params(
+            val roomId: String,
+            val startPollEventId: String,
+    )
+}
+
+internal class DefaultFetchPollResponseEventsTask @Inject constructor(
+        private val roomAPI: RoomAPI,
+        private val globalErrorReceiver: GlobalErrorReceiver,
+        @SessionDatabase private val monarchy: Monarchy,
+        private val clock: Clock,
+        private val eventDecryptor: EventDecryptor,
+) : FetchPollResponseEventsTask {
+
+    override suspend fun execute(params: FetchPollResponseEventsTask.Params): Result = runCatching {
+        var nextBatch: String? = fetchAndProcessRelatedEventsFrom(params)
+
+        while (nextBatch?.isNotEmpty() == true) {
+            nextBatch = fetchAndProcessRelatedEventsFrom(params, from = nextBatch)
+        }
+    }
+
+    private suspend fun fetchAndProcessRelatedEventsFrom(params: FetchPollResponseEventsTask.Params, from: String? = null): String? {
+        val response = getRelatedEvents(params, from)
+
+        val filteredEvents = response.chunks
+                .map { decryptEventIfNeeded(it) }
+                .filter { it.isPollResponse() }
+
+        addMissingEventsInDB(params.roomId, filteredEvents)
+
+        return response.nextBatch
+    }
+
+    private suspend fun getRelatedEvents(params: FetchPollResponseEventsTask.Params, from: String? = null): RelationsResponse {
+        return executeRequest(globalErrorReceiver, canRetry = true) {
+            roomAPI.getRelations(
+                    roomId = params.roomId,
+                    eventId = params.startPollEventId,
+                    relationType = RelationType.REFERENCE,
+                    from = from,
+                    limit = FETCH_RELATED_EVENTS_LIMIT,
+            )
+        }
+    }
+
+    private suspend fun addMissingEventsInDB(roomId: String, events: List) {
+        monarchy.awaitTransaction { realm ->
+            val eventIdsToCheck = events.mapNotNull { it.eventId }.filter { it.isNotEmpty() }
+            if (eventIdsToCheck.isNotEmpty()) {
+                val existingIds = EventEntity.where(realm, eventIdsToCheck).findAll().toList().map { it.eventId }
+
+                events.filterNot { it.eventId in existingIds }
+                        .map { it.toEntity(roomId = roomId, sendState = SendState.SYNCED, ageLocalTs = computeLocalTs(it)) }
+                        .forEach { it.copyToRealmOrIgnore(realm, EventInsertType.PAGINATION) }
+            }
+        }
+    }
+
+    private suspend fun decryptEventIfNeeded(event: Event): Event {
+        if (event.isEncrypted()) {
+            eventDecryptor.decryptEventAndSaveResult(event, timeline = "")
+        }
+
+        event.ageLocalTs = computeLocalTs(event)
+
+        return event
+    }
+
+    private fun computeLocalTs(event: Event) = clock.epochMillis() - (event.unsignedData?.age ?: 0)
+}
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/threads/FetchThreadSummariesTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/threads/FetchThreadSummariesTask.kt
index 254dee4295..848b9698ee 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/threads/FetchThreadSummariesTask.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/threads/FetchThreadSummariesTask.kt
@@ -16,37 +16,38 @@
 package org.matrix.android.sdk.internal.session.room.relation.threads
 
 import com.zhuinden.monarchy.Monarchy
+import io.realm.RealmList
 import org.matrix.android.sdk.api.session.room.model.RoomMemberContent
+import org.matrix.android.sdk.api.session.room.threads.FetchThreadsResult
+import org.matrix.android.sdk.api.session.room.threads.ThreadFilter
 import org.matrix.android.sdk.api.session.room.threads.model.ThreadSummaryUpdateType
 import org.matrix.android.sdk.internal.crypto.DefaultCryptoService
 import org.matrix.android.sdk.internal.database.helper.createOrUpdate
 import org.matrix.android.sdk.internal.database.model.RoomEntity
+import org.matrix.android.sdk.internal.database.model.threads.ThreadListPageEntity
 import org.matrix.android.sdk.internal.database.model.threads.ThreadSummaryEntity
+import org.matrix.android.sdk.internal.database.query.getOrCreate
 import org.matrix.android.sdk.internal.database.query.where
 import org.matrix.android.sdk.internal.di.SessionDatabase
 import org.matrix.android.sdk.internal.di.UserId
 import org.matrix.android.sdk.internal.network.GlobalErrorReceiver
 import org.matrix.android.sdk.internal.network.executeRequest
-import org.matrix.android.sdk.internal.session.filter.FilterFactory
 import org.matrix.android.sdk.internal.session.room.RoomAPI
-import org.matrix.android.sdk.internal.session.room.timeline.PaginationDirection
-import org.matrix.android.sdk.internal.session.room.timeline.PaginationResponse
 import org.matrix.android.sdk.internal.task.Task
 import org.matrix.android.sdk.internal.util.awaitTransaction
 import org.matrix.android.sdk.internal.util.time.Clock
-import timber.log.Timber
 import javax.inject.Inject
 
 /***
  * This class is responsible to Fetch all the thread in the current room,
  * To fetch all threads in a room, the /messages API is used with newly added filtering options.
  */
-internal interface FetchThreadSummariesTask : Task {
+internal interface FetchThreadSummariesTask : Task {
     data class Params(
             val roomId: String,
-            val from: String = "",
-            val limit: Int = 500,
-            val isUserParticipating: Boolean = true
+            val from: String? = null,
+            val limit: Int = 5,
+            val filter: ThreadFilter? = null,
     )
 }
 
@@ -59,39 +60,43 @@ internal class DefaultFetchThreadSummariesTask @Inject constructor(
         private val clock: Clock,
 ) : FetchThreadSummariesTask {
 
-    override suspend fun execute(params: FetchThreadSummariesTask.Params): Result {
-        val filter = FilterFactory.createThreadsFilter(
-                numberOfEvents = params.limit,
-                userId = if (params.isUserParticipating) userId else null
-        ).toJSONString()
-
-        val response = executeRequest(
-                globalErrorReceiver,
-                canRetry = true
-        ) {
-            roomAPI.getRoomMessagesFrom(params.roomId, params.from, PaginationDirection.BACKWARDS.value, params.limit, filter)
+    override suspend fun execute(params: FetchThreadSummariesTask.Params): FetchThreadsResult {
+        val response = executeRequest(globalErrorReceiver) {
+            roomAPI.getThreadsList(
+                    roomId = params.roomId,
+                    include = params.filter?.toString()?.lowercase(),
+                    from = params.from,
+                    limit = params.limit
+            )
         }
 
-        Timber.i("###THREADS DefaultFetchThreadSummariesTask Fetched size:${response.events.size} nextBatch:${response.end} ")
+        handleResponse(response, params)
 
-        return handleResponse(response, params)
+        return when {
+            response.nextBatch != null -> FetchThreadsResult.ShouldFetchMore(response.nextBatch)
+            else -> FetchThreadsResult.ReachedEnd
+        }
     }
 
     private suspend fun handleResponse(
-            response: PaginationResponse,
+            response: ThreadSummariesResponse,
             params: FetchThreadSummariesTask.Params
-    ): Result {
-        val rootThreadList = response.events
+    ) {
+        val rootThreadList = response.chunk
+
+        val threadSummaries = RealmList()
+
         monarchy.awaitTransaction { realm ->
             val roomEntity = RoomEntity.where(realm, roomId = params.roomId).findFirst() ?: return@awaitTransaction
 
             val roomMemberContentsByUser = HashMap()
+
             for (rootThreadEvent in rootThreadList) {
                 if (rootThreadEvent.eventId == null || rootThreadEvent.senderId == null || rootThreadEvent.type == null) {
                     continue
                 }
 
-                ThreadSummaryEntity.createOrUpdate(
+                val threadSummary = ThreadSummaryEntity.createOrUpdate(
                         threadSummaryType = ThreadSummaryUpdateType.REPLACE,
                         realm = realm,
                         roomId = params.roomId,
@@ -102,14 +107,16 @@ internal class DefaultFetchThreadSummariesTask @Inject constructor(
                         cryptoService = cryptoService,
                         currentTimeMillis = clock.epochMillis(),
                 )
+
+                threadSummaries.add(threadSummary)
             }
-        }
-        return Result.SUCCESS
-    }
 
-    enum class Result {
-        SHOULD_FETCH_MORE,
-        REACHED_END,
-        SUCCESS
+            val page = ThreadListPageEntity.getOrCreate(realm, params.roomId)
+            threadSummaries.forEach {
+                if (!page.threadSummaries.contains(it)) {
+                    page.threadSummaries.add(it)
+                }
+            }
+        }
     }
 }
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/threads/FetchThreadTimelineTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/threads/FetchThreadTimelineTask.kt
index 4cf6445920..1e9a785c80 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/threads/FetchThreadTimelineTask.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/threads/FetchThreadTimelineTask.kt
@@ -22,6 +22,7 @@ import org.matrix.android.sdk.api.session.crypto.MXCryptoError
 import org.matrix.android.sdk.api.session.crypto.model.OlmDecryptionResult
 import org.matrix.android.sdk.api.session.events.model.Event
 import org.matrix.android.sdk.api.session.events.model.EventType
+import org.matrix.android.sdk.api.session.events.model.RelationType
 import org.matrix.android.sdk.api.session.room.model.RoomMemberContent
 import org.matrix.android.sdk.api.session.room.send.SendState
 import org.matrix.android.sdk.internal.crypto.DefaultCryptoService
@@ -102,11 +103,12 @@ internal class DefaultFetchThreadTimelineTask @Inject constructor(
 
     override suspend fun execute(params: FetchThreadTimelineTask.Params): Result {
         val response = executeRequest(globalErrorReceiver) {
-            roomAPI.getThreadsRelations(
+            roomAPI.getRelations(
                     roomId = params.roomId,
                     eventId = params.rootThreadEventId,
+                    relationType = RelationType.THREAD,
                     from = params.from,
-                    limit = params.limit
+                    limit = params.limit,
             )
         }
 
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/threads/ThreadSummariesResponse.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/threads/ThreadSummariesResponse.kt
new file mode 100644
index 0000000000..d37a058ef6
--- /dev/null
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/relation/threads/ThreadSummariesResponse.kt
@@ -0,0 +1,27 @@
+/*
+ * Copyright 2022 The Matrix.org Foundation C.I.C.
+ *
+ * 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 org.matrix.android.sdk.internal.session.room.relation.threads
+
+import com.squareup.moshi.Json
+import com.squareup.moshi.JsonClass
+import org.matrix.android.sdk.api.session.events.model.Event
+
+@JsonClass(generateAdapter = true)
+internal data class ThreadSummariesResponse(
+        @Json(name = "chunk") val chunk: List,
+        @Json(name = "next_batch") val nextBatch: String?,
+        @Json(name = "prev_batch") val prevBatch: String?
+)
diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/LocalEchoEventFactory.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/LocalEchoEventFactory.kt
index 7d8605c2bd..8be6b26249 100644
--- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/LocalEchoEventFactory.kt
+++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/LocalEchoEventFactory.kt
@@ -181,7 +181,7 @@ internal class LocalEchoEventFactory @Inject constructor(
                 originServerTs = dummyOriginServerTs(),
                 senderId = userId,
                 eventId = localId,
-                type = EventType.POLL_START.first(),
+                type = EventType.POLL_START.unstable,
                 content = newContent.toContent().plus(additionalContent.orEmpty())
         )
     }
@@ -206,7 +206,7 @@ internal class LocalEchoEventFactory @Inject constructor(
                 originServerTs = dummyOriginServerTs(),
                 senderId = userId,
                 eventId = localId,
-                type = EventType.POLL_RESPONSE.first(),
+                type = EventType.POLL_RESPONSE.unstable,
                 content = content.toContent().plus(additionalContent.orEmpty()),
                 unsignedData = UnsignedData(age = null, transactionId = localId)
         )
@@ -226,7 +226,7 @@ internal class LocalEchoEventFactory @Inject constructor(
                 originServerTs = dummyOriginServerTs(),
                 senderId = userId,
                 eventId = localId,
-                type = EventType.POLL_START.first(),
+                type = EventType.POLL_START.unstable,
                 content = content.toContent().plus(additionalContent.orEmpty()),
                 unsignedData = UnsignedData(age = null, transactionId = localId)
         )
@@ -249,7 +249,7 @@ internal class LocalEchoEventFactory @Inject constructor(
                 originServerTs = dummyOriginServerTs(),
                 senderId = userId,
                 eventId = localId,
-                type = EventType.POLL_END.first(),
+                type = EventType.POLL_END.unstable,
                 content = content.toContent().plus(additionalContent.orEmpty()),
                 unsignedData = UnsignedData(age = null, transactionId = localId)
         )
@@ -300,7 +300,7 @@ internal class LocalEchoEventFactory @Inject constructor(
                 originServerTs = dummyOriginServerTs(),
                 senderId = userId,
                 eventId = localId,
-                type = EventType.BEACON_LOCATION_DATA.first(),
+                type = EventType.BEACON_LOCATION_DATA.unstable,
                 content = content.toContent().plus(additionalContent.orEmpty()),
                 unsignedData = UnsignedData(age = null, transactionId = localId)
         )
@@ -804,20 +804,12 @@ internal class LocalEchoEventFactory @Inject constructor(
             additionalContent: Content? = null,
     ): Event {
         val messageContent = quotedEvent.getLastMessageContent()
-        val textMsg = if (messageContent is MessageContentWithFormattedBody) {
-            messageContent.formattedBody
-        } else {
-            messageContent?.body
-        }
-        val quoteText = legacyRiotQuoteText(textMsg, text)
-        val quoteFormattedText = "
$textMsg
$formattedText" - + val formattedQuotedText = (messageContent as? MessageContentWithFormattedBody)?.formattedBody + val textContent = createQuoteTextContent(messageContent?.body, formattedQuotedText, text, formattedText, autoMarkdown) return if (rootThreadEventId != null) { createMessageEvent( roomId, - markdownParser - .parse(quoteText, force = true, advanced = autoMarkdown).copy(formattedText = quoteFormattedText) - .toThreadTextContent( + textContent.toThreadTextContent( rootThreadEventId = rootThreadEventId, latestThreadEventId = localEchoRepository.getLatestThreadEvent(rootThreadEventId), msgType = MessageType.MSGTYPE_TEXT @@ -827,31 +819,54 @@ internal class LocalEchoEventFactory @Inject constructor( } else { createFormattedTextEvent( roomId, - markdownParser.parse(quoteText, force = true, advanced = autoMarkdown).copy(formattedText = quoteFormattedText), + textContent, MessageType.MSGTYPE_TEXT, additionalContent, ) } } - private fun legacyRiotQuoteText(quotedText: String?, myText: String): String { - val messageParagraphs = quotedText?.split("\n\n".toRegex())?.dropLastWhile { it.isEmpty() }?.toTypedArray() - return buildString { - if (messageParagraphs != null) { - for (i in messageParagraphs.indices) { - if (messageParagraphs[i].isNotBlank()) { - append("> ") - append(messageParagraphs[i]) - } - - if (i != messageParagraphs.lastIndex) { - append("\n\n") - } + private fun createQuoteTextContent( + quotedText: String?, + formattedQuotedText: String?, + text: String, + formattedText: String?, + autoMarkdown: Boolean + ): TextContent { + val currentFormattedText = formattedText ?: if (autoMarkdown) { + val parsed = markdownParser.parse(text, force = true, advanced = true) + // If formattedText == text, formattedText is returned as null + parsed.formattedText ?: parsed.text + } else { + text + } + val processedFormattedQuotedText = formattedQuotedText ?: quotedText + + val plainTextBody = buildString { + val plainMessageParagraphs = quotedText?.split("\n\n".toRegex())?.dropLastWhile { it.isEmpty() }?.toTypedArray().orEmpty() + plainMessageParagraphs.forEachIndexed { index, paragraph -> + if (paragraph.isNotBlank()) { + append("> ") + append(paragraph) + } + + if (index != plainMessageParagraphs.lastIndex) { + append("\n\n") } } append("\n\n") - append(myText) + append(text) + } + val formattedTextBody = buildString { + if (!processedFormattedQuotedText.isNullOrBlank()) { + append("
") + append(processedFormattedQuotedText) + append("
") + } + append("
") + append(currentFormattedText) } + return TextContent(plainTextBody, formattedTextBody) } companion object { diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/summary/RoomSummaryEventsHelper.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/summary/RoomSummaryEventsHelper.kt index 7437a686da..a68ae620dc 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/summary/RoomSummaryEventsHelper.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/summary/RoomSummaryEventsHelper.kt @@ -17,17 +17,23 @@ package org.matrix.android.sdk.internal.session.room.summary import io.realm.Realm +import org.matrix.android.sdk.api.MatrixConfiguration import org.matrix.android.sdk.api.session.room.summary.RoomSummaryConstants import org.matrix.android.sdk.api.session.room.timeline.EventTypeFilter import org.matrix.android.sdk.api.session.room.timeline.TimelineEventFilters import org.matrix.android.sdk.internal.database.model.TimelineEventEntity import org.matrix.android.sdk.internal.database.query.latestEvent +import javax.inject.Inject -internal object RoomSummaryEventsHelper { +internal class RoomSummaryEventsHelper @Inject constructor( + matrixConfiguration: MatrixConfiguration, +) { private val previewFilters = TimelineEventFilters( filterTypes = true, - allowedTypes = RoomSummaryConstants.PREVIEWABLE_TYPES.map { EventTypeFilter(eventType = it, stateKey = null) }, + allowedTypes = RoomSummaryConstants.PREVIEWABLE_TYPES + .plus(matrixConfiguration.customEventTypesProvider?.customPreviewableEventTypes.orEmpty()) + .map { EventTypeFilter(eventType = it, stateKey = null) }, filterUseless = true, filterRedacted = false, filterEdits = true diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/summary/RoomSummaryUpdater.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/summary/RoomSummaryUpdater.kt index 29eb0b537c..cafcb8b70f 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/summary/RoomSummaryUpdater.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/summary/RoomSummaryUpdater.kt @@ -26,6 +26,7 @@ import org.matrix.android.sdk.api.extensions.tryOrNull import org.matrix.android.sdk.api.session.events.model.EventType import org.matrix.android.sdk.api.session.events.model.content.EncryptionEventContent import org.matrix.android.sdk.api.session.events.model.toModel +import org.matrix.android.sdk.api.session.homeserver.HomeServerCapabilitiesService import org.matrix.android.sdk.api.session.room.accountdata.RoomAccountDataTypes import org.matrix.android.sdk.api.session.room.model.Membership import org.matrix.android.sdk.api.session.room.model.PowerLevelsContent @@ -77,13 +78,15 @@ internal class RoomSummaryUpdater @Inject constructor( private val roomAvatarResolver: RoomAvatarResolver, private val eventDecryptor: EventDecryptor, private val crossSigningService: DefaultCrossSigningService, - private val roomAccountDataDataSource: RoomAccountDataDataSource + private val roomAccountDataDataSource: RoomAccountDataDataSource, + private val homeServerCapabilitiesService: HomeServerCapabilitiesService, + private val roomSummaryEventsHelper: RoomSummaryEventsHelper, ) { fun refreshLatestPreviewContent(realm: Realm, roomId: String) { val roomSummaryEntity = RoomSummaryEntity.getOrNull(realm, roomId) if (roomSummaryEntity != null) { - val latestPreviewableEvent = RoomSummaryEventsHelper.getLatestPreviewableEvent(realm, roomId) + val latestPreviewableEvent = roomSummaryEventsHelper.getLatestPreviewableEvent(realm, roomId) latestPreviewableEvent?.attemptToDecrypt() } } @@ -146,7 +149,7 @@ internal class RoomSummaryUpdater @Inject constructor( val encryptionEvent = CurrentStateEventEntity.getOrNull(realm, roomId, type = EventType.STATE_ROOM_ENCRYPTION, stateKey = "")?.root Timber.d("## CRYPTO: currentEncryptionEvent is $encryptionEvent") - val latestPreviewableEvent = RoomSummaryEventsHelper.getLatestPreviewableEvent(realm, roomId) + val latestPreviewableEvent = roomSummaryEventsHelper.getLatestPreviewableEvent(realm, roomId) val lastActivityFromEvent = latestPreviewableEvent?.root?.originServerTs if (lastActivityFromEvent != null) { @@ -154,9 +157,11 @@ internal class RoomSummaryUpdater @Inject constructor( latestPreviewableEvent.attemptToDecrypt() } + val shouldCheckIfReadInEventsThread = homeServerCapabilitiesService.getHomeServerCapabilities().canUseThreadReadReceiptsAndNotifications + roomSummaryEntity.hasUnreadMessages = roomSummaryEntity.notificationCount > 0 || // avoid this call if we are sure there are unread events - latestPreviewableEvent?.let { !isEventRead(realm.configuration, userId, roomId, it.eventId) } ?: false + latestPreviewableEvent?.let { !isEventRead(realm.configuration, userId, roomId, it.eventId, shouldCheckIfReadInEventsThread) } ?: false roomSummaryEntity.setDisplayName(roomDisplayNameResolver.resolve(realm, roomId)) roomSummaryEntity.avatarUrl = roomAvatarResolver.resolve(realm, roomId) @@ -195,6 +200,11 @@ internal class RoomSummaryUpdater @Inject constructor( roomSummaryEntity.otherMemberIds.clear() roomSummaryEntity.otherMemberIds.addAll(otherRoomMembers) + if (roomSummary?.joinedMembersCount == null) { + // in case m.joined_member_count from sync summary was null? + // better to use what we know + roomSummaryEntity.joinedMembersCount = otherRoomMembers.size + 1 + } if (roomSummaryEntity.isEncrypted && otherRoomMembers.isNotEmpty()) { if (aggregator == null) { // Do it now @@ -231,7 +241,7 @@ internal class RoomSummaryUpdater @Inject constructor( fun updateSendingInformation(realm: Realm, roomId: String) { val roomSummaryEntity = RoomSummaryEntity.getOrCreate(realm, roomId) roomSummaryEntity.updateHasFailedSending() - roomSummaryEntity.latestPreviewableEvent = RoomSummaryEventsHelper.getLatestPreviewableEvent(realm, roomId) + roomSummaryEntity.latestPreviewableEvent = roomSummaryEventsHelper.getLatestPreviewableEvent(realm, roomId) } /** diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/threads/DefaultThreadsService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/threads/DefaultThreadsService.kt index 6c6d6368d1..63756811f9 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/threads/DefaultThreadsService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/threads/DefaultThreadsService.kt @@ -16,32 +16,39 @@ package org.matrix.android.sdk.internal.session.room.threads -import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.paging.LivePagedListBuilder +import androidx.paging.PagedList import com.zhuinden.monarchy.Monarchy import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject import io.realm.Realm +import io.realm.Sort +import io.realm.kotlin.where +import org.matrix.android.sdk.api.session.room.ResultBoundaries +import org.matrix.android.sdk.api.session.room.threads.FetchThreadsResult +import org.matrix.android.sdk.api.session.room.threads.ThreadFilter +import org.matrix.android.sdk.api.session.room.threads.ThreadLivePageResult import org.matrix.android.sdk.api.session.room.threads.ThreadsService import org.matrix.android.sdk.api.session.room.threads.model.ThreadSummary import org.matrix.android.sdk.internal.database.helper.enhanceWithEditions import org.matrix.android.sdk.internal.database.helper.findAllThreadsForRoomId import org.matrix.android.sdk.internal.database.mapper.ThreadSummaryMapper -import org.matrix.android.sdk.internal.database.mapper.TimelineEventMapper +import org.matrix.android.sdk.internal.database.model.threads.ThreadListPageEntity import org.matrix.android.sdk.internal.database.model.threads.ThreadSummaryEntity +import org.matrix.android.sdk.internal.database.model.threads.ThreadSummaryEntityFields import org.matrix.android.sdk.internal.di.SessionDatabase -import org.matrix.android.sdk.internal.di.UserId import org.matrix.android.sdk.internal.session.room.relation.threads.FetchThreadSummariesTask import org.matrix.android.sdk.internal.session.room.relation.threads.FetchThreadTimelineTask +import org.matrix.android.sdk.internal.util.awaitTransaction internal class DefaultThreadsService @AssistedInject constructor( @Assisted private val roomId: String, - @UserId private val userId: String, private val fetchThreadTimelineTask: FetchThreadTimelineTask, - private val fetchThreadSummariesTask: FetchThreadSummariesTask, @SessionDatabase private val monarchy: Monarchy, - private val timelineEventMapper: TimelineEventMapper, - private val threadSummaryMapper: ThreadSummaryMapper + private val threadSummaryMapper: ThreadSummaryMapper, + private val fetchThreadSummariesTask: FetchThreadSummariesTask, ) : ThreadsService { @AssistedFactory @@ -49,16 +56,58 @@ internal class DefaultThreadsService @AssistedInject constructor( fun create(roomId: String): DefaultThreadsService } - override fun getAllThreadSummariesLive(): LiveData> { - return monarchy.findAllMappedWithChanges( - { ThreadSummaryEntity.findAllThreadsForRoomId(it, roomId = roomId) }, - { - threadSummaryMapper.map(it) + override suspend fun getPagedThreadsList(userParticipating: Boolean, pagedListConfig: PagedList.Config): ThreadLivePageResult { + monarchy.awaitTransaction { realm -> + realm.where().findAll().deleteAllFromRealm() + } + + val realmDataSourceFactory = monarchy.createDataSourceFactory { realm -> + realm + .where().equalTo(ThreadSummaryEntityFields.PAGE.ROOM_ID, roomId) + .sort(ThreadSummaryEntityFields.LATEST_THREAD_EVENT_ENTITY.ORIGIN_SERVER_TS, Sort.DESCENDING) + } + + val dataSourceFactory = realmDataSourceFactory.map { + threadSummaryMapper.map(it) + } + + val boundaries = MutableLiveData(ResultBoundaries()) + + val builder = LivePagedListBuilder(dataSourceFactory, pagedListConfig).also { + it.setBoundaryCallback(object : PagedList.BoundaryCallback() { + override fun onItemAtEndLoaded(itemAtEnd: ThreadSummary) { + boundaries.postValue(boundaries.value?.copy(endLoaded = true)) + } + + override fun onItemAtFrontLoaded(itemAtFront: ThreadSummary) { + boundaries.postValue(boundaries.value?.copy(frontLoaded = true)) + } + + override fun onZeroItemsLoaded() { + boundaries.postValue(boundaries.value?.copy(zeroItemLoaded = true)) } + }) + } + + val livePagedList = monarchy.findAllPagedWithChanges( + realmDataSourceFactory, + builder ) + return ThreadLivePageResult(livePagedList, boundaries) } - override fun getAllThreadSummaries(): List { + override suspend fun fetchThreadList(nextBatchId: String?, limit: Int, filter: ThreadFilter): FetchThreadsResult { + return fetchThreadSummariesTask.execute( + FetchThreadSummariesTask.Params( + roomId = roomId, + from = nextBatchId, + limit = limit, + filter = filter + ) + ) + } + + override suspend fun getAllThreadSummaries(): List { return monarchy.fetchAllMappedSync( { ThreadSummaryEntity.findAllThreadsForRoomId(it, roomId = roomId) }, { threadSummaryMapper.map(it) } @@ -81,12 +130,4 @@ internal class DefaultThreadsService @AssistedInject constructor( ) ) } - - override suspend fun fetchThreadSummaries() { - fetchThreadSummariesTask.execute( - FetchThreadSummariesTask.Params( - roomId = roomId - ) - ) - } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/DefaultTimeline.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/DefaultTimeline.kt index c380ccf14f..0854cc5cf4 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/DefaultTimeline.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/DefaultTimeline.kt @@ -411,7 +411,7 @@ internal class DefaultTimeline( private fun ensureReadReceiptAreLoaded(realm: Realm) { readReceiptHandler.getContentFromInitSync(roomId) ?.also { - Timber.w("INIT_SYNC Insert when opening timeline RR for room $roomId") + Timber.d("INIT_SYNC Insert when opening timeline RR for room $roomId") } ?.let { readReceiptContent -> realm.executeTransactionAsync { diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/FetchTokenAndPaginateTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/FetchTokenAndPaginateTask.kt index 96646b42ed..9d8d8ecbf1 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/FetchTokenAndPaginateTask.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/FetchTokenAndPaginateTask.kt @@ -47,7 +47,7 @@ internal class DefaultFetchTokenAndPaginateTask @Inject constructor( ) : FetchTokenAndPaginateTask { override suspend fun execute(params: FetchTokenAndPaginateTask.Params): TokenChunkEventPersistor.Result { - val filter = filterRepository.getRoomFilter() + val filter = filterRepository.getRoomFilterBody() val response = executeRequest(globalErrorReceiver) { roomAPI.getContextOfEvent(params.roomId, params.lastKnownEventId, 0, filter) } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/GetContextOfEventTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/GetContextOfEventTask.kt index 015e55f070..c3911dfa2c 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/GetContextOfEventTask.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/GetContextOfEventTask.kt @@ -39,7 +39,7 @@ internal class DefaultGetContextOfEventTask @Inject constructor( ) : GetContextOfEventTask { override suspend fun execute(params: GetContextOfEventTask.Params): TokenChunkEventPersistor.Result { - val filter = filterRepository.getRoomFilter() + val filter = filterRepository.getRoomFilterBody() val response = executeRequest(globalErrorReceiver) { // We are limiting the response to the event with eventId to be sure we don't have any issue with potential merging process. roomAPI.getContextOfEvent(params.roomId, params.eventId, 0, filter) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/GetEventTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/GetEventTask.kt index e0751865ad..3707205aef 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/GetEventTask.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/GetEventTask.kt @@ -16,8 +16,6 @@ package org.matrix.android.sdk.internal.session.room.timeline -import org.matrix.android.sdk.api.extensions.tryOrNull -import org.matrix.android.sdk.api.session.crypto.model.OlmDecryptionResult import org.matrix.android.sdk.api.session.events.model.Event import org.matrix.android.sdk.internal.crypto.EventDecryptor import org.matrix.android.sdk.internal.network.GlobalErrorReceiver @@ -48,18 +46,7 @@ internal class DefaultGetEventTask @Inject constructor( // Try to decrypt the Event if (event.isEncrypted()) { - tryOrNull(message = "Unable to decrypt the event") { - eventDecryptor.decryptEvent(event, "") - } - ?.let { result -> - event.mxDecryptionResult = OlmDecryptionResult( - payload = result.clearEvent, - senderKey = result.senderCurve25519Key, - keysClaimed = result.claimedEd25519Key?.let { mapOf("ed25519" to it) }, - forwardingCurve25519KeyChain = result.forwardingCurve25519KeyChain, - isSafe = result.isSafe - ) - } + eventDecryptor.decryptEventAndSaveResult(event, timeline = "") } event.ageLocalTs = clock.epochMillis() - (event.unsignedData?.age ?: 0) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/PaginationTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/PaginationTask.kt index 8aeccb66c8..1a7b1cdac4 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/PaginationTask.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/timeline/PaginationTask.kt @@ -41,7 +41,7 @@ internal class DefaultPaginationTask @Inject constructor( ) : PaginationTask { override suspend fun execute(params: PaginationTask.Params): TokenChunkEventPersistor.Result { - val filter = filterRepository.getRoomFilter() + val filter = filterRepository.getRoomFilterBody() val chunk = executeRequest( globalErrorReceiver, canRetry = true diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/tombstone/RoomTombstoneEventProcessor.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/tombstone/RoomTombstoneEventProcessor.kt index 2b404775f0..3684bec167 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/tombstone/RoomTombstoneEventProcessor.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/tombstone/RoomTombstoneEventProcessor.kt @@ -30,7 +30,7 @@ import javax.inject.Inject internal class RoomTombstoneEventProcessor @Inject constructor() : EventInsertLiveProcessor { - override suspend fun process(realm: Realm, event: Event) { + override fun process(realm: Realm, event: Event) { if (event.roomId == null) return val createRoomContent = event.getClearContent().toModel() if (createRoomContent?.replacementRoomId == null) return diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/SyncResponseHandler.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/SyncResponseHandler.kt index 05216d1de1..05d50d9595 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/SyncResponseHandler.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/SyncResponseHandler.kt @@ -17,6 +17,11 @@ package org.matrix.android.sdk.internal.session.sync import com.zhuinden.monarchy.Monarchy +import io.realm.Realm +import org.matrix.android.sdk.api.MatrixConfiguration +import org.matrix.android.sdk.api.extensions.measureMetric +import org.matrix.android.sdk.api.extensions.measureSpan +import org.matrix.android.sdk.api.metrics.SyncDurationMetricPlugin import org.matrix.android.sdk.api.session.pushrules.PushRuleService import org.matrix.android.sdk.api.session.pushrules.RuleScope import org.matrix.android.sdk.api.session.sync.InitialSyncStep @@ -52,9 +57,12 @@ internal class SyncResponseHandler @Inject constructor( private val tokenStore: SyncTokenStore, private val processEventForPushTask: ProcessEventForPushTask, private val pushRuleService: PushRuleService, - private val presenceSyncHandler: PresenceSyncHandler + private val presenceSyncHandler: PresenceSyncHandler, + matrixConfiguration: MatrixConfiguration, ) { + private val relevantPlugins = matrixConfiguration.metricPlugins.filterIsInstance() + suspend fun handleResponse( syncResponse: SyncResponse, fromToken: String?, @@ -63,39 +71,91 @@ internal class SyncResponseHandler @Inject constructor( val isInitialSync = fromToken == null Timber.v("Start handling sync, is InitialSync: $isInitialSync") - measureTimeMillis { - if (!cryptoService.isStarted()) { - Timber.v("Should start cryptoService") - cryptoService.start() - } - cryptoService.onSyncWillProcess(isInitialSync) - }.also { - Timber.v("Finish handling start cryptoService in $it ms") + relevantPlugins.measureMetric { + startCryptoService(isInitialSync) + + // Handle the to device events before the room ones + // to ensure to decrypt them properly + handleToDevice(syncResponse, reporter) + + val aggregator = SyncResponsePostTreatmentAggregator() + + // Prerequisite for thread events handling in RoomSyncHandler + // Disabled due to the new fallback + // if (!lightweightSettingsStorage.areThreadMessagesEnabled()) { + // threadsAwarenessHandler.fetchRootThreadEventsIfNeeded(syncResponse) + // } + + startMonarchyTransaction(syncResponse, isInitialSync, reporter, aggregator) + + aggregateSyncResponse(aggregator) + + postTreatmentSyncResponse(syncResponse, isInitialSync) + + markCryptoSyncCompleted(syncResponse) + + handlePostSync() + + Timber.v("On sync completed") } + } - // Handle the to device events before the room ones - // to ensure to decrypt them properly - measureTimeMillis { - Timber.v("Handle toDevice") - reportSubtask(reporter, InitialSyncStep.ImportingAccountCrypto, 100, 0.1f) { - if (syncResponse.toDevice != null) { - cryptoSyncHandler.handleToDevice(syncResponse.toDevice, reporter) + private fun startCryptoService(isInitialSync: Boolean) { + relevantPlugins.measureSpan("task", "start_crypto_service") { + measureTimeMillis { + if (!cryptoService.isStarted()) { + Timber.v("Should start cryptoService") + cryptoService.start() } + cryptoService.onSyncWillProcess(isInitialSync) + }.also { + Timber.v("Finish handling start cryptoService in $it ms") } - }.also { - Timber.v("Finish handling toDevice in $it ms") } - val aggregator = SyncResponsePostTreatmentAggregator() + } - // Prerequisite for thread events handling in RoomSyncHandler -// Disabled due to the new fallback -// if (!lightweightSettingsStorage.areThreadMessagesEnabled()) { -// threadsAwarenessHandler.fetchRootThreadEventsIfNeeded(syncResponse) -// } + private suspend fun handleToDevice(syncResponse: SyncResponse, reporter: ProgressReporter?) { + relevantPlugins.measureSpan("task", "handle_to_device") { + measureTimeMillis { + Timber.v("Handle toDevice") + reportSubtask(reporter, InitialSyncStep.ImportingAccountCrypto, 100, 0.1f) { + if (syncResponse.toDevice != null) { + cryptoSyncHandler.handleToDevice(syncResponse.toDevice, reporter) + } + } + }.also { + Timber.v("Finish handling toDevice in $it ms") + } + } + } + private suspend fun startMonarchyTransaction( + syncResponse: SyncResponse, + isInitialSync: Boolean, + reporter: ProgressReporter?, + aggregator: SyncResponsePostTreatmentAggregator + ) { // Start one big transaction - monarchy.awaitTransaction { realm -> - // IMPORTANT nothing should be suspend here as we are accessing the realm instance (thread local) + relevantPlugins.measureSpan("task", "monarchy_transaction") { + monarchy.awaitTransaction { realm -> + // IMPORTANT nothing should be suspend here as we are accessing the realm instance (thread local) + handleRooms(reporter, syncResponse, realm, isInitialSync, aggregator) + handleAccountData(reporter, realm, syncResponse) + handlePresence(realm, syncResponse) + + tokenStore.saveToken(realm, syncResponse.nextBatch) + } + } + } + + private fun handleRooms( + reporter: ProgressReporter?, + syncResponse: SyncResponse, + realm: Realm, + isInitialSync: Boolean, + aggregator: SyncResponsePostTreatmentAggregator + ) { + relevantPlugins.measureSpan("task", "handle_rooms") { measureTimeMillis { Timber.v("Handle rooms") reportSubtask(reporter, InitialSyncStep.ImportingAccountRoom, 1, 0.8f) { @@ -106,7 +166,11 @@ internal class SyncResponseHandler @Inject constructor( }.also { Timber.v("Finish handling rooms in $it ms") } + } + } + private fun handleAccountData(reporter: ProgressReporter?, realm: Realm, syncResponse: SyncResponse) { + relevantPlugins.measureSpan("task", "handle_account_data") { measureTimeMillis { reportSubtask(reporter, InitialSyncStep.ImportingAccountData, 1, 0.1f) { Timber.v("Handle accountData") @@ -115,44 +179,59 @@ internal class SyncResponseHandler @Inject constructor( }.also { Timber.v("Finish handling accountData in $it ms") } + } + } + private fun handlePresence(realm: Realm, syncResponse: SyncResponse) { + relevantPlugins.measureSpan("task", "handle_presence") { measureTimeMillis { Timber.v("Handle Presence") presenceSyncHandler.handle(realm, syncResponse.presence) }.also { Timber.v("Finish handling Presence in $it ms") } - tokenStore.saveToken(realm, syncResponse.nextBatch) } + } - // Everything else we need to do outside the transaction - measureTimeMillis { - aggregatorHandler.handle(aggregator) - }.also { - Timber.v("Aggregator management took $it ms") + private suspend fun aggregateSyncResponse(aggregator: SyncResponsePostTreatmentAggregator) { + relevantPlugins.measureSpan("task", "aggregator_management") { + // Everything else we need to do outside the transaction + measureTimeMillis { + aggregatorHandler.handle(aggregator) + }.also { + Timber.v("Aggregator management took $it ms") + } } + } - measureTimeMillis { - syncResponse.rooms?.let { - checkPushRules(it, isInitialSync) - userAccountDataSyncHandler.synchronizeWithServerIfNeeded(it.invite) - dispatchInvitedRoom(it) + private suspend fun postTreatmentSyncResponse(syncResponse: SyncResponse, isInitialSync: Boolean) { + relevantPlugins.measureSpan("task", "sync_response_post_treatment") { + measureTimeMillis { + syncResponse.rooms?.let { + checkPushRules(it, isInitialSync) + userAccountDataSyncHandler.synchronizeWithServerIfNeeded(it.invite) + dispatchInvitedRoom(it) + } + }.also { + Timber.v("SyncResponse.rooms post treatment took $it ms") } - }.also { - Timber.v("SyncResponse.rooms post treatment took $it ms") } + } - measureTimeMillis { - cryptoSyncHandler.onSyncCompleted(syncResponse) - }.also { - Timber.v("cryptoSyncHandler.onSyncCompleted took $it ms") + private fun markCryptoSyncCompleted(syncResponse: SyncResponse) { + relevantPlugins.measureSpan("task", "crypto_sync_handler_onSyncCompleted") { + measureTimeMillis { + cryptoSyncHandler.onSyncCompleted(syncResponse) + }.also { + Timber.v("cryptoSyncHandler.onSyncCompleted took $it ms") + } } + } - // post sync stuffs + private fun handlePostSync() { monarchy.writeAsync { roomSyncHandler.postSyncSpaceHierarchyHandle(it) } - Timber.v("On sync completed") } private fun dispatchInvitedRoom(roomsSyncResponse: RoomsSyncResponse) { diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/SyncTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/SyncTask.kt index bc1a69769d..8a287fb0b4 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/SyncTask.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/SyncTask.kt @@ -36,7 +36,7 @@ import org.matrix.android.sdk.internal.network.executeRequest import org.matrix.android.sdk.internal.network.toFailure import org.matrix.android.sdk.internal.session.SessionListeners import org.matrix.android.sdk.internal.session.dispatchTo -import org.matrix.android.sdk.internal.session.filter.FilterRepository +import org.matrix.android.sdk.internal.session.filter.GetCurrentFilterTask import org.matrix.android.sdk.internal.session.homeserver.GetHomeServerCapabilitiesTask import org.matrix.android.sdk.internal.session.sync.parsing.InitialSyncResponseParser import org.matrix.android.sdk.internal.session.user.UserStore @@ -64,11 +64,9 @@ internal interface SyncTask : Task { internal class DefaultSyncTask @Inject constructor( private val syncAPI: SyncAPI, @UserId private val userId: String, - private val filterRepository: FilterRepository, private val syncResponseHandler: SyncResponseHandler, private val syncRequestStateTracker: SyncRequestStateTracker, private val syncTokenStore: SyncTokenStore, - private val getHomeServerCapabilitiesTask: GetHomeServerCapabilitiesTask, private val userStore: UserStore, private val session: Session, private val sessionListeners: SessionListeners, @@ -79,6 +77,8 @@ internal class DefaultSyncTask @Inject constructor( private val syncResponseParser: InitialSyncResponseParser, private val roomSyncEphemeralTemporaryStore: RoomSyncEphemeralTemporaryStore, private val clock: Clock, + private val getHomeServerCapabilitiesTask: GetHomeServerCapabilitiesTask, + private val getCurrentFilterTask: GetCurrentFilterTask ) : SyncTask { private val workingDir = File(fileDirectory, "is") @@ -100,8 +100,13 @@ internal class DefaultSyncTask @Inject constructor( requestParams["since"] = token timeout = params.timeout } + + // Maybe refresh the homeserver capabilities data we know + getHomeServerCapabilitiesTask.execute(GetHomeServerCapabilitiesTask.Params(forceRefresh = false)) + val filter = getCurrentFilterTask.execute(Unit) + requestParams["timeout"] = timeout.toString() - requestParams["filter"] = filterRepository.getFilter() + requestParams["filter"] = filter params.presence?.let { requestParams["set_presence"] = it.value } val isInitialSync = token == null @@ -115,8 +120,6 @@ internal class DefaultSyncTask @Inject constructor( ) syncRequestStateTracker.startRoot(InitialSyncStep.ImportingAccount, 100) } - // Maybe refresh the homeserver capabilities data we know - getHomeServerCapabilitiesTask.execute(GetHomeServerCapabilitiesTask.Params(forceRefresh = false)) val readTimeOut = (params.timeout + TIMEOUT_MARGIN).coerceAtLeast(TimeOutInterceptor.DEFAULT_LONG_TIMEOUT) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/CryptoSyncHandler.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/CryptoSyncHandler.kt index b2fe12ebc3..551db52dbd 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/CryptoSyncHandler.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/CryptoSyncHandler.kt @@ -29,6 +29,7 @@ import org.matrix.android.sdk.api.session.room.model.message.MessageContent import org.matrix.android.sdk.api.session.sync.model.SyncResponse import org.matrix.android.sdk.api.session.sync.model.ToDeviceSyncResponse import org.matrix.android.sdk.internal.crypto.DefaultCryptoService +import org.matrix.android.sdk.internal.crypto.tasks.toDeviceTracingId import org.matrix.android.sdk.internal.crypto.verification.DefaultVerificationService import org.matrix.android.sdk.internal.session.sync.ProgressReporter import timber.log.Timber @@ -48,12 +49,14 @@ internal class CryptoSyncHandler @Inject constructor( ?.forEachIndexed { index, event -> progressReporter?.reportProgress(index * 100F / total) // Decrypt event if necessary - Timber.tag(loggerTag.value).i("To device event from ${event.senderId} of type:${event.type}") + Timber.tag(loggerTag.value).d("To device event msgid:${event.toDeviceTracingId()}") decryptToDeviceEvent(event, null) + if (event.getClearType() == EventType.MESSAGE && event.getClearContent()?.toModel()?.msgType == "m.bad.encrypted") { Timber.tag(loggerTag.value).e("handleToDeviceEvent() : Warning: Unable to decrypt to-device event : ${event.content}") } else { + Timber.tag(loggerTag.value).d("received to-device ${event.getClearType()} from:${event.senderId} msgid:${event.toDeviceTracingId()}") verificationService.onToDeviceEvent(event) cryptoService.onToDeviceEvent(event) } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/UserAccountDataSyncHandler.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/UserAccountDataSyncHandler.kt index 0f296ded5d..92ebb41ad9 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/UserAccountDataSyncHandler.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/UserAccountDataSyncHandler.kt @@ -45,6 +45,7 @@ import org.matrix.android.sdk.internal.database.model.TimelineEventEntity import org.matrix.android.sdk.internal.database.model.UserAccountDataEntity import org.matrix.android.sdk.internal.database.model.UserAccountDataEntityFields import org.matrix.android.sdk.internal.database.model.deleteOnCascade +import org.matrix.android.sdk.internal.database.query.delete import org.matrix.android.sdk.internal.database.query.findAllFrom import org.matrix.android.sdk.internal.database.query.getDirectRooms import org.matrix.android.sdk.internal.database.query.getOrCreate @@ -94,7 +95,7 @@ internal class UserAccountDataSyncHandler @Inject constructor( // If we get some direct chat invites, we synchronize the user account data including those. suspend fun synchronizeWithServerIfNeeded(invites: Map) { - if (invites.isNullOrEmpty()) return + if (invites.isEmpty()) return val directChats = directChatsHelper.getLocalDirectMessages().toMutable() var hasUpdate = false monarchy.doWithRealm { realm -> @@ -252,9 +253,17 @@ internal class UserAccountDataSyncHandler @Inject constructor( } fun handleGenericAccountData(realm: Realm, type: String, content: Content?) { + if (content.isNullOrEmpty()) { + // This is a response for a deleted account data according to + // https://github.com/ShadowJonathan/matrix-doc/blob/account-data-delete/proposals/3391-account-data-delete.md#sync + UserAccountDataEntity.delete(realm, type) + return + } + val existing = realm.where() .equalTo(UserAccountDataEntityFields.TYPE, type) .findFirst() + if (existing != null) { // Update current value existing.contentStr = ContentMapper.map(content) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/room/ReadReceiptHandler.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/room/ReadReceiptHandler.kt index 7329611a01..7f12ce653c 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/room/ReadReceiptHandler.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/room/ReadReceiptHandler.kt @@ -33,10 +33,11 @@ import javax.inject.Inject // value : dict key $UserId // value dict key ts // dict value ts value -internal typealias ReadReceiptContent = Map>>> +internal typealias ReadReceiptContent = Map>>> private const val READ_KEY = "m.read" private const val TIMESTAMP_KEY = "ts" +private const val THREAD_ID_KEY = "thread_id" internal class ReadReceiptHandler @Inject constructor( private val roomSyncEphemeralTemporaryStore: RoomSyncEphemeralTemporaryStore @@ -47,14 +48,19 @@ internal class ReadReceiptHandler @Inject constructor( fun createContent( userId: String, eventId: String, + threadId: String?, currentTimeMillis: Long ): ReadReceiptContent { + val userReadReceipt = mutableMapOf( + TIMESTAMP_KEY to currentTimeMillis.toDouble(), + ) + threadId?.let { + userReadReceipt.put(THREAD_ID_KEY, threadId) + } return mapOf( eventId to mapOf( READ_KEY to mapOf( - userId to mapOf( - TIMESTAMP_KEY to currentTimeMillis.toDouble() - ) + userId to userReadReceipt ) ) ) @@ -98,8 +104,9 @@ internal class ReadReceiptHandler @Inject constructor( val readReceiptsSummary = ReadReceiptsSummaryEntity(eventId = eventId, roomId = roomId) for ((userId, paramsDict) in userIdsDict) { - val ts = paramsDict[TIMESTAMP_KEY] ?: 0.0 - val receiptEntity = ReadReceiptEntity.createUnmanaged(roomId, eventId, userId, ts) + val ts = paramsDict[TIMESTAMP_KEY] as? Double ?: 0.0 + val threadId = paramsDict[THREAD_ID_KEY] as String? + val receiptEntity = ReadReceiptEntity.createUnmanaged(roomId, eventId, userId, threadId, ts) readReceiptsSummary.readReceipts.add(receiptEntity) } readReceiptSummaries.add(readReceiptsSummary) @@ -115,7 +122,7 @@ internal class ReadReceiptHandler @Inject constructor( ) { // First check if we have data from init sync to handle getContentFromInitSync(roomId)?.let { - Timber.w("INIT_SYNC Insert during incremental sync RR for room $roomId") + Timber.d("INIT_SYNC Insert during incremental sync RR for room $roomId") doIncrementalSyncStrategy(realm, roomId, it) aggregator?.ephemeralFilesToDelete?.add(roomId) } @@ -132,8 +139,9 @@ internal class ReadReceiptHandler @Inject constructor( } for ((userId, paramsDict) in userIdsDict) { - val ts = paramsDict[TIMESTAMP_KEY] ?: 0.0 - val receiptEntity = ReadReceiptEntity.getOrCreate(realm, roomId, userId) + val ts = paramsDict[TIMESTAMP_KEY] as? Double ?: 0.0 + val threadId = paramsDict[THREAD_ID_KEY] as String? + val receiptEntity = ReadReceiptEntity.getOrCreate(realm, roomId, userId, threadId) // ensure new ts is superior to the previous one if (ts > receiptEntity.originServerTs) { ReadReceiptsSummaryEntity.where(realm, receiptEntity.eventId).findFirst()?.also { diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/room/RoomSyncHandler.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/room/RoomSyncHandler.kt index 2825be8291..4001ae2ccf 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/room/RoomSyncHandler.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/room/RoomSyncHandler.kt @@ -25,6 +25,8 @@ import org.matrix.android.sdk.api.session.crypto.MXCryptoError import org.matrix.android.sdk.api.session.crypto.model.OlmDecryptionResult import org.matrix.android.sdk.api.session.events.model.Event import org.matrix.android.sdk.api.session.events.model.EventType +import org.matrix.android.sdk.api.session.events.model.RelationType +import org.matrix.android.sdk.api.session.events.model.getRelationContent import org.matrix.android.sdk.api.session.events.model.toModel import org.matrix.android.sdk.api.session.homeserver.HomeServerCapabilitiesService import org.matrix.android.sdk.api.session.room.model.Membership @@ -49,6 +51,7 @@ import org.matrix.android.sdk.internal.database.mapper.asDomain import org.matrix.android.sdk.internal.database.mapper.toEntity import org.matrix.android.sdk.internal.database.model.ChunkEntity import org.matrix.android.sdk.internal.database.model.CurrentStateEventEntity +import org.matrix.android.sdk.internal.database.model.EventAnnotationsSummaryEntity import org.matrix.android.sdk.internal.database.model.EventEntity import org.matrix.android.sdk.internal.database.model.EventInsertType import org.matrix.android.sdk.internal.database.model.RoomEntity @@ -486,23 +489,41 @@ internal class RoomSyncHandler @Inject constructor( cryptoService.onLiveEvent(roomEntity.roomId, event, isInitialSync) // Try to remove local echo - event.unsignedData?.transactionId?.also { - val sendingEventEntity = roomEntity.sendingTimelineEvents.find(it) + event.unsignedData?.transactionId?.also { txId -> + val sendingEventEntity = roomEntity.sendingTimelineEvents.find(txId) if (sendingEventEntity != null) { - Timber.v("Remove local echo for tx:$it") + Timber.v("Remove local echo for tx:$txId") roomEntity.sendingTimelineEvents.remove(sendingEventEntity) if (event.isEncrypted() && event.content?.get("algorithm") as? String == MXCRYPTO_ALGORITHM_MEGOLM) { - // updated with echo decryption, to avoid seeing it decrypt again + // updated with echo decryption, to avoid seeing txId decrypt again val adapter = MoshiProvider.providesMoshi().adapter(OlmDecryptionResult::class.java) sendingEventEntity.root?.decryptionResultJson?.let { json -> eventEntity.decryptionResultJson = json event.mxDecryptionResult = adapter.fromJson(json) } } + // also update potential edit that could refer to that event? + // If not display will flicker :/ + val relationContent = event.getRelationContent() + if (relationContent?.type == RelationType.REPLACE) { + relationContent.eventId?.let { targetId -> + EventAnnotationsSummaryEntity.where(realm, roomId, targetId) + .findFirst() + ?.editSummary + ?.editions + ?.forEach { + if (it.eventId == txId) { + // just do that, the aggregation processor will to the rest + it.event = eventEntity + } + } + } + } + // Finally delete the local echo sendingEventEntity.deleteOnCascade(true) } else { - Timber.v("Can't find corresponding local echo for tx:$it") + Timber.v("Can't find corresponding local echo for tx:$txId") } } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/room/RoomTypingUsersHandler.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/room/RoomTypingUsersHandler.kt index 54bb63753c..519112b1b7 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/room/RoomTypingUsersHandler.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/handler/room/RoomTypingUsersHandler.kt @@ -18,6 +18,7 @@ package org.matrix.android.sdk.internal.session.sync.handler.room import io.realm.Realm import org.matrix.android.sdk.api.session.room.sender.SenderInfo +import org.matrix.android.sdk.internal.database.model.IgnoredUserEntity import org.matrix.android.sdk.internal.di.UserId import org.matrix.android.sdk.internal.session.room.membership.RoomMemberHelper import org.matrix.android.sdk.internal.session.typing.DefaultTypingUsersTracker @@ -30,8 +31,15 @@ internal class RoomTypingUsersHandler @Inject constructor( // TODO This could be handled outside of the Realm transaction. Use the new aggregator? fun handle(realm: Realm, roomId: String, ephemeralResult: RoomSyncHandler.EphemeralResult?) { + val typingUserIds = ephemeralResult?.typingUserIds + if (typingUserIds.isNullOrEmpty()) { + typingUsersTracker.setTypingUsersFromRoom(roomId, emptyList()) + return + } + // Filter ignored users and current user + val filteredUserIds = realm.where(IgnoredUserEntity::class.java).findAll().map { it.userId } + userId val roomMemberHelper = RoomMemberHelper(realm, roomId) - val typingIds = ephemeralResult?.typingUserIds?.filter { it != userId }.orEmpty() + val typingIds = typingUserIds.filter { it !in filteredUserIds } val senderInfo = typingIds.map { userId -> val roomMemberSummaryEntity = roomMemberHelper.getLastRoomMember(userId) SenderInfo( diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/parsing/RoomSyncAccountDataHandler.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/parsing/RoomSyncAccountDataHandler.kt index b1b2bfef33..9da12a2c4a 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/parsing/RoomSyncAccountDataHandler.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/parsing/RoomSyncAccountDataHandler.kt @@ -27,6 +27,7 @@ import org.matrix.android.sdk.internal.database.model.RoomAccountDataEntity import org.matrix.android.sdk.internal.database.model.RoomAccountDataEntityFields import org.matrix.android.sdk.internal.database.model.RoomEntity import org.matrix.android.sdk.internal.database.query.getOrCreate +import org.matrix.android.sdk.internal.database.query.removeAccountData import org.matrix.android.sdk.internal.session.room.read.FullyReadContent import org.matrix.android.sdk.internal.session.sync.handler.room.RoomFullyReadHandler import org.matrix.android.sdk.internal.session.sync.handler.room.RoomTagHandler @@ -56,6 +57,13 @@ internal class RoomSyncAccountDataHandler @Inject constructor( } private fun handleGeneric(roomEntity: RoomEntity, content: JsonDict?, eventType: String) { + if (content.isNullOrEmpty()) { + // This is a response for a deleted account data according to + // https://github.com/ShadowJonathan/matrix-doc/blob/account-data-delete/proposals/3391-account-data-delete.md#sync + roomEntity.removeAccountData(eventType) + return + } + val existing = roomEntity.accountData.where().equalTo(RoomAccountDataEntityFields.TYPE, eventType).findFirst() if (existing != null) { existing.contentStr = ContentMapper.map(content) diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/user/accountdata/AccountDataAPI.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/user/accountdata/AccountDataAPI.kt index b283d51845..1b3d59ac66 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/user/accountdata/AccountDataAPI.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/user/accountdata/AccountDataAPI.kt @@ -18,13 +18,14 @@ package org.matrix.android.sdk.internal.session.user.accountdata import org.matrix.android.sdk.internal.network.NetworkConstants import retrofit2.http.Body +import retrofit2.http.DELETE import retrofit2.http.PUT import retrofit2.http.Path internal interface AccountDataAPI { /** - * Set some account_data for the client. + * Set some account_data for the user. * * @param userId the user id * @param type the type @@ -36,4 +37,16 @@ internal interface AccountDataAPI { @Path("type") type: String, @Body params: Any ) + + /** + * Remove an account_data for the user. + * + * @param userId the user id + * @param type the type + */ + @DELETE(NetworkConstants.URI_API_PREFIX_PATH_UNSTABLE + "org.matrix.msc3391/user/{userId}/account_data/{type}") + suspend fun deleteAccountData( + @Path("userId") userId: String, + @Path("type") type: String + ) } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/user/accountdata/AccountDataModule.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/user/accountdata/AccountDataModule.kt index 3173686a27..463292b9c6 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/user/accountdata/AccountDataModule.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/user/accountdata/AccountDataModule.kt @@ -42,4 +42,7 @@ internal abstract class AccountDataModule { @Binds abstract fun bindUpdateBreadcrumbsTask(task: DefaultUpdateBreadcrumbsTask): UpdateBreadcrumbsTask + + @Binds + abstract fun bindDeleteUserAccountDataTask(task: DefaultDeleteUserAccountDataTask): DeleteUserAccountDataTask } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/user/accountdata/DefaultSessionAccountDataService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/user/accountdata/DefaultSessionAccountDataService.kt index c73446cf25..304a586a79 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/user/accountdata/DefaultSessionAccountDataService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/user/accountdata/DefaultSessionAccountDataService.kt @@ -34,10 +34,11 @@ import javax.inject.Inject internal class DefaultSessionAccountDataService @Inject constructor( @SessionDatabase private val monarchy: Monarchy, private val updateUserAccountDataTask: UpdateUserAccountDataTask, + private val deleteUserAccountDataTask: DeleteUserAccountDataTask, private val userAccountDataSyncHandler: UserAccountDataSyncHandler, private val userAccountDataDataSource: UserAccountDataDataSource, private val roomAccountDataDataSource: RoomAccountDataDataSource, - private val taskExecutor: TaskExecutor + private val taskExecutor: TaskExecutor, ) : SessionAccountDataService { override fun getUserAccountDataEvent(type: String): UserAccountDataEvent? { @@ -78,4 +79,12 @@ internal class DefaultSessionAccountDataService @Inject constructor( userAccountDataSyncHandler.handleGenericAccountData(realm, type, content) } } + + override fun getUserAccountDataEventsStartWith(type: String): List { + return userAccountDataDataSource.getAccountDataEventsStartWith(type) + } + + override suspend fun deleteUserAccountData(type: String) { + deleteUserAccountDataTask.execute(DeleteUserAccountDataTask.Params(type)) + } } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/user/accountdata/DeleteUserAccountDataTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/user/accountdata/DeleteUserAccountDataTask.kt new file mode 100644 index 0000000000..8d155e32cb --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/user/accountdata/DeleteUserAccountDataTask.kt @@ -0,0 +1,43 @@ +/* + * Copyright 2022 The Matrix.org Foundation C.I.C. + * + * 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 org.matrix.android.sdk.internal.session.user.accountdata + +import org.matrix.android.sdk.internal.di.UserId +import org.matrix.android.sdk.internal.network.GlobalErrorReceiver +import org.matrix.android.sdk.internal.network.executeRequest +import org.matrix.android.sdk.internal.task.Task +import javax.inject.Inject + +internal interface DeleteUserAccountDataTask : Task { + + data class Params( + val type: String, + ) +} + +internal class DefaultDeleteUserAccountDataTask @Inject constructor( + private val accountDataApi: AccountDataAPI, + @UserId private val userId: String, + private val globalErrorReceiver: GlobalErrorReceiver, +) : DeleteUserAccountDataTask { + + override suspend fun execute(params: DeleteUserAccountDataTask.Params) { + return executeRequest(globalErrorReceiver) { + accountDataApi.deleteAccountData(userId, params.type) + } + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/user/accountdata/UserAccountDataDataSource.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/user/accountdata/UserAccountDataDataSource.kt index 39f155096a..01f5d9f708 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/user/accountdata/UserAccountDataDataSource.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/user/accountdata/UserAccountDataDataSource.kt @@ -60,6 +60,16 @@ internal class UserAccountDataDataSource @Inject constructor( ) } + fun getAccountDataEventsStartWith(type: String): List { + return realmSessionProvider.withRealm { realm -> + realm + .where(UserAccountDataEntity::class.java) + .beginsWith(UserAccountDataEntityFields.TYPE, type) + .findAll() + .map(accountDataMapper::map) + } + } + private fun accountDataEventsQuery(realm: Realm, types: Set): RealmQuery { val query = realm.where(UserAccountDataEntity::class.java) if (types.isNotEmpty()) { diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/sync/filter/SyncFilterBuilder.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/sync/filter/SyncFilterBuilder.kt new file mode 100644 index 0000000000..d58b9d3765 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/sync/filter/SyncFilterBuilder.kt @@ -0,0 +1,119 @@ +/* + * Copyright 2022 The Matrix.org Foundation C.I.C. + * + * 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 org.matrix.android.sdk.internal.sync.filter + +import org.matrix.android.sdk.api.session.homeserver.HomeServerCapabilities +import org.matrix.android.sdk.api.session.sync.filter.SyncFilterParams +import org.matrix.android.sdk.internal.session.filter.Filter +import org.matrix.android.sdk.internal.session.filter.RoomEventFilter +import org.matrix.android.sdk.internal.session.filter.RoomFilter + +internal class SyncFilterBuilder { + private var lazyLoadMembersForStateEvents: Boolean? = null + private var lazyLoadMembersForMessageEvents: Boolean? = null + private var useThreadNotifications: Boolean? = null + private var listOfSupportedEventTypes: List? = null + private var listOfSupportedStateEventTypes: List? = null + + fun lazyLoadMembersForStateEvents(lazyLoadMembersForStateEvents: Boolean) = apply { this.lazyLoadMembersForStateEvents = lazyLoadMembersForStateEvents } + + fun lazyLoadMembersForMessageEvents(lazyLoadMembersForMessageEvents: Boolean) = + apply { this.lazyLoadMembersForMessageEvents = lazyLoadMembersForMessageEvents } + + fun useThreadNotifications(useThreadNotifications: Boolean) = + apply { this.useThreadNotifications = useThreadNotifications } + + fun listOfSupportedStateEventTypes(listOfSupportedStateEventTypes: List) = + apply { this.listOfSupportedStateEventTypes = listOfSupportedStateEventTypes } + + fun listOfSupportedTimelineEventTypes(listOfSupportedEventTypes: List) = + apply { this.listOfSupportedEventTypes = listOfSupportedEventTypes } + + internal fun with(currentFilterParams: SyncFilterParams?) = + apply { + currentFilterParams?.let { + useThreadNotifications = currentFilterParams.useThreadNotifications + lazyLoadMembersForMessageEvents = currentFilterParams.lazyLoadMembersForMessageEvents + lazyLoadMembersForStateEvents = currentFilterParams.lazyLoadMembersForStateEvents + listOfSupportedEventTypes = currentFilterParams.listOfSupportedEventTypes?.toList() + listOfSupportedStateEventTypes = currentFilterParams.listOfSupportedStateEventTypes?.toList() + } + } + + internal fun build(homeServerCapabilities: HomeServerCapabilities): Filter { + return Filter( + room = buildRoomFilter(homeServerCapabilities) + ) + } + + private fun buildRoomFilter(homeServerCapabilities: HomeServerCapabilities): RoomFilter { + return RoomFilter( + timeline = buildTimelineFilter(homeServerCapabilities), + state = buildStateFilter() + ) + } + + private fun buildTimelineFilter(homeServerCapabilities: HomeServerCapabilities): RoomEventFilter? { + val resolvedUseThreadNotifications = if (homeServerCapabilities.canUseThreadReadReceiptsAndNotifications) { + useThreadNotifications + } else { + null + } + return RoomEventFilter( + enableUnreadThreadNotifications = resolvedUseThreadNotifications, + lazyLoadMembers = lazyLoadMembersForMessageEvents + ).orNullIfEmpty() + } + + private fun buildStateFilter(): RoomEventFilter? = + RoomEventFilter( + lazyLoadMembers = lazyLoadMembersForStateEvents, + types = listOfSupportedStateEventTypes + ).orNullIfEmpty() + + private fun RoomEventFilter.orNullIfEmpty(): RoomEventFilter? { + return if (hasData()) { + this + } else { + null + } + } + + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (javaClass != other?.javaClass) return false + + other as SyncFilterBuilder + + if (lazyLoadMembersForStateEvents != other.lazyLoadMembersForStateEvents) return false + if (lazyLoadMembersForMessageEvents != other.lazyLoadMembersForMessageEvents) return false + if (useThreadNotifications != other.useThreadNotifications) return false + if (listOfSupportedEventTypes != other.listOfSupportedEventTypes) return false + if (listOfSupportedStateEventTypes != other.listOfSupportedStateEventTypes) return false + + return true + } + + override fun hashCode(): Int { + var result = lazyLoadMembersForStateEvents?.hashCode() ?: 0 + result = 31 * result + (lazyLoadMembersForMessageEvents?.hashCode() ?: 0) + result = 31 * result + (useThreadNotifications?.hashCode() ?: 0) + result = 31 * result + (listOfSupportedEventTypes?.hashCode() ?: 0) + result = 31 * result + (listOfSupportedStateEventTypes?.hashCode() ?: 0) + return result + } +} diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/util/Monarchy.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/util/Monarchy.kt index 6152eacae5..af3ba80fe4 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/util/Monarchy.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/util/Monarchy.kt @@ -22,7 +22,7 @@ import io.realm.RealmModel import org.matrix.android.sdk.internal.database.awaitTransaction import java.util.concurrent.atomic.AtomicReference -internal suspend fun Monarchy.awaitTransaction(transaction: suspend (realm: Realm) -> T): T { +internal suspend fun Monarchy.awaitTransaction(transaction: (realm: Realm) -> T): T { return awaitTransaction(realmConfiguration, transaction) } diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/crypto/DefaultSendToDeviceTaskTest.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/crypto/DefaultSendToDeviceTaskTest.kt new file mode 100644 index 0000000000..df6fc5f165 --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/crypto/DefaultSendToDeviceTaskTest.kt @@ -0,0 +1,255 @@ +/* + * Copyright 2022 The Matrix.org Foundation C.I.C. + * + * 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 org.matrix.android.sdk.internal.crypto + +import io.mockk.mockk +import kotlinx.coroutines.runBlocking +import org.amshove.kluent.internal.assertEquals +import org.junit.Assert +import org.junit.Test +import org.matrix.android.sdk.api.session.crypto.model.DeviceInfo +import org.matrix.android.sdk.api.session.crypto.model.DevicesListResponse +import org.matrix.android.sdk.api.session.crypto.model.MXUsersDevicesMap +import org.matrix.android.sdk.api.session.events.model.EventType +import org.matrix.android.sdk.api.session.room.model.message.MessageType +import org.matrix.android.sdk.internal.crypto.api.CryptoApi +import org.matrix.android.sdk.internal.crypto.model.rest.DeleteDeviceParams +import org.matrix.android.sdk.internal.crypto.model.rest.DeleteDevicesParams +import org.matrix.android.sdk.internal.crypto.model.rest.KeyChangesResponse +import org.matrix.android.sdk.internal.crypto.model.rest.KeysClaimBody +import org.matrix.android.sdk.internal.crypto.model.rest.KeysClaimResponse +import org.matrix.android.sdk.internal.crypto.model.rest.KeysQueryBody +import org.matrix.android.sdk.internal.crypto.model.rest.KeysQueryResponse +import org.matrix.android.sdk.internal.crypto.model.rest.KeysUploadBody +import org.matrix.android.sdk.internal.crypto.model.rest.KeysUploadResponse +import org.matrix.android.sdk.internal.crypto.model.rest.SendToDeviceBody +import org.matrix.android.sdk.internal.crypto.model.rest.SignatureUploadResponse +import org.matrix.android.sdk.internal.crypto.model.rest.UpdateDeviceInfoBody +import org.matrix.android.sdk.internal.crypto.model.rest.UploadSigningKeysBody +import org.matrix.android.sdk.internal.crypto.tasks.DefaultSendToDeviceTask +import org.matrix.android.sdk.internal.crypto.tasks.SendToDeviceTask + +class DefaultSendToDeviceTaskTest { + + private val users = listOf( + "@alice:example.com" to listOf("D0", "D1"), + "bob@example.com" to listOf("D2", "D3") + ) + + private val fakeEncryptedContent = mapOf( + "algorithm" to "m.olm.v1.curve25519-aes-sha2", + "sender_key" to "gMObR+/4dqL5T4DisRRRYBJpn+OjzFnkyCFOktP6Eyw", + "ciphertext" to mapOf( + "tdwXf7006FDgzmufMCVI4rDdVPO51ecRTTT6HkRxUwE" to mapOf( + "type" to 0, + "body" to "AwogCA1ULEc0abGIFxMDIC9iv7ul3jqJSnapTHQ+8JJx" + ) + ) + ) + + private val fakeStartVerificationContent = mapOf( + "method" to "m.sas.v1", + "from_device" to "MNQHVEISFQ", + "key_agreement_protocols" to listOf( + "curve25519-hkdf-sha256", + "curve25519" + ), + "hashes" to listOf("sha256"), + "message_authentication_codes" to listOf( + "org.matrix.msc3783.hkdf-hmac-sha256", + "hkdf-hmac-sha256", + "hmac-sha256" + ), + "short_authentication_string" to listOf( + "decimal", + "emoji" + ), + "transaction_id" to "4wNOpkHGwGZPXjkZToooCDWfb8hsf7vW" + ) + + @Test + fun `tracing id should be added to to_device contents`() { + val fakeCryptoAPi = FakeCryptoApi() + + val sendToDeviceTask = DefaultSendToDeviceTask( + cryptoApi = fakeCryptoAPi, + globalErrorReceiver = mockk(relaxed = true) + ) + + val contentMap = MXUsersDevicesMap() + + users.forEach { pairOfUserDevices -> + val userId = pairOfUserDevices.first + pairOfUserDevices.second.forEach { + contentMap.setObject(userId, it, fakeEncryptedContent) + } + } + + val params = SendToDeviceTask.Params( + eventType = EventType.ENCRYPTED, + contentMap = contentMap + ) + + runBlocking { + sendToDeviceTask.execute(params) + } + + val generatedIds = mutableListOf() + users.forEach { pairOfUserDevices -> + val userId = pairOfUserDevices.first + pairOfUserDevices.second.forEach { + val modifiedContent = fakeCryptoAPi.body!!.messages!![userId]!![it] as Map<*, *> + Assert.assertNotNull("Tracing id should have been added", modifiedContent["org.matrix.msgid"]) + generatedIds.add(modifiedContent["org.matrix.msgid"] as String) + + assertEquals( + "The rest of the content should be the same", + fakeEncryptedContent.keys, + modifiedContent.toMutableMap().apply { remove("org.matrix.msgid") }.keys + ) + } + } + + assertEquals("Id should be unique per content", generatedIds.size, generatedIds.toSet().size) + println("modified content ${fakeCryptoAPi.body}") + } + + @Test + fun `tracing id should not be added to verification start to_device contents`() { + val fakeCryptoAPi = FakeCryptoApi() + + val sendToDeviceTask = DefaultSendToDeviceTask( + cryptoApi = fakeCryptoAPi, + globalErrorReceiver = mockk(relaxed = true) + ) + val contentMap = MXUsersDevicesMap() + contentMap.setObject("@alice:example.com", "MNQHVEISFQ", fakeStartVerificationContent) + + val params = SendToDeviceTask.Params( + eventType = EventType.KEY_VERIFICATION_START, + contentMap = contentMap + ) + + runBlocking { + sendToDeviceTask.execute(params) + } + + val modifiedContent = fakeCryptoAPi.body!!.messages!!["@alice:example.com"]!!["MNQHVEISFQ"] as Map<*, *> + Assert.assertNull("Tracing id should not have been added", modifiedContent["org.matrix.msgid"]) + + // try to force + runBlocking { + sendToDeviceTask.execute( + SendToDeviceTask.Params( + eventType = EventType.KEY_VERIFICATION_START, + contentMap = contentMap, + addTracingIds = true + ) + ) + } + + val modifiedContentForced = fakeCryptoAPi.body!!.messages!!["@alice:example.com"]!!["MNQHVEISFQ"] as Map<*, *> + Assert.assertNotNull("Tracing id should have been added", modifiedContentForced["org.matrix.msgid"]) + } + + @Test + fun `tracing id should not be added to all verification to_device contents`() { + val fakeCryptoAPi = FakeCryptoApi() + + val sendToDeviceTask = DefaultSendToDeviceTask( + cryptoApi = fakeCryptoAPi, + globalErrorReceiver = mockk(relaxed = true) + ) + val contentMap = MXUsersDevicesMap() + contentMap.setObject("@alice:example.com", "MNQHVEISFQ", emptyMap()) + + val verificationEvents = listOf( + MessageType.MSGTYPE_VERIFICATION_REQUEST, + EventType.KEY_VERIFICATION_START, + EventType.KEY_VERIFICATION_ACCEPT, + EventType.KEY_VERIFICATION_KEY, + EventType.KEY_VERIFICATION_MAC, + EventType.KEY_VERIFICATION_CANCEL, + EventType.KEY_VERIFICATION_DONE, + EventType.KEY_VERIFICATION_READY + ) + + for (type in verificationEvents) { + val params = SendToDeviceTask.Params( + eventType = type, + contentMap = contentMap + ) + runBlocking { + sendToDeviceTask.execute(params) + } + + val modifiedContent = fakeCryptoAPi.body!!.messages!!["@alice:example.com"]!!["MNQHVEISFQ"] as Map<*, *> + Assert.assertNull("Tracing id should not have been added", modifiedContent["org.matrix.msgid"]) + } + } + + internal class FakeCryptoApi : CryptoApi { + override suspend fun getDevices(): DevicesListResponse { + throw java.lang.AssertionError("Should not be called") + } + + override suspend fun getDeviceInfo(deviceId: String): DeviceInfo { + throw java.lang.AssertionError("Should not be called") + } + + override suspend fun uploadKeys(body: KeysUploadBody): KeysUploadResponse { + throw java.lang.AssertionError("Should not be called") + } + + override suspend fun downloadKeysForUsers(params: KeysQueryBody): KeysQueryResponse { + throw java.lang.AssertionError("Should not be called") + } + + override suspend fun uploadSigningKeys(params: UploadSigningKeysBody): KeysQueryResponse { + throw java.lang.AssertionError("Should not be called") + } + + override suspend fun uploadSignatures(params: Map?): SignatureUploadResponse { + throw java.lang.AssertionError("Should not be called") + } + + override suspend fun claimOneTimeKeysForUsersDevices(body: KeysClaimBody): KeysClaimResponse { + throw java.lang.AssertionError("Should not be called") + } + + var body: SendToDeviceBody? = null + override suspend fun sendToDevice(eventType: String, transactionId: String, body: SendToDeviceBody) { + this.body = body + } + + override suspend fun deleteDevice(deviceId: String, params: DeleteDeviceParams) { + throw java.lang.AssertionError("Should not be called") + } + + override suspend fun deleteDevices(params: DeleteDevicesParams) { + throw java.lang.AssertionError("Should not be called") + } + + override suspend fun updateDeviceInfo(deviceId: String, params: UpdateDeviceInfoBody) { + throw java.lang.AssertionError("Should not be called") + } + + override suspend fun getKeyChanges(oldToken: String, newToken: String): KeyChangesResponse { + throw java.lang.AssertionError("Should not be called") + } + } +} diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/database/mapper/EditAggregatedSummaryEntityMapperTest.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/database/mapper/EditAggregatedSummaryEntityMapperTest.kt new file mode 100644 index 0000000000..7ad5bb40e3 --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/database/mapper/EditAggregatedSummaryEntityMapperTest.kt @@ -0,0 +1,114 @@ +/* + * Copyright 2022 The Matrix.org Foundation C.I.C. + * + * 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 org.matrix.android.sdk.internal.database.mapper + +import io.mockk.every +import io.mockk.mockk +import io.realm.RealmList +import org.amshove.kluent.shouldBeEqualTo +import org.amshove.kluent.shouldNotBe +import org.junit.Test +import org.matrix.android.sdk.internal.database.model.EditAggregatedSummaryEntity +import org.matrix.android.sdk.internal.database.model.EditionOfEvent +import org.matrix.android.sdk.internal.database.model.EventEntity + +class EditAggregatedSummaryEntityMapperTest { + + @Test + fun `test mapping summary entity to model`() { + val edits = RealmList( + EditionOfEvent( + timestamp = 0L, + eventId = "e0", + isLocalEcho = false, + event = mockEvent("e0") + ), + EditionOfEvent( + timestamp = 1L, + eventId = "e1", + isLocalEcho = false, + event = mockEvent("e1") + ), + EditionOfEvent( + timestamp = 30L, + eventId = "e2", + isLocalEcho = true, + event = mockEvent("e2") + ) + ) + val fakeSummaryEntity = mockk { + every { editions } returns edits + } + + val mapped = EditAggregatedSummaryEntityMapper.map(fakeSummaryEntity) + mapped shouldNotBe null + mapped!!.sourceEvents.size shouldBeEqualTo 2 + mapped.localEchos.size shouldBeEqualTo 1 + mapped.localEchos.first() shouldBeEqualTo "e2" + + mapped.lastEditTs shouldBeEqualTo 30L + mapped.latestEdit?.eventId shouldBeEqualTo "e2" + } + + @Test + fun `event with lexicographically largest event_id is treated as more recent`() { + val lowerId = "\$Albatross" + val higherId = "\$Zebra" + + (higherId > lowerId) shouldBeEqualTo true + val timestamp = 1669288766745L + val edits = RealmList( + EditionOfEvent( + timestamp = timestamp, + eventId = lowerId, + isLocalEcho = false, + event = mockEvent(lowerId) + ), + EditionOfEvent( + timestamp = timestamp, + eventId = higherId, + isLocalEcho = false, + event = mockEvent(higherId) + ), + EditionOfEvent( + timestamp = 1L, + eventId = "e2", + isLocalEcho = true, + event = mockEvent("e2") + ) + ) + + val fakeSummaryEntity = mockk { + every { editions } returns edits + } + val mapped = EditAggregatedSummaryEntityMapper.map(fakeSummaryEntity) + mapped!!.lastEditTs shouldBeEqualTo timestamp + mapped.latestEdit?.eventId shouldBeEqualTo higherId + } + + private fun mockEvent(eventId: String): EventEntity { + return EventEntity().apply { + this.eventId = eventId + this.content = """ + { + "body" : "Hello", + "msgtype": "text" + } + """.trimIndent() + } + } +} diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/event/ValidDecryptedEventTest.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/event/ValidDecryptedEventTest.kt new file mode 100644 index 0000000000..5fda242b90 --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/event/ValidDecryptedEventTest.kt @@ -0,0 +1,134 @@ +/* + * Copyright 2022 The Matrix.org Foundation C.I.C. + * + * 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 org.matrix.android.sdk.internal.session.event + +import org.amshove.kluent.shouldBe +import org.amshove.kluent.shouldNotBe +import org.junit.Test +import org.matrix.android.sdk.api.crypto.MXCRYPTO_ALGORITHM_MEGOLM +import org.matrix.android.sdk.api.session.crypto.model.OlmDecryptionResult +import org.matrix.android.sdk.api.session.events.model.Event +import org.matrix.android.sdk.api.session.events.model.EventType +import org.matrix.android.sdk.api.session.events.model.content.EncryptedEventContent +import org.matrix.android.sdk.api.session.events.model.toContent +import org.matrix.android.sdk.api.session.events.model.toModel +import org.matrix.android.sdk.api.session.events.model.toValidDecryptedEvent +import org.matrix.android.sdk.api.session.room.model.message.MessageTextContent + +class ValidDecryptedEventTest { + + private val fakeEvent = Event( + type = EventType.ENCRYPTED, + eventId = "\$eventId", + roomId = "!fakeRoom", + content = EncryptedEventContent( + algorithm = MXCRYPTO_ALGORITHM_MEGOLM, + ciphertext = "AwgBEpACQEKOkd4Gp0+gSXG4M+btcrnPgsF23xs/lUmS2I4YjmqF...", + sessionId = "TO2G4u2HlnhtbIJk", + senderKey = "5e3EIqg3JfooZnLQ2qHIcBarbassQ4qXblai0", + deviceId = "FAKEE" + ).toContent() + ) + + @Test + fun `A failed to decrypt message should give a null validated decrypted event`() { + fakeEvent.toValidDecryptedEvent() shouldBe null + } + + @Test + fun `Mismatch sender key detection`() { + val decryptedEvent = fakeEvent + .apply { + mxDecryptionResult = OlmDecryptionResult( + payload = mapOf( + "type" to EventType.MESSAGE, + "content" to mapOf( + "body" to "some message", + "msgtype" to "m.text" + ), + ), + senderKey = "the_real_sender_key", + ) + } + + val validDecryptedEvent = decryptedEvent.toValidDecryptedEvent() + validDecryptedEvent shouldNotBe null + + fakeEvent.content!!["senderKey"] shouldNotBe "the_real_sender_key" + validDecryptedEvent!!.cryptoSenderKey shouldBe "the_real_sender_key" + } + + @Test + fun `Mixed content event should be detected`() { + val mixedEvent = Event( + type = EventType.ENCRYPTED, + eventId = "\$eventd ", + roomId = "!fakeRoo", + content = mapOf( + "algorithm" to "m.megolm.v1.aes-sha2", + "ciphertext" to "AwgBEpACQEKOkd4Gp0+gSXG4M+btcrnPgsF23xs/lUmS2I4YjmqF...", + "sessionId" to "TO2G4u2HlnhtbIJk", + "senderKey" to "5e3EIqg3JfooZnLQ2qHIcBarbassQ4qXblai0", + "deviceId" to "FAKEE", + "body" to "some message", + "msgtype" to "m.text" + ).toContent() + ) + + val unValidatedContent = mixedEvent.getClearContent().toModel() + unValidatedContent?.body shouldBe "some message" + + mixedEvent.toValidDecryptedEvent()?.clearContent?.toModel() shouldBe null + } + + @Test + fun `Basic field validation`() { + val decryptedEvent = fakeEvent + .apply { + mxDecryptionResult = OlmDecryptionResult( + payload = mapOf( + "type" to EventType.MESSAGE, + "content" to mapOf( + "body" to "some message", + "msgtype" to "m.text" + ), + ), + senderKey = "the_real_sender_key", + ) + } + + decryptedEvent.toValidDecryptedEvent() shouldNotBe null + decryptedEvent.copy(roomId = null).toValidDecryptedEvent() shouldBe null + decryptedEvent.copy(eventId = null).toValidDecryptedEvent() shouldBe null + } + + @Test + fun `A clear event is not a valid decrypted event`() { + val mockTextEvent = Event( + type = EventType.MESSAGE, + eventId = "eventId", + roomId = "!fooe:example.com", + content = mapOf( + "body" to "some message", + "msgtype" to "m.text" + ), + originServerTs = 1000, + senderId = "@anne:example.com", + ) + mockTextEvent.toValidDecryptedEvent() shouldBe null + } +} diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/EventEditValidatorTest.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/EventEditValidatorTest.kt new file mode 100644 index 0000000000..0ae712bff1 --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/EventEditValidatorTest.kt @@ -0,0 +1,372 @@ +/* + * Copyright (c) 2022 The Matrix.org Foundation C.I.C. + * + * 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 org.matrix.android.sdk.internal.session.room + +import io.mockk.every +import io.mockk.mockk +import org.amshove.kluent.shouldBeInstanceOf +import org.junit.Test +import org.matrix.android.sdk.api.session.crypto.model.CryptoDeviceInfo +import org.matrix.android.sdk.api.session.crypto.model.OlmDecryptionResult +import org.matrix.android.sdk.api.session.events.model.Event +import org.matrix.android.sdk.api.session.events.model.EventType +import org.matrix.android.sdk.internal.crypto.store.IMXCryptoStore + +class EventEditValidatorTest { + + private val mockTextEvent = Event( + type = EventType.MESSAGE, + eventId = "\$WX8WlNC2reiXrwHIA_CQHmU_pSR-jhOA2xKPRcJN9wQ", + roomId = "!GXKhWsRwiWWvbQDBpe:example.com", + content = mapOf( + "body" to "some message", + "msgtype" to "m.text" + ), + originServerTs = 1000, + senderId = "@alice:example.com", + ) + + private val mockEdit = Event( + type = EventType.MESSAGE, + eventId = "\$-SF7RWLPzRzCbHqK3ZAhIrX5Auh3B2lS5AqJiypt1p0", + roomId = "!GXKhWsRwiWWvbQDBpe:example.com", + content = mapOf( + "body" to "* some message edited", + "msgtype" to "m.text", + "m.new_content" to mapOf( + "body" to "some message edited", + "msgtype" to "m.text" + ), + "m.relates_to" to mapOf( + "rel_type" to "m.replace", + "event_id" to mockTextEvent.eventId + ) + ), + originServerTs = 2000, + senderId = "@alice:example.com", + ) + + @Test + fun `edit should be valid`() { + val mockCryptoStore = mockk() + val validator = EventEditValidator(mockCryptoStore) + + validator + .validateEdit(mockTextEvent, mockEdit) shouldBeInstanceOf EventEditValidator.EditValidity.Valid::class + } + + @Test + fun `original event and replacement event must have the same sender`() { + val mockCryptoStore = mockk() + val validator = EventEditValidator(mockCryptoStore) + + validator + .validateEdit( + mockTextEvent, + mockEdit.copy(senderId = "@bob:example.com") + ) shouldBeInstanceOf EventEditValidator.EditValidity.Invalid::class + } + + @Test + fun `original event and replacement event must have the same room_id`() { + val mockCryptoStore = mockk() + val validator = EventEditValidator(mockCryptoStore) + + validator + .validateEdit( + mockTextEvent, + mockEdit.copy(roomId = "!someotherroom") + ) shouldBeInstanceOf EventEditValidator.EditValidity.Invalid::class + + validator + .validateEdit( + encryptedEvent, + encryptedEditEvent.copy(roomId = "!someotherroom") + ) shouldBeInstanceOf EventEditValidator.EditValidity.Invalid::class + } + + @Test + fun `replacement and original events must not have a state_key property`() { + val mockCryptoStore = mockk() + val validator = EventEditValidator(mockCryptoStore) + + validator + .validateEdit( + mockTextEvent, + mockEdit.copy(stateKey = "") + ) shouldBeInstanceOf EventEditValidator.EditValidity.Invalid::class + + validator + .validateEdit( + mockTextEvent.copy(stateKey = ""), + mockEdit + ) shouldBeInstanceOf EventEditValidator.EditValidity.Invalid::class + } + + @Test + fun `replacement event must have an new_content property`() { + val mockCryptoStore = mockk { + every { deviceWithIdentityKey("R0s/7Aindgg/RNWqUGJyJOXtCz5H7Gx7fInFuroq1xo") } returns + mockk { + every { userId } returns "@alice:example.com" + } + } + val validator = EventEditValidator(mockCryptoStore) + + validator + .validateEdit(mockTextEvent, mockEdit.copy( + content = mockEdit.content!!.toMutableMap().apply { + this.remove("m.new_content") + } + )) shouldBeInstanceOf EventEditValidator.EditValidity.Invalid::class + + validator + .validateEdit( + encryptedEvent, + encryptedEditEvent.copy().apply { + mxDecryptionResult = encryptedEditEvent.mxDecryptionResult!!.copy( + payload = mapOf( + "type" to EventType.MESSAGE, + "content" to mapOf( + "body" to "* some message edited", + "msgtype" to "m.text", + "m.relates_to" to mapOf( + "rel_type" to "m.replace", + "event_id" to mockTextEvent.eventId + ) + ) + ) + ) + } + ) shouldBeInstanceOf EventEditValidator.EditValidity.Invalid::class + } + + @Test + fun `The original event must not itself have a rel_type of m_replace`() { + val mockCryptoStore = mockk { + every { deviceWithIdentityKey("R0s/7Aindgg/RNWqUGJyJOXtCz5H7Gx7fInFuroq1xo") } returns + mockk { + every { userId } returns "@alice:example.com" + } + } + val validator = EventEditValidator(mockCryptoStore) + + validator + .validateEdit( + mockTextEvent.copy( + content = mockTextEvent.content!!.toMutableMap().apply { + this["m.relates_to"] = mapOf( + "rel_type" to "m.replace", + "event_id" to mockTextEvent.eventId + ) + } + ), + mockEdit + ) shouldBeInstanceOf EventEditValidator.EditValidity.Invalid::class + + validator + .validateEdit( + encryptedEvent.copy( + content = encryptedEvent.content!!.toMutableMap().apply { + put( + "m.relates_to", + mapOf( + "rel_type" to "m.replace", + "event_id" to mockTextEvent.eventId + ) + ) + } + ).apply { + mxDecryptionResult = encryptedEditEvent.mxDecryptionResult!!.copy( + payload = mapOf( + "type" to EventType.MESSAGE, + "content" to mapOf( + "body" to "some message", + "msgtype" to "m.text", + ), + ) + ) + }, + encryptedEditEvent + ) shouldBeInstanceOf EventEditValidator.EditValidity.Invalid::class + } + + @Test + fun `valid e2ee edit`() { + val mockCryptoStore = mockk { + every { deviceWithIdentityKey("R0s/7Aindgg/RNWqUGJyJOXtCz5H7Gx7fInFuroq1xo") } returns + mockk { + every { userId } returns "@alice:example.com" + } + } + val validator = EventEditValidator(mockCryptoStore) + + validator + .validateEdit( + encryptedEvent, + encryptedEditEvent + ) shouldBeInstanceOf EventEditValidator.EditValidity.Valid::class + } + + @Test + fun `If the original event was encrypted, the replacement should be too`() { + val mockCryptoStore = mockk { + every { deviceWithIdentityKey("R0s/7Aindgg/RNWqUGJyJOXtCz5H7Gx7fInFuroq1xo") } returns + mockk { + every { userId } returns "@alice:example.com" + } + } + val validator = EventEditValidator(mockCryptoStore) + + validator + .validateEdit( + encryptedEvent, + mockEdit + ) shouldBeInstanceOf EventEditValidator.EditValidity.Invalid::class + } + + @Test + fun `encrypted, original event and replacement event must have the same sender`() { + val mockCryptoStore = mockk { + every { deviceWithIdentityKey("R0s/7Aindgg/RNWqUGJyJOXtCz5H7Gx7fInFuroq1xo") } returns + mockk { + every { userId } returns "@alice:example.com" + } + every { deviceWithIdentityKey("7V5e/2O93mf4GeW7Mtq4YWcRNpYS9NhQbdJMgdnIPUI") } returns + mockk { + every { userId } returns "@bob:example.com" + } + } + val validator = EventEditValidator(mockCryptoStore) + + validator + .validateEdit( + encryptedEvent, + encryptedEditEvent.copy().apply { + mxDecryptionResult = encryptedEditEvent.mxDecryptionResult!!.copy( + senderKey = "7V5e/2O93mf4GeW7Mtq4YWcRNpYS9NhQbdJMgdnIPUI" + ) + } + + ) shouldBeInstanceOf EventEditValidator.EditValidity.Invalid::class + + // if sent fom a deleted device it should use the event claimed sender id + } + + @Test + fun `encrypted, sent fom a deleted device, original event and replacement event must have the same sender`() { + val mockCryptoStore = mockk { + every { deviceWithIdentityKey("R0s/7Aindgg/RNWqUGJyJOXtCz5H7Gx7fInFuroq1xo") } returns + mockk { + every { userId } returns "@alice:example.com" + } + every { deviceWithIdentityKey("7V5e/2O93mf4GeW7Mtq4YWcRNpYS9NhQbdJMgdnIPUI") } returns + null + } + val validator = EventEditValidator(mockCryptoStore) + + validator + .validateEdit( + encryptedEvent, + encryptedEditEvent.copy().apply { + mxDecryptionResult = encryptedEditEvent.mxDecryptionResult!!.copy( + senderKey = "7V5e/2O93mf4GeW7Mtq4YWcRNpYS9NhQbdJMgdnIPUI" + ) + } + + ) shouldBeInstanceOf EventEditValidator.EditValidity.Valid::class + + validator + .validateEdit( + encryptedEvent, + encryptedEditEvent.copy( + senderId = "bob@example.com" + ).apply { + mxDecryptionResult = encryptedEditEvent.mxDecryptionResult!!.copy( + senderKey = "7V5e/2O93mf4GeW7Mtq4YWcRNpYS9NhQbdJMgdnIPUI" + ) + } + + ) shouldBeInstanceOf EventEditValidator.EditValidity.Invalid::class + } + + private val encryptedEditEvent = Event( + type = EventType.ENCRYPTED, + eventId = "\$-SF7RWLPzRzCbHqK3ZAhIrX5Auh3B2lS5AqJiypt1p0", + roomId = "!GXKhWsRwiWWvbQDBpe:example.com", + content = mapOf( + "algorithm" to "m.megolm.v1.aes-sha2", + "sender_key" to "R0s/7Aindgg/RNWqUGJyJOXtCz5H7Gx7fInFuroq1xo", + "session_id" to "7tOd6xon/R2zJpy2LlSKcKWIek2jvkim0sNdnZZCWMQ", + "device_id" to "QDHBLWOTSN", + "ciphertext" to "AwgXErAC6TgQ4bV6NFldlffTWuUV1gsBYH6JLQMqG...deLfCQOSPunSSNDFdWuDkB8Cg", + "m.relates_to" to mapOf( + "rel_type" to "m.replace", + "event_id" to mockTextEvent.eventId + ) + ), + originServerTs = 2000, + senderId = "@alice:example.com", + ).apply { + mxDecryptionResult = OlmDecryptionResult( + payload = mapOf( + "type" to EventType.MESSAGE, + "content" to mapOf( + "body" to "* some message edited", + "msgtype" to "m.text", + "m.new_content" to mapOf( + "body" to "some message edited", + "msgtype" to "m.text" + ), + "m.relates_to" to mapOf( + "rel_type" to "m.replace", + "event_id" to mockTextEvent.eventId + ) + ) + ), + senderKey = "R0s/7Aindgg/RNWqUGJyJOXtCz5H7Gx7fInFuroq1xo", + isSafe = true + ) + } + + private val encryptedEvent = Event( + type = EventType.ENCRYPTED, + eventId = "\$WX8WlNC2reiXrwHIA_CQHmU_pSR-jhOA2xKPRcJN9wQ", + roomId = "!GXKhWsRwiWWvbQDBpe:example.com", + content = mapOf( + "algorithm" to "m.megolm.v1.aes-sha2", + "sender_key" to "R0s/7Aindgg/RNWqUGJyJOXtCz5H7Gx7fInFuroq1xo", + "session_id" to "7tOd6xon/R2zJpy2LlSKcKWIek2jvkim0sNdnZZCWMQ", + "device_id" to "QDHBLWOTSN", + "ciphertext" to "AwgXErAC6TgQ4bV6NFldlffTWuUV1gsBYH6JLQMqG+4Vr...Yf0gYyhVWZY4SedF3fTMwkjmTuel4fwrmq", + ), + originServerTs = 2000, + senderId = "@alice:example.com", + ).apply { + mxDecryptionResult = OlmDecryptionResult( + payload = mapOf( + "type" to EventType.MESSAGE, + "content" to mapOf( + "body" to "some message", + "msgtype" to "m.text" + ), + ), + senderKey = "R0s/7Aindgg/RNWqUGJyJOXtCz5H7Gx7fInFuroq1xo", + isSafe = true + ) + } +} diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/aggregation/poll/PollAggregationProcessorTest.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/aggregation/poll/DefaultPollAggregationProcessorTest.kt similarity index 79% rename from matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/aggregation/poll/PollAggregationProcessorTest.kt rename to matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/aggregation/poll/DefaultPollAggregationProcessorTest.kt index 3044ca5d43..0888d82907 100644 --- a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/aggregation/poll/PollAggregationProcessorTest.kt +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/aggregation/poll/DefaultPollAggregationProcessorTest.kt @@ -16,9 +16,13 @@ package org.matrix.android.sdk.internal.session.room.aggregation.poll +import io.mockk.coVerify import io.mockk.every import io.mockk.mockk import io.realm.RealmList +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest import org.amshove.kluent.shouldBeFalse import org.amshove.kluent.shouldBeTrue import org.junit.Before @@ -34,6 +38,7 @@ import org.matrix.android.sdk.internal.database.model.PollResponseAggregatedSumm import org.matrix.android.sdk.internal.session.room.aggregation.poll.PollEventsTestData.AN_EVENT_ID import org.matrix.android.sdk.internal.session.room.aggregation.poll.PollEventsTestData.AN_INVALID_POLL_RESPONSE_EVENT import org.matrix.android.sdk.internal.session.room.aggregation.poll.PollEventsTestData.A_BROKEN_POLL_REPLACE_EVENT +import org.matrix.android.sdk.internal.session.room.aggregation.poll.PollEventsTestData.A_POLL_END_CONTENT import org.matrix.android.sdk.internal.session.room.aggregation.poll.PollEventsTestData.A_POLL_END_EVENT import org.matrix.android.sdk.internal.session.room.aggregation.poll.PollEventsTestData.A_POLL_REFERENCE_EVENT import org.matrix.android.sdk.internal.session.room.aggregation.poll.PollEventsTestData.A_POLL_REPLACE_EVENT @@ -43,13 +48,22 @@ import org.matrix.android.sdk.internal.session.room.aggregation.poll.PollEventsT import org.matrix.android.sdk.internal.session.room.aggregation.poll.PollEventsTestData.A_ROOM_ID import org.matrix.android.sdk.internal.session.room.aggregation.poll.PollEventsTestData.A_TIMELINE_EVENT import org.matrix.android.sdk.internal.session.room.aggregation.poll.PollEventsTestData.A_USER_ID_1 +import org.matrix.android.sdk.internal.session.room.relation.poll.FetchPollResponseEventsTask +import org.matrix.android.sdk.test.fakes.FakeFetchPollResponseEventsTask import org.matrix.android.sdk.test.fakes.FakeRealm +import org.matrix.android.sdk.test.fakes.FakeTaskExecutor import org.matrix.android.sdk.test.fakes.givenEqualTo import org.matrix.android.sdk.test.fakes.givenFindFirst -class PollAggregationProcessorTest { +@OptIn(ExperimentalCoroutinesApi::class) +class DefaultPollAggregationProcessorTest { - private val pollAggregationProcessor: PollAggregationProcessor = DefaultPollAggregationProcessor() + private val fakeTaskExecutor = FakeTaskExecutor() + private val fakeFetchPollResponseEventsTask = FakeFetchPollResponseEventsTask() + private val pollAggregationProcessor: PollAggregationProcessor = DefaultPollAggregationProcessor( + taskExecutor = fakeTaskExecutor.instance, + fetchPollResponseEventsTask = fakeFetchPollResponseEventsTask + ) private val realm = FakeRealm() private val session = mockk() @@ -114,16 +128,28 @@ class PollAggregationProcessorTest { } @Test - fun `given a poll end event, when processing, then is processed and return true`() { + fun `given a poll end event, when processing, then is processed and return true`() = runTest { + // Given every { realm.instance.createObject(PollResponseAggregatedSummaryEntity::class.java) } returns PollResponseAggregatedSummaryEntity() + every { fakeTaskExecutor.instance.executorScope } returns this + + // When val powerLevelsHelper = mockRedactionPowerLevels(A_USER_ID_1, true) + + // Then pollAggregationProcessor.handlePollEndEvent(session, powerLevelsHelper, realm.instance, A_POLL_END_EVENT).shouldBeTrue() } @Test - fun `given a poll end event for my own poll without enough redaction power level, when processing, then is processed and returns true`() { + fun `given a poll end event for my own poll without enough redaction power level, when processing, then is processed and returns true`() = runTest { + // Given every { realm.instance.createObject(PollResponseAggregatedSummaryEntity::class.java) } returns PollResponseAggregatedSummaryEntity() + every { fakeTaskExecutor.instance.executorScope } returns this + + // When val powerLevelsHelper = mockRedactionPowerLevels(A_USER_ID_1, false) + + // Then pollAggregationProcessor.handlePollEndEvent(session, powerLevelsHelper, realm.instance, A_POLL_END_EVENT).shouldBeTrue() } @@ -135,6 +161,28 @@ class PollAggregationProcessorTest { pollAggregationProcessor.handlePollEndEvent(session, powerLevelsHelper, realm.instance, event).shouldBeFalse() } + @Test + fun `given a non local echo poll end event, when is processed, then ensure to aggregate all poll responses`() = runTest { + // Given + every { realm.instance.createObject(PollResponseAggregatedSummaryEntity::class.java) } returns PollResponseAggregatedSummaryEntity() + val powerLevelsHelper = mockRedactionPowerLevels("another-sender-id", true) + val event = A_POLL_END_EVENT.copy(senderId = "another-sender-id") + every { fakeTaskExecutor.instance.executorScope } returns this + val expectedParams = FetchPollResponseEventsTask.Params( + roomId = A_POLL_END_EVENT.roomId.orEmpty(), + startPollEventId = A_POLL_END_CONTENT.relatesTo?.eventId.orEmpty(), + ) + + // When + pollAggregationProcessor.handlePollEndEvent(session, powerLevelsHelper, realm.instance, event) + advanceUntilIdle() + + // Then + coVerify { + fakeFetchPollResponseEventsTask.execute(expectedParams) + } + } + private fun mockEventAnnotationsSummaryEntity() { realm.givenWhere() .givenFindFirst(EventAnnotationsSummaryEntity()) diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/aggregation/poll/PollEventsTestData.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/aggregation/poll/PollEventsTestData.kt index 129d49633e..e38b51132d 100644 --- a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/aggregation/poll/PollEventsTestData.kt +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/aggregation/poll/PollEventsTestData.kt @@ -87,7 +87,7 @@ object PollEventsTestData { ) internal val A_POLL_START_EVENT = Event( - type = EventType.POLL_START.first(), + type = EventType.POLL_START.unstable, eventId = AN_EVENT_ID, originServerTs = 1652435922563, senderId = A_USER_ID_1, @@ -96,7 +96,7 @@ object PollEventsTestData { ) internal val A_POLL_RESPONSE_EVENT = Event( - type = EventType.POLL_RESPONSE.first(), + type = EventType.POLL_RESPONSE.unstable, eventId = AN_EVENT_ID, originServerTs = 1652435922563, senderId = A_USER_ID_1, @@ -105,7 +105,7 @@ object PollEventsTestData { ) internal val A_POLL_END_EVENT = Event( - type = EventType.POLL_END.first(), + type = EventType.POLL_END.unstable, eventId = AN_EVENT_ID, originServerTs = 1652435922563, senderId = A_USER_ID_1, diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/location/DefaultGetActiveBeaconInfoForUserTaskTest.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/location/DefaultGetActiveBeaconInfoForUserTaskTest.kt index d51ed77399..6f416a6bc1 100644 --- a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/location/DefaultGetActiveBeaconInfoForUserTaskTest.kt +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/location/DefaultGetActiveBeaconInfoForUserTaskTest.kt @@ -69,7 +69,7 @@ class DefaultGetActiveBeaconInfoForUserTaskTest { result shouldBeEqualTo currentStateEvent fakeStateEventDataSource.verifyGetStateEvent( roomId = params.roomId, - eventType = EventType.STATE_ROOM_BEACON_INFO.first(), + eventType = EventType.STATE_ROOM_BEACON_INFO.unstable, stateKey = QueryStringValue.Equals(A_USER_ID) ) } diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/location/DefaultLocationSharingServiceTest.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/location/DefaultLocationSharingServiceTest.kt index a01f51604c..1f15a9bee8 100644 --- a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/location/DefaultLocationSharingServiceTest.kt +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/location/DefaultLocationSharingServiceTest.kt @@ -53,7 +53,6 @@ private const val A_LATITUDE = 1.4 private const val A_LONGITUDE = 40.0 private const val AN_UNCERTAINTY = 5.0 private const val A_TIMEOUT = 15_000L -private const val A_DESCRIPTION = "description" private const val A_REASON = "reason" @ExperimentalCoroutinesApi @@ -143,7 +142,7 @@ internal class DefaultLocationSharingServiceTest { coEvery { stopLiveLocationShareTask.execute(any()) } returns UpdateLiveLocationShareResult.Success("stopped-event-id") coEvery { startLiveLocationShareTask.execute(any()) } returns UpdateLiveLocationShareResult.Success(AN_EVENT_ID) - val result = defaultLocationSharingService.startLiveLocationShare(A_TIMEOUT, A_DESCRIPTION) + val result = defaultLocationSharingService.startLiveLocationShare(A_TIMEOUT) result shouldBeEqualTo UpdateLiveLocationShareResult.Success(AN_EVENT_ID) val expectedCheckExistingParams = CheckIfExistingActiveLiveTask.Params( @@ -157,7 +156,6 @@ internal class DefaultLocationSharingServiceTest { val expectedStartParams = StartLiveLocationShareTask.Params( roomId = A_ROOM_ID, timeoutMillis = A_TIMEOUT, - description = A_DESCRIPTION ) coVerify { startLiveLocationShareTask.execute(expectedStartParams) } } @@ -168,7 +166,7 @@ internal class DefaultLocationSharingServiceTest { val error = Throwable() coEvery { stopLiveLocationShareTask.execute(any()) } returns UpdateLiveLocationShareResult.Failure(error) - val result = defaultLocationSharingService.startLiveLocationShare(A_TIMEOUT, A_DESCRIPTION) + val result = defaultLocationSharingService.startLiveLocationShare(A_TIMEOUT) result shouldBeEqualTo UpdateLiveLocationShareResult.Failure(error) val expectedCheckExistingParams = CheckIfExistingActiveLiveTask.Params( @@ -186,7 +184,7 @@ internal class DefaultLocationSharingServiceTest { coEvery { checkIfExistingActiveLiveTask.execute(any()) } returns false coEvery { startLiveLocationShareTask.execute(any()) } returns UpdateLiveLocationShareResult.Success(AN_EVENT_ID) - val result = defaultLocationSharingService.startLiveLocationShare(A_TIMEOUT, A_DESCRIPTION) + val result = defaultLocationSharingService.startLiveLocationShare(A_TIMEOUT) result shouldBeEqualTo UpdateLiveLocationShareResult.Success(AN_EVENT_ID) val expectedCheckExistingParams = CheckIfExistingActiveLiveTask.Params( @@ -196,7 +194,6 @@ internal class DefaultLocationSharingServiceTest { val expectedStartParams = StartLiveLocationShareTask.Params( roomId = A_ROOM_ID, timeoutMillis = A_TIMEOUT, - description = A_DESCRIPTION ) coVerify { startLiveLocationShareTask.execute(expectedStartParams) } } diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/location/DefaultStartLiveLocationShareTaskTest.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/location/DefaultStartLiveLocationShareTaskTest.kt index aa8826243f..3156287774 100644 --- a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/location/DefaultStartLiveLocationShareTaskTest.kt +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/location/DefaultStartLiveLocationShareTaskTest.kt @@ -34,7 +34,6 @@ import org.matrix.android.sdk.test.fakes.FakeSendStateTask private const val A_USER_ID = "user-id" private const val A_ROOM_ID = "room-id" private const val AN_EVENT_ID = "event-id" -private const val A_DESCRIPTION = "description" private const val A_TIMEOUT = 15_000L private const val AN_EPOCH = 1655210176L @@ -60,7 +59,6 @@ internal class DefaultStartLiveLocationShareTaskTest { val params = StartLiveLocationShareTask.Params( roomId = A_ROOM_ID, timeoutMillis = A_TIMEOUT, - description = A_DESCRIPTION ) fakeClock.givenEpoch(AN_EPOCH) fakeSendStateTask.givenExecuteRetryReturns(AN_EVENT_ID) @@ -69,7 +67,7 @@ internal class DefaultStartLiveLocationShareTaskTest { result shouldBeEqualTo UpdateLiveLocationShareResult.Success(AN_EVENT_ID) val expectedBeaconContent = MessageBeaconInfoContent( - body = A_DESCRIPTION, + body = "Live location", timeout = params.timeoutMillis, isLive = true, unstableTimestampMillis = AN_EPOCH @@ -77,7 +75,7 @@ internal class DefaultStartLiveLocationShareTaskTest { val expectedParams = SendStateTask.Params( roomId = params.roomId, stateKey = A_USER_ID, - eventType = EventType.STATE_ROOM_BEACON_INFO.first(), + eventType = EventType.STATE_ROOM_BEACON_INFO.unstable, body = expectedBeaconContent ) fakeSendStateTask.verifyExecuteRetry( @@ -91,7 +89,6 @@ internal class DefaultStartLiveLocationShareTaskTest { val params = StartLiveLocationShareTask.Params( roomId = A_ROOM_ID, timeoutMillis = A_TIMEOUT, - description = A_DESCRIPTION ) fakeClock.givenEpoch(AN_EPOCH) fakeSendStateTask.givenExecuteRetryReturns("") @@ -106,7 +103,6 @@ internal class DefaultStartLiveLocationShareTaskTest { val params = StartLiveLocationShareTask.Params( roomId = A_ROOM_ID, timeoutMillis = A_TIMEOUT, - description = A_DESCRIPTION ) fakeClock.givenEpoch(AN_EPOCH) val error = Throwable() diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/location/DefaultStopLiveLocationShareTaskTest.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/location/DefaultStopLiveLocationShareTaskTest.kt index 1abf179ccf..03c6f525e0 100644 --- a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/location/DefaultStopLiveLocationShareTaskTest.kt +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/location/DefaultStopLiveLocationShareTaskTest.kt @@ -79,7 +79,7 @@ class DefaultStopLiveLocationShareTaskTest { val expectedSendParams = SendStateTask.Params( roomId = params.roomId, stateKey = A_USER_ID, - eventType = EventType.STATE_ROOM_BEACON_INFO.first(), + eventType = EventType.STATE_ROOM_BEACON_INFO.unstable, body = expectedBeaconContent ) fakeSendStateTask.verifyExecuteRetry( diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/location/LiveLocationShareRedactionEventProcessorTest.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/location/LiveLocationShareRedactionEventProcessorTest.kt index 24d9c30039..8dc7a5c9bc 100644 --- a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/location/LiveLocationShareRedactionEventProcessorTest.kt +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/location/LiveLocationShareRedactionEventProcessorTest.kt @@ -79,7 +79,7 @@ class LiveLocationShareRedactionEventProcessorTest { @Test fun `given a redacted live location share event when processing it then related summaries are deleted from database`() = runTest { val event = Event(eventId = AN_EVENT_ID, redacts = A_REDACTED_EVENT_ID) - val redactedEventEntity = EventEntity(eventId = A_REDACTED_EVENT_ID, type = EventType.STATE_ROOM_BEACON_INFO.first()) + val redactedEventEntity = EventEntity(eventId = A_REDACTED_EVENT_ID, type = EventType.STATE_ROOM_BEACON_INFO.unstable) fakeRealm.givenWhere() .givenEqualTo(EventEntityFields.EVENT_ID, A_REDACTED_EVENT_ID) .givenFindFirst(redactedEventEntity) diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/relation/poll/DefaultFetchPollResponseEventsTaskTest.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/relation/poll/DefaultFetchPollResponseEventsTaskTest.kt new file mode 100644 index 0000000000..8d50bac38f --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/relation/poll/DefaultFetchPollResponseEventsTaskTest.kt @@ -0,0 +1,163 @@ +/* + * Copyright (c) 2022 The Matrix.org Foundation C.I.C. + * + * 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 org.matrix.android.sdk.internal.session.room.relation.poll + +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import io.mockk.verify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.matrix.android.sdk.api.session.events.model.Event +import org.matrix.android.sdk.api.session.events.model.RelationType +import org.matrix.android.sdk.api.session.events.model.isPollResponse +import org.matrix.android.sdk.api.session.room.send.SendState +import org.matrix.android.sdk.internal.database.mapper.toEntity +import org.matrix.android.sdk.internal.database.model.EventEntity +import org.matrix.android.sdk.internal.database.model.EventEntityFields +import org.matrix.android.sdk.internal.database.model.EventInsertType +import org.matrix.android.sdk.internal.database.query.copyToRealmOrIgnore +import org.matrix.android.sdk.internal.session.room.relation.RelationsResponse +import org.matrix.android.sdk.test.fakes.FakeClock +import org.matrix.android.sdk.test.fakes.FakeEventDecryptor +import org.matrix.android.sdk.test.fakes.FakeGlobalErrorReceiver +import org.matrix.android.sdk.test.fakes.FakeMonarchy +import org.matrix.android.sdk.test.fakes.FakeRoomApi +import org.matrix.android.sdk.test.fakes.givenFindAll +import org.matrix.android.sdk.test.fakes.givenIn + +@OptIn(ExperimentalCoroutinesApi::class) +internal class DefaultFetchPollResponseEventsTaskTest { + + private val fakeRoomAPI = FakeRoomApi() + private val fakeGlobalErrorReceiver = FakeGlobalErrorReceiver() + private val fakeMonarchy = FakeMonarchy() + private val fakeClock = FakeClock() + private val fakeEventDecryptor = FakeEventDecryptor() + + private val defaultFetchPollResponseEventsTask = DefaultFetchPollResponseEventsTask( + roomAPI = fakeRoomAPI.instance, + globalErrorReceiver = fakeGlobalErrorReceiver, + monarchy = fakeMonarchy.instance, + clock = fakeClock, + eventDecryptor = fakeEventDecryptor.instance, + ) + + @Before + fun setup() { + mockkStatic("org.matrix.android.sdk.api.session.events.model.EventKt") + mockkStatic("org.matrix.android.sdk.internal.database.mapper.EventMapperKt") + mockkStatic("org.matrix.android.sdk.internal.database.query.EventEntityQueriesKt") + } + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun `given a room and a poll when execute then fetch related events and store them in local if needed`() = runTest { + // Given + val aRoomId = "roomId" + val aPollEventId = "eventId" + val params = givenTaskParams(roomId = aRoomId, eventId = aPollEventId) + val aNextBatchToken = "nextBatch" + val anEventId1 = "eventId1" + val anEventId2 = "eventId2" + val anEventId3 = "eventId3" + val anEventId4 = "eventId4" + val event1 = givenAnEvent(eventId = anEventId1, isPollResponse = true, isEncrypted = true) + val event2 = givenAnEvent(eventId = anEventId2, isPollResponse = true, isEncrypted = true) + val event3 = givenAnEvent(eventId = anEventId3, isPollResponse = false, isEncrypted = false) + val event4 = givenAnEvent(eventId = anEventId4, isPollResponse = false, isEncrypted = false) + val firstEvents = listOf(event1, event2) + val secondEvents = listOf(event3, event4) + val firstResponse = givenARelationsResponse(events = firstEvents, nextBatch = aNextBatchToken) + fakeRoomAPI.givenGetRelationsReturns(from = null, relationsResponse = firstResponse) + val secondResponse = givenARelationsResponse(events = secondEvents, nextBatch = null) + fakeRoomAPI.givenGetRelationsReturns(from = aNextBatchToken, relationsResponse = secondResponse) + fakeEventDecryptor.givenDecryptEventAndSaveResultSuccess(event1) + fakeEventDecryptor.givenDecryptEventAndSaveResultSuccess(event2) + fakeClock.givenEpoch(123) + givenExistingEventEntities(eventIdsToCheck = listOf(anEventId1, anEventId2), existingIds = listOf(anEventId1)) + val eventEntityToSave = EventEntity(eventId = anEventId2) + every { event2.toEntity(any(), any(), any()) } returns eventEntityToSave + every { eventEntityToSave.copyToRealmOrIgnore(any(), any()) } returns eventEntityToSave + + // When + defaultFetchPollResponseEventsTask.execute(params) + + // Then + fakeRoomAPI.verifyGetRelations( + roomId = params.roomId, + eventId = params.startPollEventId, + relationType = RelationType.REFERENCE, + from = null, + limit = FETCH_RELATED_EVENTS_LIMIT + ) + fakeRoomAPI.verifyGetRelations( + roomId = params.roomId, + eventId = params.startPollEventId, + relationType = RelationType.REFERENCE, + from = aNextBatchToken, + limit = FETCH_RELATED_EVENTS_LIMIT + ) + fakeEventDecryptor.verifyDecryptEventAndSaveResult(event1, timeline = "") + fakeEventDecryptor.verifyDecryptEventAndSaveResult(event2, timeline = "") + // Check we save in DB the event2 which is a non stored poll response + verify { + event2.toEntity(aRoomId, SendState.SYNCED, any()) + eventEntityToSave.copyToRealmOrIgnore(fakeMonarchy.fakeRealm.instance, EventInsertType.PAGINATION) + } + } + + private fun givenTaskParams(roomId: String, eventId: String) = FetchPollResponseEventsTask.Params( + roomId = roomId, + startPollEventId = eventId, + ) + + private fun givenARelationsResponse(events: List, nextBatch: String?): RelationsResponse { + return RelationsResponse( + chunks = events, + nextBatch = nextBatch, + prevBatch = null, + ) + } + + private fun givenAnEvent( + eventId: String, + isPollResponse: Boolean, + isEncrypted: Boolean, + ): Event { + val event = mockk(relaxed = true) + every { event.eventId } returns eventId + every { event.isPollResponse() } returns isPollResponse + every { event.isEncrypted() } returns isEncrypted + return event + } + + private fun givenExistingEventEntities(eventIdsToCheck: List, existingIds: List) { + val eventEntities = existingIds.map { EventEntity(eventId = it) } + fakeMonarchy.givenWhere() + .givenIn(EventEntityFields.EVENT_ID, eventIdsToCheck) + .givenFindAll(eventEntities) + } +} diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/send/LocalEchoEventFactoryTests.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/send/LocalEchoEventFactoryTests.kt new file mode 100644 index 0000000000..19f58d690f --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/send/LocalEchoEventFactoryTests.kt @@ -0,0 +1,237 @@ +/* + * Copyright (c) 2022 The Matrix.org Foundation C.I.C. + * + * 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 org.matrix.android.sdk.internal.session.room.send + +import org.amshove.kluent.internal.assertEquals +import org.junit.Before +import org.junit.Test +import org.matrix.android.sdk.api.session.events.model.Event +import org.matrix.android.sdk.api.session.events.model.EventType +import org.matrix.android.sdk.api.session.events.model.toContent +import org.matrix.android.sdk.api.session.events.model.toModel +import org.matrix.android.sdk.api.session.room.model.message.MessageContent +import org.matrix.android.sdk.api.session.room.model.message.MessageContentWithFormattedBody +import org.matrix.android.sdk.api.session.room.sender.SenderInfo +import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent +import org.matrix.android.sdk.api.util.TextContent +import org.matrix.android.sdk.test.fakes.FakeClock +import org.matrix.android.sdk.test.fakes.FakeContext +import org.matrix.android.sdk.test.fakes.internal.session.content.FakeThumbnailExtractor +import org.matrix.android.sdk.test.fakes.internal.session.permalinks.FakePermalinkFactory +import org.matrix.android.sdk.test.fakes.internal.session.room.send.FakeLocalEchoRepository +import org.matrix.android.sdk.test.fakes.internal.session.room.send.FakeMarkdownParser +import org.matrix.android.sdk.test.fakes.internal.session.room.send.FakeWaveFormSanitizer +import org.matrix.android.sdk.test.fakes.internal.session.room.send.pills.FakeTextPillsUtils + +@Suppress("MaxLineLength") +class LocalEchoEventFactoryTests { + + companion object { + internal const val A_USER_ID_1 = "@user_1:matrix.org" + internal const val A_ROOM_ID = "!sUeOGZKsBValPTUMax:matrix.org" + internal const val AN_EVENT_ID = "\$vApgexcL8Vfh-WxYKsFKCDooo67ttbjm3TiVKXaWijU" + internal const val AN_EPOCH = 1655210176L + + val A_START_EVENT = Event( + type = EventType.STATE_ROOM_CREATE, + eventId = AN_EVENT_ID, + originServerTs = 1652435922563, + senderId = A_USER_ID_1, + roomId = A_ROOM_ID + ) + } + + private val fakeContext = FakeContext() + private val fakeMarkdownParser = FakeMarkdownParser() + private val fakeTextPillsUtils = FakeTextPillsUtils() + private val fakeThumbnailExtractor = FakeThumbnailExtractor() + private val fakeWaveFormSanitizer = FakeWaveFormSanitizer() + private val fakeLocalEchoRepository = FakeLocalEchoRepository() + private val fakePermalinkFactory = FakePermalinkFactory() + private val fakeClock = FakeClock() + + private val localEchoEventFactory = LocalEchoEventFactory( + context = fakeContext.instance, + userId = A_USER_ID_1, + markdownParser = fakeMarkdownParser.instance, + textPillsUtils = fakeTextPillsUtils.instance, + thumbnailExtractor = fakeThumbnailExtractor.instance, + waveformSanitizer = fakeWaveFormSanitizer.instance, + localEchoRepository = fakeLocalEchoRepository.instance, + permalinkFactory = fakePermalinkFactory.instance, + clock = fakeClock + ) + + @Before + fun setup() { + fakeClock.givenEpoch(AN_EPOCH) + fakeMarkdownParser.givenBoldMarkdown() + } + + @Test + fun `given a null quotedText, when a quote event is created, then the result message should only contain the new text after new lines`() { + val event = createTimelineEvent(null, null) + val quotedContent = localEchoEventFactory.createQuotedTextEvent( + roomId = A_ROOM_ID, + quotedEvent = event, + text = "Text", + formattedText = null, + autoMarkdown = false, + rootThreadEventId = null, + additionalContent = null, + ).content.toModel() + assertEquals("\n\nText", quotedContent?.body) + assertEquals("
Text", (quotedContent as? MessageContentWithFormattedBody)?.formattedBody) + } + + @Test + fun `given a plain text quoted message, when a quote event is created, then the result message should contain both the quoted and new text`() { + val event = createTimelineEvent("Quoted", null) + val quotedContent = localEchoEventFactory.createQuotedTextEvent( + roomId = A_ROOM_ID, + quotedEvent = event, + text = "Text", + formattedText = null, + autoMarkdown = false, + rootThreadEventId = null, + additionalContent = null, + ).content.toModel() + assertEquals("> Quoted\n\nText", quotedContent?.body) + assertEquals("
Quoted

Text", (quotedContent as? MessageContentWithFormattedBody)?.formattedBody) + } + + @Test + fun `given a formatted text quoted message, when a quote event is created, then the result message should contain both the formatted quote and new text`() { + val event = createTimelineEvent("Quoted", "Quoted") + val quotedContent = localEchoEventFactory.createQuotedTextEvent( + roomId = A_ROOM_ID, + quotedEvent = event, + text = "Text", + formattedText = null, + autoMarkdown = false, + rootThreadEventId = null, + additionalContent = null, + ).content.toModel() + // This still uses the plain text version + assertEquals("> Quoted\n\nText", quotedContent?.body) + // This one has the formatted one + assertEquals("
Quoted

Text", (quotedContent as? MessageContentWithFormattedBody)?.formattedBody) + } + + @Test + fun `given formatted text quoted message and new message, when a quote event is created, then the result message should contain both the formatted quote and new formatted text`() { + val event = createTimelineEvent("Quoted", "Quoted") + val quotedContent = localEchoEventFactory.createQuotedTextEvent( + roomId = A_ROOM_ID, + quotedEvent = event, + text = "Text", + formattedText = "Formatted text", + autoMarkdown = false, + rootThreadEventId = null, + additionalContent = null, + ).content.toModel() + // This still uses the plain text version + assertEquals("> Quoted\n\nText", quotedContent?.body) + // This one has the formatted one + assertEquals( + "
Quoted

Formatted text", + (quotedContent as? MessageContentWithFormattedBody)?.formattedBody + ) + } + + @Test + fun `given formatted text quoted message and new message with autoMarkdown, when a quote event is created, then the result message should contain both the formatted quote and new formatted text, not the markdown processed text`() { + val event = createTimelineEvent("Quoted", "Quoted") + val quotedContent = localEchoEventFactory.createQuotedTextEvent( + roomId = A_ROOM_ID, + quotedEvent = event, + text = "Text", + formattedText = "Formatted text", + autoMarkdown = true, + rootThreadEventId = null, + additionalContent = null, + ).content.toModel() + // This still uses the plain text version + assertEquals("> Quoted\n\nText", quotedContent?.body) + // This one has the formatted one + assertEquals( + "
Quoted

Formatted text", + (quotedContent as? MessageContentWithFormattedBody)?.formattedBody + ) + } + + @Test + fun `given a formatted text quoted message and a new message with autoMarkdown, when a quote event is created, then the result message should contain both the formatted quote and new processed formatted text`() { + val event = createTimelineEvent("Quoted", "Quoted") + val quotedContent = localEchoEventFactory.createQuotedTextEvent( + roomId = A_ROOM_ID, + quotedEvent = event, + text = "**Text**", + formattedText = null, + autoMarkdown = true, + rootThreadEventId = null, + additionalContent = null, + ).content.toModel() + // This still uses the markdown text version + assertEquals("> Quoted\n\n**Text**", quotedContent?.body) + // This one has the formatted one + assertEquals( + "
Quoted

Text", + (quotedContent as? MessageContentWithFormattedBody)?.formattedBody + ) + } + + @Test + fun `given a plain text quoted message and a new message with autoMarkdown, when a quote event is created, then the result message should the plain text quote and new processed formatted text`() { + val event = createTimelineEvent("Quoted", null) + val quotedContent = localEchoEventFactory.createQuotedTextEvent( + roomId = A_ROOM_ID, + quotedEvent = event, + text = "**Text**", + formattedText = null, + autoMarkdown = true, + rootThreadEventId = null, + additionalContent = null, + ).content.toModel() + // This still uses the markdown text version + assertEquals("> Quoted\n\n**Text**", quotedContent?.body) + // This one has the formatted one + assertEquals( + "
Quoted

Text", + (quotedContent as? MessageContentWithFormattedBody)?.formattedBody + ) + } + + private fun createTimelineEvent(quotedText: String?, formattedQuotedText: String?): TimelineEvent { + val textContent = quotedText?.let { + TextContent( + quotedText, + formattedQuotedText + ).toMessageTextContent().toContent() + } + return TimelineEvent( + root = A_START_EVENT.copy( + type = EventType.MESSAGE, + content = textContent + ), + localId = 1234, + eventId = AN_EVENT_ID, + displayIndex = 0, + senderInfo = SenderInfo(A_USER_ID_1, A_USER_ID_1, true, null), + ) + } +} diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/user/accountdata/DefaultDeleteUserAccountDataTaskTest.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/user/accountdata/DefaultDeleteUserAccountDataTaskTest.kt new file mode 100644 index 0000000000..86580127dc --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/user/accountdata/DefaultDeleteUserAccountDataTaskTest.kt @@ -0,0 +1,53 @@ +/* + * Copyright 2022 The Matrix.org Foundation C.I.C. + * + * 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 org.matrix.android.sdk.internal.session.user.accountdata + +import io.mockk.coVerify +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.matrix.android.sdk.test.fakes.FakeAccountDataApi +import org.matrix.android.sdk.test.fakes.FakeGlobalErrorReceiver + +private const val A_TYPE = "a-type" +private const val A_USER_ID = "a-user-id" + +@ExperimentalCoroutinesApi +class DefaultDeleteUserAccountDataTaskTest { + + private val fakeGlobalErrorReceiver = FakeGlobalErrorReceiver() + private val fakeAccountDataApi = FakeAccountDataApi() + + private val deleteUserAccountDataTask = DefaultDeleteUserAccountDataTask( + accountDataApi = fakeAccountDataApi.instance, + userId = A_USER_ID, + globalErrorReceiver = fakeGlobalErrorReceiver + ) + + @Test + fun `given parameters when executing the task then api is called`() = runTest { + // Given + val params = DeleteUserAccountDataTask.Params(type = A_TYPE) + fakeAccountDataApi.givenParamsToDeleteAccountData(A_USER_ID, A_TYPE) + + // When + deleteUserAccountDataTask.execute(params) + + // Then + coVerify { fakeAccountDataApi.instance.deleteAccountData(A_USER_ID, A_TYPE) } + } +} diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/sync/DefaultGetCurrentFilterTaskTest.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/sync/DefaultGetCurrentFilterTaskTest.kt new file mode 100644 index 0000000000..f3ab65f6c4 --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/sync/DefaultGetCurrentFilterTaskTest.kt @@ -0,0 +1,101 @@ +/* + * Copyright 2022 The Matrix.org Foundation C.I.C. + * + * 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 org.matrix.android.sdk.internal.sync + +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.amshove.kluent.shouldBeEqualTo +import org.junit.Test +import org.matrix.android.sdk.api.MatrixConfiguration +import org.matrix.android.sdk.api.SyncConfig +import org.matrix.android.sdk.api.session.homeserver.HomeServerCapabilities +import org.matrix.android.sdk.api.session.sync.filter.SyncFilterParams +import org.matrix.android.sdk.internal.session.filter.DefaultGetCurrentFilterTask +import org.matrix.android.sdk.internal.sync.filter.SyncFilterBuilder +import org.matrix.android.sdk.test.fakes.FakeFilterRepository +import org.matrix.android.sdk.test.fakes.FakeHomeServerCapabilitiesDataSource +import org.matrix.android.sdk.test.fakes.FakeSaveFilterTask + +private const val A_FILTER_ID = "filter-id" +private val A_HOMESERVER_CAPABILITIES = HomeServerCapabilities() +private val A_SYNC_FILTER_PARAMS = SyncFilterParams( + lazyLoadMembersForStateEvents = true, + useThreadNotifications = true +) + +@ExperimentalCoroutinesApi +class DefaultGetCurrentFilterTaskTest { + + private val filterRepository = FakeFilterRepository() + private val homeServerCapabilitiesDataSource = FakeHomeServerCapabilitiesDataSource() + private val saveFilterTask = FakeSaveFilterTask() + + private val getCurrentFilterTask = DefaultGetCurrentFilterTask( + filterRepository = filterRepository, + homeServerCapabilitiesDataSource = homeServerCapabilitiesDataSource.instance, + saveFilterTask = saveFilterTask, + matrixConfiguration = MatrixConfiguration( + applicationFlavor = "TestFlavor", + roomDisplayNameFallbackProvider = mockk(), + syncConfig = SyncConfig(syncFilterParams = SyncFilterParams(lazyLoadMembersForStateEvents = true, useThreadNotifications = true)), + ) + ) + + @Test + fun `given no filter is stored, when execute, then executes task to save new filter`() = runTest { + homeServerCapabilitiesDataSource.givenHomeServerCapabilities(A_HOMESERVER_CAPABILITIES) + + filterRepository.givenFilterStored(null, null) + + getCurrentFilterTask.execute(Unit) + + val filter = SyncFilterBuilder() + .with(A_SYNC_FILTER_PARAMS) + .build(A_HOMESERVER_CAPABILITIES) + + saveFilterTask.verifyExecution(filter) + } + + @Test + fun `given filter is stored and didn't change, when execute, then returns stored filter id`() = runTest { + homeServerCapabilitiesDataSource.givenHomeServerCapabilities(A_HOMESERVER_CAPABILITIES) + + val filter = SyncFilterBuilder().with(A_SYNC_FILTER_PARAMS).build(A_HOMESERVER_CAPABILITIES) + filterRepository.givenFilterStored(A_FILTER_ID, filter.toJSONString()) + + val result = getCurrentFilterTask.execute(Unit) + + result shouldBeEqualTo A_FILTER_ID + } + + @Test + fun `given filter is set and home server capabilities has changed, when execute, then executes task to save new filter`() = runTest { + homeServerCapabilitiesDataSource.givenHomeServerCapabilities(A_HOMESERVER_CAPABILITIES) + + val filter = SyncFilterBuilder().with(A_SYNC_FILTER_PARAMS).build(A_HOMESERVER_CAPABILITIES) + filterRepository.givenFilterStored(A_FILTER_ID, filter.toJSONString()) + + val newHomeServerCapabilities = HomeServerCapabilities(canUseThreadReadReceiptsAndNotifications = true) + homeServerCapabilitiesDataSource.givenHomeServerCapabilities(newHomeServerCapabilities) + val newFilter = SyncFilterBuilder().with(A_SYNC_FILTER_PARAMS).build(newHomeServerCapabilities) + + getCurrentFilterTask.execute(Unit) + + saveFilterTask.verifyExecution(newFilter) + } +} diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeAccountDataApi.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeAccountDataApi.kt new file mode 100644 index 0000000000..f3acc02458 --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeAccountDataApi.kt @@ -0,0 +1,32 @@ +/* + * Copyright 2022 The Matrix.org Foundation C.I.C. + * + * 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 org.matrix.android.sdk.test.fakes + +import io.mockk.coEvery +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import org.matrix.android.sdk.internal.session.user.accountdata.AccountDataAPI + +internal class FakeAccountDataApi { + + val instance: AccountDataAPI = mockk() + + fun givenParamsToDeleteAccountData(userId: String, type: String) { + coEvery { instance.deleteAccountData(userId, type) } just runs + } +} diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeClipboardManager.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeClipboardManager.kt new file mode 100644 index 0000000000..bce8b41aa9 --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeClipboardManager.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2022 The Matrix.org Foundation C.I.C. + * + * 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 org.matrix.android.sdk.test.fakes + +import android.content.ClipData +import android.content.ClipboardManager +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import io.mockk.verify + +class FakeClipboardManager { + val instance = mockk() + + fun givenSetPrimaryClip() { + every { instance.setPrimaryClip(any()) } just runs + } + + fun verifySetPrimaryClip(clipData: ClipData) { + verify { instance.setPrimaryClip(clipData) } + } +} diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeConnectivityManager.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeConnectivityManager.kt new file mode 100644 index 0000000000..5c3a245c51 --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeConnectivityManager.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2022 The Matrix.org Foundation C.I.C + * + * 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 org.matrix.android.sdk.test.fakes + +import android.net.ConnectivityManager +import android.net.Network +import android.net.NetworkCapabilities +import io.mockk.every +import io.mockk.mockk + +class FakeConnectivityManager { + val instance = mockk() + + fun givenNoActiveConnection() { + every { instance.activeNetwork } returns null + } + + fun givenHasActiveConnection() { + val network = mockk() + every { instance.activeNetwork } returns network + + val networkCapabilities = FakeNetworkCapabilities() + networkCapabilities.givenTransports( + NetworkCapabilities.TRANSPORT_CELLULAR, + NetworkCapabilities.TRANSPORT_WIFI, + NetworkCapabilities.TRANSPORT_VPN + ) + every { instance.getNetworkCapabilities(network) } returns networkCapabilities.instance + } +} diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeContext.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeContext.kt new file mode 100644 index 0000000000..966c6a1bb2 --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeContext.kt @@ -0,0 +1,84 @@ +/* + * Copyright (c) 2022 The Matrix.org Foundation C.I.C. + * + * 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 org.matrix.android.sdk.test.fakes + +import android.content.ClipboardManager +import android.content.ContentResolver +import android.content.Context +import android.content.Intent +import android.net.ConnectivityManager +import android.net.Uri +import android.os.ParcelFileDescriptor +import io.mockk.every +import io.mockk.just +import io.mockk.mockk +import io.mockk.runs +import java.io.OutputStream + +class FakeContext( + private val contentResolver: ContentResolver = mockk() +) { + + val instance = mockk() + + init { + every { instance.contentResolver } returns contentResolver + every { instance.applicationContext } returns instance + } + + fun givenFileDescriptor(uri: Uri, mode: String, factory: () -> ParcelFileDescriptor?) { + val fileDescriptor = factory() + every { contentResolver.openFileDescriptor(uri, mode, null) } returns fileDescriptor + } + + fun givenSafeOutputStreamFor(uri: Uri): OutputStream { + val outputStream = mockk(relaxed = true) + every { contentResolver.openOutputStream(uri, "wt") } returns outputStream + return outputStream + } + + fun givenMissingSafeOutputStreamFor(uri: Uri) { + every { contentResolver.openOutputStream(uri, "wt") } returns null + } + + fun givenNoConnection() { + val connectivityManager = FakeConnectivityManager() + connectivityManager.givenNoActiveConnection() + givenService(Context.CONNECTIVITY_SERVICE, ConnectivityManager::class.java, connectivityManager.instance) + } + + fun givenService(name: String, klass: Class, service: T) { + every { instance.getSystemService(name) } returns service + every { instance.getSystemService(klass) } returns service + } + + fun givenHasConnection() { + val connectivityManager = FakeConnectivityManager() + connectivityManager.givenHasActiveConnection() + givenService(Context.CONNECTIVITY_SERVICE, ConnectivityManager::class.java, connectivityManager.instance) + } + + fun givenStartActivity(intent: Intent) { + every { instance.startActivity(intent) } just runs + } + + fun givenClipboardManager(): FakeClipboardManager { + val fakeClipboardManager = FakeClipboardManager() + givenService(Context.CLIPBOARD_SERVICE, ClipboardManager::class.java, fakeClipboardManager.instance) + return fakeClipboardManager + } +} diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeEventDecryptor.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeEventDecryptor.kt new file mode 100644 index 0000000000..f2b62ad3ba --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeEventDecryptor.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2022 The Matrix.org Foundation C.I.C. + * + * 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 org.matrix.android.sdk.test.fakes + +import io.mockk.coJustRun +import io.mockk.coVerify +import io.mockk.mockk +import org.matrix.android.sdk.api.session.events.model.Event +import org.matrix.android.sdk.internal.crypto.EventDecryptor + +internal class FakeEventDecryptor { + val instance: EventDecryptor = mockk() + + fun givenDecryptEventAndSaveResultSuccess(event: Event) { + coJustRun { instance.decryptEventAndSaveResult(event, any()) } + } + + fun verifyDecryptEventAndSaveResult(event: Event, timeline: String) { + coVerify { instance.decryptEventAndSaveResult(event, timeline) } + } +} diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeFetchPollResponseEventsTask.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeFetchPollResponseEventsTask.kt new file mode 100644 index 0000000000..cb75d8b708 --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeFetchPollResponseEventsTask.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2022 The Matrix.org Foundation C.I.C. + * + * 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 org.matrix.android.sdk.test.fakes + +import io.mockk.mockk +import org.matrix.android.sdk.internal.session.room.relation.poll.FetchPollResponseEventsTask + +class FakeFetchPollResponseEventsTask : FetchPollResponseEventsTask by mockk(relaxed = true) diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeFilterRepository.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeFilterRepository.kt new file mode 100644 index 0000000000..27a39120f8 --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeFilterRepository.kt @@ -0,0 +1,29 @@ +/* + * Copyright 2022 The Matrix.org Foundation C.I.C. + * + * 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 org.matrix.android.sdk.test.fakes + +import io.mockk.coEvery +import io.mockk.mockk +import org.matrix.android.sdk.internal.session.filter.FilterRepository + +internal class FakeFilterRepository : FilterRepository by mockk() { + + fun givenFilterStored(filterId: String?, filterBody: String?) { + coEvery { getStoredSyncFilterId() } returns filterId + coEvery { getStoredSyncFilterBody() } returns filterBody + } +} diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeHomeServerCapabilitiesDataSource.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeHomeServerCapabilitiesDataSource.kt new file mode 100644 index 0000000000..9a56a599d1 --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeHomeServerCapabilitiesDataSource.kt @@ -0,0 +1,30 @@ +/* + * Copyright 2022 The Matrix.org Foundation C.I.C. + * + * 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 org.matrix.android.sdk.test.fakes + +import io.mockk.every +import io.mockk.mockk +import org.matrix.android.sdk.api.session.homeserver.HomeServerCapabilities +import org.matrix.android.sdk.internal.session.homeserver.HomeServerCapabilitiesDataSource + +internal class FakeHomeServerCapabilitiesDataSource { + val instance = mockk() + + fun givenHomeServerCapabilities(homeServerCapabilities: HomeServerCapabilities) { + every { instance.getHomeServerCapabilities() } returns homeServerCapabilities + } +} diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeMonarchy.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeMonarchy.kt index 93999458c6..76ede75910 100644 --- a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeMonarchy.kt +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeMonarchy.kt @@ -38,9 +38,9 @@ internal class FakeMonarchy { init { mockkStatic("org.matrix.android.sdk.internal.util.MonarchyKt") coEvery { - instance.awaitTransaction(any Any>()) - } coAnswers { - secondArg Any>().invoke(fakeRealm.instance) + instance.awaitTransaction(any<(Realm) -> Any>()) + } answers { + secondArg<(Realm) -> Any>().invoke(fakeRealm.instance) } coEvery { instance.doWithRealm(any()) diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeNetworkCapabilities.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeNetworkCapabilities.kt new file mode 100644 index 0000000000..c630b94d47 --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeNetworkCapabilities.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2022 The Matrix.org Foundation C.I.C. + * + * 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 org.matrix.android.sdk.test.fakes + +import android.net.NetworkCapabilities +import io.mockk.every +import io.mockk.mockk + +class FakeNetworkCapabilities { + val instance = mockk() + + fun givenTransports(vararg type: Int) { + every { instance.hasTransport(any()) } answers { + val input = it.invocation.args.first() as Int + type.contains(input) + } + } +} diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeRealm.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeRealm.kt index afdcf111f8..ba124a86aa 100644 --- a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeRealm.kt +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeRealm.kt @@ -109,6 +109,14 @@ inline fun RealmQuery.givenLessThan( return this } +inline fun RealmQuery.givenIn( + fieldName: String, + values: List, +): RealmQuery { + every { `in`(fieldName, values.toTypedArray()) } returns this + return this +} + /** * Should be called on a mocked RealmObject and not on a real RealmObject so that the underlying final method is mocked. */ diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeRealmConfiguration.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeRealmConfiguration.kt index 15a9823c79..9ad7032262 100644 --- a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeRealmConfiguration.kt +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeRealmConfiguration.kt @@ -19,7 +19,6 @@ package org.matrix.android.sdk.test.fakes import io.mockk.coEvery import io.mockk.mockk import io.mockk.mockkStatic -import io.mockk.slot import io.realm.Realm import io.realm.RealmConfiguration import org.matrix.android.sdk.internal.database.awaitTransaction @@ -33,9 +32,8 @@ internal class FakeRealmConfiguration { val instance = mockk() fun givenAwaitTransaction(realm: Realm) { - val transaction = slot T>() - coEvery { awaitTransaction(instance, capture(transaction)) } coAnswers { - secondArg T>().invoke(realm) + coEvery { awaitTransaction(instance, any<(Realm) -> T>()) } answers { + secondArg<(Realm) -> T>().invoke(realm) } } } diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeRoomApi.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeRoomApi.kt new file mode 100644 index 0000000000..68dbbe7ea6 --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeRoomApi.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2022 The Matrix.org Foundation C.I.C. + * + * 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 org.matrix.android.sdk.test.fakes + +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import org.matrix.android.sdk.internal.session.room.RoomAPI +import org.matrix.android.sdk.internal.session.room.relation.RelationsResponse + +internal class FakeRoomApi { + + val instance: RoomAPI = mockk() + + fun givenGetRelationsReturns( + from: String?, + relationsResponse: RelationsResponse, + ) { + coEvery { + instance.getRelations( + roomId = any(), + eventId = any(), + relationType = any(), + from = from, + limit = any() + ) + } returns relationsResponse + } + + fun verifyGetRelations( + roomId: String, + eventId: String, + relationType: String, + from: String?, + limit: Int, + ) { + coVerify { + instance.getRelations( + roomId = roomId, + eventId = eventId, + relationType = relationType, + from = from, + limit = limit + ) + } + } +} diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeSaveFilterTask.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeSaveFilterTask.kt new file mode 100644 index 0000000000..40bee227e0 --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeSaveFilterTask.kt @@ -0,0 +1,40 @@ +/* + * Copyright 2022 The Matrix.org Foundation C.I.C. + * + * 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 org.matrix.android.sdk.test.fakes + +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import io.mockk.slot +import org.amshove.kluent.shouldBeEqualTo +import org.matrix.android.sdk.internal.session.filter.Filter +import org.matrix.android.sdk.internal.session.filter.SaveFilterTask +import java.util.UUID + +internal class FakeSaveFilterTask : SaveFilterTask by mockk() { + + init { + coEvery { execute(any()) } returns UUID.randomUUID().toString() + } + + fun verifyExecution(filter: Filter) { + val slot = slot() + coVerify { execute(capture(slot)) } + val params = slot.captured + params.filter shouldBeEqualTo filter + } +} diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/internal/session/content/FakeThumbnailExtractor.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/internal/session/content/FakeThumbnailExtractor.kt new file mode 100644 index 0000000000..b541d24161 --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/internal/session/content/FakeThumbnailExtractor.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2022 The Matrix.org Foundation C.I.C. + * + * 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 org.matrix.android.sdk.test.fakes.internal.session.content + +import io.mockk.mockk +import org.matrix.android.sdk.internal.session.content.ThumbnailExtractor + +class FakeThumbnailExtractor { + internal val instance = mockk() +} diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/internal/session/permalinks/FakePermalinkFactory.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/internal/session/permalinks/FakePermalinkFactory.kt new file mode 100644 index 0000000000..3d7e85424e --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/internal/session/permalinks/FakePermalinkFactory.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2022 The Matrix.org Foundation C.I.C. + * + * 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 org.matrix.android.sdk.test.fakes.internal.session.permalinks + +import io.mockk.mockk +import org.matrix.android.sdk.internal.session.permalinks.PermalinkFactory + +class FakePermalinkFactory { + internal val instance = mockk() +} diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/internal/session/room/send/FakeLocalEchoRepository.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/internal/session/room/send/FakeLocalEchoRepository.kt new file mode 100644 index 0000000000..b10d13824b --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/internal/session/room/send/FakeLocalEchoRepository.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2022 The Matrix.org Foundation C.I.C. + * + * 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 org.matrix.android.sdk.test.fakes.internal.session.room.send + +import io.mockk.mockk +import org.matrix.android.sdk.internal.session.room.send.LocalEchoRepository + +class FakeLocalEchoRepository { + internal val instance = mockk() +} diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/internal/session/room/send/FakeMarkdownParser.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/internal/session/room/send/FakeMarkdownParser.kt new file mode 100644 index 0000000000..a27c9284e7 --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/internal/session/room/send/FakeMarkdownParser.kt @@ -0,0 +1,32 @@ +/* + * Copyright (c) 2022 The Matrix.org Foundation C.I.C. + * + * 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 org.matrix.android.sdk.test.fakes.internal.session.room.send + +import io.mockk.every +import io.mockk.mockk +import org.matrix.android.sdk.api.util.TextContent +import org.matrix.android.sdk.internal.session.room.send.MarkdownParser + +class FakeMarkdownParser { + internal val instance = mockk() + fun givenBoldMarkdown() { + every { instance.parse(any(), any(), any()) } answers { + val text = arg(0) + TextContent(text, "${text.replace("*", "")}") + } + } +} diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/internal/session/room/send/FakeWaveFormSanitizer.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/internal/session/room/send/FakeWaveFormSanitizer.kt new file mode 100644 index 0000000000..052ddf7831 --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/internal/session/room/send/FakeWaveFormSanitizer.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2022 The Matrix.org Foundation C.I.C. + * + * 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 org.matrix.android.sdk.test.fakes.internal.session.room.send + +import io.mockk.mockk +import org.matrix.android.sdk.internal.session.room.send.WaveFormSanitizer + +class FakeWaveFormSanitizer { + internal val instance = mockk() +} diff --git a/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/internal/session/room/send/pills/FakeTextPillsUtils.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/internal/session/room/send/pills/FakeTextPillsUtils.kt new file mode 100644 index 0000000000..0d783d6628 --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/internal/session/room/send/pills/FakeTextPillsUtils.kt @@ -0,0 +1,24 @@ +/* + * Copyright (c) 2022 The Matrix.org Foundation C.I.C. + * + * 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 org.matrix.android.sdk.test.fakes.internal.session.room.send.pills + +import io.mockk.mockk +import org.matrix.android.sdk.internal.session.room.send.pills.TextPillsUtils + +class FakeTextPillsUtils { + internal val instance = mockk() +} diff --git a/tools/detekt/detekt.yml b/tools/detekt/detekt.yml index 96adb3d117..62a4fc408f 100644 --- a/tools/detekt/detekt.yml +++ b/tools/detekt/detekt.yml @@ -55,7 +55,7 @@ complexity: active: false LongParameterList: active: false - ComplexMethod: + CyclomaticComplexMethod: active: false NestedBlockDepth: active: false diff --git a/tools/emojis/emoji_picker_datasource_formatted.json b/tools/emojis/emoji_picker_datasource_formatted.json index c00bd10371..316b6e01d0 100644 --- a/tools/emojis/emoji_picker_datasource_formatted.json +++ b/tools/emojis/emoji_picker_datasource_formatted.json @@ -3013,7 +3013,11 @@ "begging", "mercy", "puppy eyes", - "face" + "face", + "cry", + "tears", + "sad", + "grievance" ] }, "face-holding-back-tears": { @@ -3060,9 +3064,7 @@ "fearful", "scared", "terrified", - "nervous", - "oops", - "huh" + "nervous" ] }, "anxious-face-with-sweat": { @@ -14360,7 +14362,11 @@ "plant", "nature", "branch", - "summer" + "summer", + "bamboo", + "wish", + "star_festival", + "tanzaku" ] }, "pine-decoration": { @@ -14371,10 +14377,12 @@ "celebration", "Japanese", "pine", + "japanese", "plant", "nature", "vegetable", - "panda" + "panda", + "new_years" ] }, "japanese-dolls": { diff --git a/tools/gradle/doctor.gradle b/tools/gradle/doctor.gradle index 7a7adad062..edd3069402 100644 --- a/tools/gradle/doctor.gradle +++ b/tools/gradle/doctor.gradle @@ -1,6 +1,6 @@ // Default configuration copied from https://runningcode.github.io/gradle-doctor/configuration/ -def isCiBuild = System.env.BUILDKITE == "true" || System.env.GITHUB_ACTIONS == "true" +def isCiBuild = System.env.GITHUB_ACTIONS == "true" println "Is CI build: $isCiBuild" doctor { diff --git a/tools/install/installFromGitHub.sh b/tools/install/installFromGitHub.sh new file mode 100755 index 0000000000..6928003773 --- /dev/null +++ b/tools/install/installFromGitHub.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash + +# Exit on any error +set -e + +if [[ "$#" -ne 1 ]]; then + echo "Usage: $0 GitHub_Token" >&2 + echo "Read more about this script in the doc ./docs/installing_from_ci.md" + exit 1 +fi + +gitHubToken=$1 + +# Path where the app is cloned (it's where this project has been cloned) +appPath=$(dirname $(dirname $(dirname $0))) +# Path where the APK will be downloaded from CI (it's a dir) +baseImportPath="${appPath}/tmp/DebugApks" + +# Select device +serialNumber=$(${appPath}/tools/install/androidSelectDevice.sh) + +# Detect device architecture +arch=$(adb -s ${serialNumber} shell getprop ro.product.cpu.abi) + +echo +echo "Will install the application on device ${serialNumber} with arch ${arch}" + +# Artifact URL +echo +read -p "Artifact url (ex: https://github.com/vector-im/element-android/suites/9293388174/artifacts/435942121)? " artifactUrl + +## Example of default value for Gplay +#artifactUrl=${artifactUrl:-https://github.com/vector-im/element-android/suites/9293388174/artifacts/435942121} +## Example of default value for FDroid +# artifactUrl=${artifactUrl:-https://github.com/vector-im/element-android/suites/9293388174/artifacts/435942119} + +artifactId=$(echo ${artifactUrl} | rev | cut -d'/' -f1 | rev) + +# Download files +targetPath=${baseImportPath}/${artifactId} + +filename="artifact.zip" + +fullFilePath="${targetPath}/${filename}" + +# Check if file already exists +if test -f "$fullFilePath"; then + read -p "$fullFilePath already exists. Override (yes/no) default to no ? " download + download=${download:-no} +else + download="yes" +fi + +# Ignore error from now +set +e + +if [ ${download} == "yes" ]; then + echo "Downloading ${filename} to ${targetPath}..." + python3 ${appPath}/tools/release/download_github_artifacts.py \ + --token ${gitHubToken} \ + --artifactUrl ${artifactUrl} \ + --directory ${targetPath} \ + --filename ${filename} \ + --ignoreErrors +fi + +echo "Unzipping ${filename}..." +unzip $fullFilePath -d ${targetPath} + +## gplay or fdroid +if test -d "${targetPath}/gplay"; then + variant="gplay" +elif test -d "${targetPath}/fdroid"; then + variant="fdroid" +else + echo "No variant found" + exit 1 +fi + +fullApkPath="${targetPath}/${variant}/debug/vector-${variant}-${arch}-debug.apk" + +echo "Installing ${fullApkPath} to device ${serialNumber}..." +adb -s ${serialNumber} install -r ${fullApkPath} + +# Check error and propose to uninstall and retry installing +if [[ "$?" -ne 0 ]]; then + read -p "Error, do you want to uninstall the application then retry (yes/no) default to no ? " retry + retry=${retry:-no} + if [ ${retry} == "yes" ]; then + echo "Uninstalling..." + adb -s ${serialNumber} uninstall im.vector.app.debug + echo "Installing again..." + adb -s ${serialNumber} install -r ${fullApkPath} + fi +fi diff --git a/tools/release/download_github_artifacts.py b/tools/release/download_github_artifacts.py new file mode 100755 index 0000000000..892a4affa6 --- /dev/null +++ b/tools/release/download_github_artifacts.py @@ -0,0 +1,154 @@ +#!/usr/bin/env python3 +# +# Copyright 2022 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. +# + +import argparse +import hashlib +import json +import os +# Run `pip3 install requests` if not installed yet +import requests + +# This script downloads artifacts from GitHub. +# Ref: https://docs.github.com/en/rest/actions/artifacts#get-an-artifact + +error = False + +### Arguments + +parser = argparse.ArgumentParser(description='Download artifacts from GitHub.') +parser.add_argument('-t', + '--token', + required=True, + help='The GitHub token with read access.') +parser.add_argument('-a', + '--artifactUrl', + required=True, + help='the artifact_url from GitHub.') +parser.add_argument('-f', + '--filename', + help='the filename, if not provided, will use the artifact name.') +parser.add_argument('-i', + '--ignoreErrors', + help='Ignore errors that can be ignored. Build state and number of artifacts.', + action="store_true") +parser.add_argument('-d', + '--directory', + default="", + help='the target directory, where files will be downloaded. If not provided the build number will be used to create a directory.') +parser.add_argument('-v', + '--verbose', + help="increase output verbosity.", + action="store_true") +parser.add_argument('-s', + '--simulate', + help="simulate action, do not create folder or download any file.", + action="store_true") + +args = parser.parse_args() + +if args.verbose: + print("Argument:") + print(args) + +# Split the artifact URL to get information +# Ex: https://github.com/vector-im/element-android/suites/9293388174/artifacts/435942121 +artifactUrl = args.artifactUrl +if not artifactUrl.startswith('https://github.com/'): + print("❌ Invalid parameter --artifactUrl %s. Must start with 'https://github.com/'" % artifactUrl) + exit(1) +if "/artifacts/" not in artifactUrl: + print("❌ Invalid parameter --artifactUrl %s. Must contain '/artifacts/'" % artifactUrl) + exit(1) +artifactItems = artifactUrl.split("/") +if len(artifactItems) != 9: + print("❌ Invalid parameter --artifactUrl %s. Please check the format." % (artifactUrl)) + exit(1) + +gitHubRepoOwner = artifactItems[3] +gitHubRepo = artifactItems[4] +artifactId = artifactItems[8] + +if args.verbose: + print("gitHubRepoOwner: %s, gitHubRepo: %s, artifactId: %s" % (gitHubRepoOwner, gitHubRepo, artifactId)) + +headers = { + 'Authorization': "Bearer %s" % args.token, + 'Accept': 'application/vnd.github+json' +} +base_url = "https://api.github.com/repos/%s/%s/actions/artifacts/%s" % (gitHubRepoOwner, gitHubRepo, artifactId) + +### Fetch build state + +print("Getting artifacts data of project '%s/%s' artifactId '%s'..." % (gitHubRepoOwner, gitHubRepo, artifactId)) + +if args.verbose: + print("Url: %s" % base_url) + +r = requests.get(base_url, headers=headers) +data = json.loads(r.content.decode()) + +if args.verbose: + print("Json data:") + print(data) + +if args.verbose: + print("Create subfolder %s to download artifacts..." % artifactId) + +if args.directory == "": + targetDir = artifactId +else: + targetDir = args.directory + +if not args.simulate: + os.makedirs(targetDir, exist_ok=True) + +url = data.get("archive_download_url") +if args.filename is not None: + filename = args.filename +else: + filename = data.get("name") + ".zip" + +## Print some info about the artifact origin +commitLink = "https://github.com/%s/%s/commit/%s" % (gitHubRepoOwner, gitHubRepo, data.get("workflow_run").get("head_sha")) +print("Preparing to download artifact `%s`, built from branch: `%s` (commit %s)" % (data.get("name"), data.get("workflow_run").get("head_branch"), commitLink)) + +if args.verbose: + print() + print("Artifact url: %s" % url) + +target = targetDir + "/" + filename +sizeInBytes = data.get("size_in_bytes") +print("Downloading %s to '%s' (file size is %s bytes, this may take a while)..." % (filename, targetDir, sizeInBytes)) +if not args.simulate: + # open file to write in binary mode + with open(target, "wb") as file: + # get request + response = requests.get(url, headers=headers) + # write to file + file.write(response.content) + print("Verifying file size...") + # get the file size + size = os.path.getsize(target) + if sizeInBytes != size: + # error = True + print("Warning, file size mismatch: expecting %s and get %s. This is just a warning for now..." % (sizeInBytes, size)) + +if error: + print("❌ Error(s) occurred, please check the log") + exit(1) +else: + print("Done!") diff --git a/tools/release/releaseScript.sh b/tools/release/releaseScript.sh new file mode 100755 index 0000000000..f91e11584c --- /dev/null +++ b/tools/release/releaseScript.sh @@ -0,0 +1,385 @@ +#!/usr/bin/env bash + +# +# Copyright (c) 2022 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. +# + +# Ignore any error to not stop the script +set +e + +printf "\n================================================================================\n" +printf "| Welcome to the release script! |\n" +printf "================================================================================\n" + +printf "Checking environment...\n" +envError=0 + +# Path of the key store (it's a file) +keyStorePath="${ELEMENT_KEYSTORE_PATH}" +if [[ -z "${keyStorePath}" ]]; then + printf "Fatal: ELEMENT_KEYSTORE_PATH is not defined in the environment.\n" + envError=1 +fi +# Keystore password +keyStorePassword="${ELEMENT_KEYSTORE_PASSWORD}" +if [[ -z "${keyStorePassword}" ]]; then + printf "Fatal: ELEMENT_KEYSTORE_PASSWORD is not defined in the environment.\n" + envError=1 +fi +# Key password +keyPassword="${ELEMENT_KEY_PASSWORD}" +if [[ -z "${keyPassword}" ]]; then + printf "Fatal: ELEMENT_KEY_PASSWORD is not defined in the environment.\n" + envError=1 +fi +# GitHub token +gitHubToken="${ELEMENT_GITHUB_TOKEN}" +if [[ -z "${gitHubToken}" ]]; then + printf "Fatal: ELEMENT_GITHUB_TOKEN is not defined in the environment.\n" + envError=1 +fi +# Android home +androidHome="${ANDROID_HOME}" +if [[ -z "${androidHome}" ]]; then + printf "Fatal: ANDROID_HOME is not defined in the environment.\n" + envError=1 +fi +# @elementbot:matrix.org matrix token / Not mandatory +elementBotToken="${ELEMENT_BOT_MATRIX_TOKEN}" +if [[ -z "${elementBotToken}" ]]; then + printf "Warning: ELEMENT_BOT_MATRIX_TOKEN is not defined in the environment.\n" +fi + +if [ ${envError} == 1 ]; then + exit 1 +fi + +buildToolsVersion="30.0.2" +buildToolsPath="${androidHome}/build-tools/${buildToolsVersion}" + +if [[ ! -d ${buildToolsPath} ]]; then + printf "Fatal: ${buildToolsPath} folder not found, ensure that you have installed the SDK version ${buildToolsVersion}.\n" + exit 1 +fi + +# Check if git flow is enabled +git flow config >/dev/null 2>&1 +if [[ $? == 0 ]] +then + printf "Git flow is initialized\n" +else + printf "Git flow is not initialized. Initializing...\n" + # All default value, just set 'v' for tag prefix + git flow init -d -t 'v' +fi + +printf "OK\n" + +printf "\n================================================================================\n" +# Guessing version to propose a default version +versionMajorCandidate=`grep "ext.versionMajor" ./vector-app/build.gradle | cut -d " " -f3` +versionMinorCandidate=`grep "ext.versionMinor" ./vector-app/build.gradle | cut -d " " -f3` +versionPatchCandidate=`grep "ext.versionPatch" ./vector-app/build.gradle | cut -d " " -f3` +versionCandidate="${versionMajorCandidate}.${versionMinorCandidate}.${versionPatchCandidate}" + +read -p "Please enter the release version (example: ${versionCandidate}). Just press enter if ${versionCandidate} is correct. " version +version=${version:-${versionCandidate}} + +# extract major, minor and patch for future use +versionMajor=`echo ${version} | cut -d "." -f1` +versionMinor=`echo ${version} | cut -d "." -f2` +versionPatch=`echo ${version} | cut -d "." -f3` +nextPatchVersion=$((versionPatch + 2)) + +printf "\n================================================================================\n" +printf "Ensuring main and develop branches are up to date...\n" + +git checkout main +git pull +git checkout develop +git pull + +printf "\n================================================================================\n" +printf "Starting the release ${version}\n" +git flow release start ${version} + +# Note: in case the release is already started and the script is started again, checkout the release branch again. +ret=$? +if [[ $ret -ne 0 ]]; then + printf "Mmh, it seems that the release is already started. Checking out the release branch...\n" + git checkout "release/${version}" +fi + +# Ensure version is OK +cp ./vector-app/build.gradle ./vector-app/build.gradle.bak +sed "s/ext.versionMajor = .*/ext.versionMajor = ${versionMajor}/" ./vector-app/build.gradle.bak > ./vector-app/build.gradle +sed "s/ext.versionMinor = .*/ext.versionMinor = ${versionMinor}/" ./vector-app/build.gradle > ./vector-app/build.gradle.bak +sed "s/ext.versionPatch = .*/ext.versionPatch = ${versionPatch}/" ./vector-app/build.gradle.bak > ./vector-app/build.gradle +rm ./vector-app/build.gradle.bak +cp ./matrix-sdk-android/build.gradle ./matrix-sdk-android/build.gradle.bak +sed "s/\"SDK_VERSION\", .*$/\"SDK_VERSION\", \"\\\\\"${version}\\\\\"\"/" ./matrix-sdk-android/build.gradle.bak > ./matrix-sdk-android/build.gradle +rm ./matrix-sdk-android/build.gradle.bak + +# This commit may have no effect because generally we do not change the version during the release. +git commit -a -m "Setting version for the release ${version}" + +printf "\n================================================================================\n" +read -p "Please check the crashes from the PlayStore. You can commit fixes if any on the release branch. Press enter when it's done." + +printf "\n================================================================================\n" +read -p "Please check the rageshake with the current dev version: https://github.com/matrix-org/element-android-rageshakes/labels/${version}-dev. You can commit fixes if any on the release branch. Press enter when it's done." + +printf "\n================================================================================\n" +read -p "Please make sure an emulator is running and press enter when it is ready." + +printf "\n================================================================================\n" +printf "Checking if Synapse is running...\n" +httpCode=`curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:8080/_matrix/static` + +if [[ ${httpCode} -ne "302" ]]; then + read -p "Please make sure Synapse is running (open http://127.0.0.1:8080) and press enter when it is ready." +else + printf "Synapse is running!\n" +fi + +printf "\n================================================================================\n" +printf "Uninstalling previous test app if any...\n" +adb -e uninstall im.vector.app.debug.test + +printf "\n================================================================================\n" +printf "Running the integration test UiAllScreensSanityTest.allScreensTest()...\n" +./gradlew connectedGplayDebugAndroidTest -Pandroid.testInstrumentationRunnerArguments.class=im.vector.app.ui.UiAllScreensSanityTest + +printf "\n================================================================================\n" +printf "Building the app...\n" +./gradlew assembleGplayDebug + +printf "\n================================================================================\n" +printf "Uninstalling previous test app if any...\n" +adb -e uninstall im.vector.app.debug + +printf "\n================================================================================\n" +printf "Installing the app...\n" +adb -e install ./vector-app/build/outputs/apk/gplay/debug/vector-gplay-arm64-v8a-debug.apk + +printf "\n================================================================================\n" +printf "Running the app...\n" +# TODO This does not work, need to be fixed +adb -e shell am start -n im.vector.app.debug/im.vector.app.features.Alias -a android.intent.action.MAIN -c android.intent.category.LAUNCHER + +printf "\n================================================================================\n" +# TODO could build and deploy the APK to any emulator +read -p "Create an account on matrix.org and do some smoke tests that the sanity test does not cover like: 1-1 call, 1-1 video call, Jitsi call for instance. Press enter when it's done." + +printf "\n================================================================================\n" +printf "Running towncrier...\n" +yes | towncrier build --version "v${version}" + +printf "\n================================================================================\n" +read -p "Check the file CHANGES.md consistency. It's possible to reorder items (most important changes first) or change their section if relevant. Also an opportunity to fix some typo, or rewrite things. Do not commit your change. Press enter when it's done." + +printf "\n================================================================================\n" +printf "Committing...\n" +git commit -a -m "Changelog for version ${version}" + +printf "\n================================================================================\n" +printf "Creating fastlane file...\n" +printf -v versionMajor2Digits "%02d" ${versionMajor} +printf -v versionMinor2Digits "%02d" ${versionMinor} +printf -v versionPatch2Digits "%02d" ${versionPatch} +fastlaneFile="4${versionMajor2Digits}${versionMinor2Digits}${versionPatch2Digits}0.txt" +fastlanePathFile="./fastlane/metadata/android/en-US/changelogs/${fastlaneFile}" +printf "Main changes in this version: TODO.\nFull changelog: https://github.com/vector-im/element-android/releases" > ${fastlanePathFile} + +read -p "I have created the file ${fastlanePathFile}, please edit it and press enter when it's done." +git add ${fastlanePathFile} +git commit -a -m "Adding fastlane file for version ${version}" + +printf "\n================================================================================\n" +# We could propose to push the branch and create a PR +read -p "(optional) Push the branch and start a draft PR (will not be merged), to check that the CI is happy with all the changes. Press enter when it's done." + +printf "\n================================================================================\n" +printf "OK, finishing the release...\n" +git flow release finish "${version}" + +printf "\n================================================================================\n" +read -p "Done, push the branch 'main' and the new tag (yes/no) default to yes? " doPush +doPush=${doPush:-yes} + +if [ ${doPush} == "yes" ]; then + printf "Pushing branch 'main' and tag 'v${version}'...\n" + git push origin main + git push origin "v${version}" +else + printf "Not pushing, do not forget to push manually!\n" +fi + +printf "\n================================================================================\n" +printf "Checking out develop...\n" +git checkout develop + +# Set next version +printf "\n================================================================================\n" +printf "Setting next version on file './vector-app/build.gradle'...\n" +cp ./vector-app/build.gradle ./vector-app/build.gradle.bak +sed "s/ext.versionPatch = .*/ext.versionPatch = ${nextPatchVersion}/" ./vector-app/build.gradle.bak > ./vector-app/build.gradle +rm ./vector-app/build.gradle.bak + +printf "\n================================================================================\n" +printf "Setting next version on file './matrix-sdk-android/build.gradle'...\n" +nextVersion="${versionMajor}.${versionMinor}.${nextPatchVersion}" +cp ./matrix-sdk-android/build.gradle ./matrix-sdk-android/build.gradle.bak +sed "s/\"SDK_VERSION\", .*$/\"SDK_VERSION\", \"\\\\\"${nextVersion}\\\\\"\"/" ./matrix-sdk-android/build.gradle.bak > ./matrix-sdk-android/build.gradle +rm ./matrix-sdk-android/build.gradle.bak + +printf "\n================================================================================\n" +read -p "I have updated the versions to prepare the next release, please check that the change are correct and press enter so I can commit." + +printf "Committing...\n" +git commit -a -m 'version++' + +printf "\n================================================================================\n" +read -p "Done, push the branch 'develop' (yes/no) default to yes? (A rebase may be necessary in case develop got new commits)" doPush +doPush=${doPush:-yes} + +if [ ${doPush} == "yes" ]; then + printf "Pushing branch 'develop'...\n" + git push origin develop +else + printf "Not pushing, do not forget to push manually!\n" +fi + +printf "\n================================================================================\n" +printf "Wait for the GitHub action https://github.com/vector-im/element-android/actions/workflows/build.yml?query=branch%3Amain to build the 'main' branch.\n" +read -p "After GHA is finished, please enter the artifact URL (for 'vector-gplay-release-unsigned'): " artifactUrl + +printf "\n================================================================================\n" +printf "Downloading the artifact...\n" + +# Download files +targetPath="./tmp/Element/${version}" + +# Ignore error +set +e + +python3 ./tools/release/download_github_artifacts.py \ + --token ${gitHubToken} \ + --artifactUrl ${artifactUrl} \ + --directory ${targetPath} \ + --ignoreErrors + +# Do not ignore error +set -e + +printf "\n================================================================================\n" +printf "Unzipping the artifact...\n" + +unzip ${targetPath}/vector-gplay-release-unsigned.zip -d ${targetPath} + +# Flatten folder hierarchy +mv ${targetPath}/gplay/release/* ${targetPath} +rm -rf ${targetPath}/gplay + +printf "\n================================================================================\n" +printf "Signing the APKs...\n" + +cp ${targetPath}/vector-gplay-arm64-v8a-release-unsigned.apk \ + ${targetPath}/vector-gplay-arm64-v8a-release-signed.apk +./tools/release/sign_apk_unsafe.sh \ + ${keyStorePath} \ + ${targetPath}/vector-gplay-arm64-v8a-release-signed.apk \ + ${keyStorePassword} \ + ${keyPassword} + +cp ${targetPath}/vector-gplay-armeabi-v7a-release-unsigned.apk \ + ${targetPath}/vector-gplay-armeabi-v7a-release-signed.apk +./tools/release/sign_apk_unsafe.sh \ + ${keyStorePath} \ + ${targetPath}/vector-gplay-armeabi-v7a-release-signed.apk \ + ${keyStorePassword} \ + ${keyPassword} + +cp ${targetPath}/vector-gplay-x86-release-unsigned.apk \ + ${targetPath}/vector-gplay-x86-release-signed.apk +./tools/release/sign_apk_unsafe.sh \ + ${keyStorePath} \ + ${targetPath}/vector-gplay-x86-release-signed.apk \ + ${keyStorePassword} \ + ${keyPassword} + +cp ${targetPath}/vector-gplay-x86_64-release-unsigned.apk \ + ${targetPath}/vector-gplay-x86_64-release-signed.apk +./tools/release/sign_apk_unsafe.sh \ + ${keyStorePath} \ + ${targetPath}/vector-gplay-x86_64-release-signed.apk \ + ${keyStorePassword} \ + ${keyPassword} + +# Ref: https://docs.fastlane.tools/getting-started/android/beta-deployment/#uploading-your-app +# set SUPPLY_APK_PATHS="${targetPath}/vector-gplay-arm64-v8a-release-unsigned.apk,${targetPath}/vector-gplay-armeabi-v7a-release-unsigned.apk,${targetPath}/vector-gplay-x86-release-unsigned.apk,${targetPath}/vector-gplay-x86_64-release-unsigned.apk" +# +# ./fastlane beta + +printf "\n================================================================================\n" +printf "Please check the information below:\n" + +printf "File vector-gplay-arm64-v8a-release-signed.apk:\n" +${buildToolsPath}/aapt dump badging ${targetPath}/vector-gplay-arm64-v8a-release-signed.apk | grep package +printf "File vector-gplay-armeabi-v7a-release-signed.apk:\n" +${buildToolsPath}/aapt dump badging ${targetPath}/vector-gplay-armeabi-v7a-release-signed.apk | grep package +printf "File vector-gplay-x86-release-signed.apk:\n" +${buildToolsPath}/aapt dump badging ${targetPath}/vector-gplay-x86-release-signed.apk | grep package +printf "File vector-gplay-x86_64-release-signed.apk:\n" +${buildToolsPath}/aapt dump badging ${targetPath}/vector-gplay-x86_64-release-signed.apk | grep package + +printf "\n" +read -p "Does it look correct? Press enter when it's done." + +printf "\n================================================================================\n" +read -p "Installing apk on a real device, press enter when a real device is connected. " +apkPath="${targetPath}/vector-gplay-arm64-v8a-release-signed.apk" +adb -d install ${apkPath} + +read -p "Please run the APK on your phone to check that the upgrade went well (no init sync, etc.). Press enter when it's done." +# TODO Get the block to copy from towncrier earlier (be may be edited by the release manager)? +read -p "Create the release on gitHub from the tag https://github.com/vector-im/element-android/tags, copy paste the block from the file CHANGES.md. Press enter when it's done." + +read -p "Add the 4 signed APKs to the GitHub release. They are located at ${targetPath}. Press enter when it's done." + +printf "\n================================================================================\n" +printf "Message for the Android internal room:\n\n" +message="@room Element Android ${version} is ready to be tested. You can get if from https://github.com/vector-im/element-android/releases/tag/v${version}. Please report any feedback here. Thanks!" +printf "${message}\n\n" + +if [[ -z "${elementBotToken}" ]]; then + read -p "ELEMENT_BOT_MATRIX_TOKEN is not defined in the environment. Cannot send the message for you. Please send it manually, and press enter when it's done " +else + read -p "Send this message to the room (yes/no) default to yes? " doSend + doSend=${doSend:-yes} + if [ ${doSend} == "yes" ]; then + printf "Sending message...\n" + transactionId=`openssl rand -hex 16` + # Element Android internal + matrixRoomId="!LiSLXinTDCsepePiYW:matrix.org" + curl -X PUT --data $"{\"msgtype\":\"m.text\",\"body\":\"${message}\"}" -H "Authorization: Bearer ${elementBotToken}" https://matrix-client.matrix.org/_matrix/client/r0/rooms/${matrixRoomId}/send/m.room.message/\$local.${transactionId} + else + printf "Message not sent, please send it manually!\n" + fi +fi + +printf "\n================================================================================\n" +printf "Congratulation! Kudos for using this script! Have a nice day!\n" +printf "================================================================================\n" diff --git a/towncrier.toml b/towncrier.toml index 8a6daa1351..178c5c7e6b 100644 --- a/towncrier.toml +++ b/towncrier.toml @@ -1,5 +1,5 @@ [tool.towncrier] - version = "2.6.1" + version = "2.7.0" directory = "changelog.d" filename = "TCHAP_CHANGES.md" name = "Changes in Tchap" diff --git a/vector-app/build.gradle b/vector-app/build.gradle index 03818b1c68..a33879c2f9 100644 --- a/vector-app/build.gradle +++ b/vector-app/build.gradle @@ -33,11 +33,11 @@ knit { // Note: 2 digits max for each value ext.versionMajor = 2 -ext.versionMinor = 6 +ext.versionMinor = 7 // Note: even values are reserved for regular release, odd values for hotfix release. // When creating a hotfix, you should decrease the value, since the current value // is the value for the next regular release. -ext.versionPatch = 2 +ext.versionPatch = 0 static def getGitTimestamp() { def cmd = 'git show -s --format=%ct' @@ -76,15 +76,8 @@ static def gitRevisionDate() { } static def gitBranchName() { - def fromEnv = System.env.BUILDKITE_BRANCH as String ?: "" - - if (!fromEnv.isEmpty()) { - return fromEnv - } else { - // Note: this command return "HEAD" on Buildkite, so use the system env 'BUILDKITE_BRANCH' content first - def cmd = "git rev-parse --abbrev-ref HEAD" - return cmd.execute().text.trim() - } + def cmd = "git rev-parse --abbrev-ref HEAD" + return cmd.execute().text.trim() } // For Google Play build, build on any other branch than main will have a "-dev" suffix @@ -125,8 +118,6 @@ project.android.buildTypes.all { buildType -> // 64 bits have greater value than 32 bits ext.abiVersionCodes = ["armeabi-v7a": 1, "arm64-v8a": 2, "x86": 3, "x86_64": 4].withDefault { 0 } -def buildNumber = System.env.BUILDKITE_BUILD_NUMBER as Integer ?: 0 - android { namespace "im.vector.application" // Due to a bug introduced in Android gradle plugin 3.6.0, we have to specify the ndk version to use @@ -158,7 +149,6 @@ android { buildConfigField "String", "GIT_REVISION", "\"${gitRevision()}\"" buildConfigField "String", "GIT_REVISION_DATE", "\"${gitRevisionDate()}\"" buildConfigField "String", "GIT_BRANCH_NAME", "\"${gitBranchName()}\"" - buildConfigField "String", "BUILD_NUMBER", "\"${buildNumber}\"" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" @@ -474,10 +464,10 @@ dependencies { debugImplementation(libs.flipper.flipperNetworkPlugin) { exclude group: 'com.facebook.fbjni', module: 'fbjni' } - debugImplementation 'com.facebook.soloader:soloader:0.10.4' + debugImplementation 'com.facebook.soloader:soloader:0.10.5' debugImplementation "com.kgurgul.flipper:flipper-realm-android:2.2.0" - gplayImplementation "com.google.android.gms:play-services-location:21.0.0" + gplayImplementation "com.google.android.gms:play-services-location:21.0.1" // UnifiedPush gplay flavor only gplayImplementation('com.google.firebase:firebase-messaging:23.1.0') { exclude group: 'com.google.firebase', module: 'firebase-core' @@ -489,7 +479,8 @@ dependencies { // API-only library gplayImplementation libs.google.appdistributionApi // Full SDK implementation - gplayImplementation libs.google.appdistribution + // Tchap: No nightly +// nightlyImplementation libs.google.appdistribution // OSS License, gplay flavor only gplayImplementation 'com.google.android.gms:play-services-oss-licenses:17.0.0' @@ -596,14 +587,14 @@ dependencies { // Plant Timber tree for test androidTestImplementation libs.tests.timberJunitRule // "The one who serves a great Espresso" - androidTestImplementation('com.adevinta.android:barista:4.2.0') { + androidTestImplementation('com.adevinta.android:barista:4.3.0') { exclude group: 'org.jetbrains.kotlin' } androidTestImplementation libs.mockk.mockkAndroid androidTestUtil libs.androidx.orchestrator androidTestImplementation libs.androidx.fragmentTesting - androidTestImplementation "org.jetbrains.kotlin:kotlin-reflect:1.7.20" + androidTestImplementation "org.jetbrains.kotlin:kotlin-reflect:1.7.22" debugImplementation libs.androidx.fragmentTesting - debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.9.1' + debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.10' } diff --git a/vector-app/signature/README.md b/vector-app/signature/README.md index 7d9005f1f4..34d40b45bd 100644 --- a/vector-app/signature/README.md +++ b/vector-app/signature/README.md @@ -1,10 +1,6 @@ ## Debug signature -Buildkite CI tool uses docker images to build the Android application, and it looks like the debug signature is changed at each build. - -So it's not possible for user to upgrade the application with the last build from buildkite without uninstalling the application. - This folder contains a debug signature, and the debug build will uses this signature to build the APK. The validity of the signature is 30 years. So it has to be replaced before June 2049 :). diff --git a/vector-app/src/androidTest/java/im/vector/app/CantVerifyTest.kt b/vector-app/src/androidTest/java/im/vector/app/CantVerifyTest.kt index af63337283..975e7d8e4d 100644 --- a/vector-app/src/androidTest/java/im/vector/app/CantVerifyTest.kt +++ b/vector-app/src/androidTest/java/im/vector/app/CantVerifyTest.kt @@ -35,7 +35,6 @@ import java.util.UUID @RunWith(AndroidJUnit4::class) @LargeTest -@Ignore("Tchap: Secure Backup is disabled, so this test cannot succeed") class CantVerifyTest { @get:Rule @@ -47,6 +46,7 @@ class CantVerifyTest { var userName: String = "loginTest_${UUID.randomUUID()}" @Test + @Ignore("Tchap: Secure Backup is disabled, so this test cannot succeed") fun checkCantVerifyPopup() { // Let' create an account // This first session will create cross signing keys then logout diff --git a/vector-app/src/androidTest/java/im/vector/app/EspressoExt.kt b/vector-app/src/androidTest/java/im/vector/app/EspressoExt.kt index 72137ed8e8..68a54e9901 100644 --- a/vector-app/src/androidTest/java/im/vector/app/EspressoExt.kt +++ b/vector-app/src/androidTest/java/im/vector/app/EspressoExt.kt @@ -89,7 +89,7 @@ fun getString(@StringRes id: Int): String { return EspressoHelper.getCurrentActivity()!!.resources.getString(id) } -fun waitForView(viewMatcher: Matcher, timeout: Long = 10_000, waitForDisplayed: Boolean = true): ViewAction { +fun waitForView(viewMatcher: Matcher, timeout: Long = 20_000, waitForDisplayed: Boolean = true): ViewAction { return object : ViewAction { private val clock = DefaultClock() diff --git a/vector-app/src/androidTest/java/im/vector/app/ui/UiAllScreensSanityTest.kt b/vector-app/src/androidTest/java/im/vector/app/ui/UiAllScreensSanityTest.kt index 52607bd9a1..3439bcfced 100644 --- a/vector-app/src/androidTest/java/im/vector/app/ui/UiAllScreensSanityTest.kt +++ b/vector-app/src/androidTest/java/im/vector/app/ui/UiAllScreensSanityTest.kt @@ -28,7 +28,6 @@ import im.vector.app.espresso.tools.ScreenshotFailureRule import im.vector.app.features.MainActivity import im.vector.app.getString import im.vector.app.ui.robot.ElementRobot -import im.vector.app.ui.robot.settings.labs.LabFeature import im.vector.app.ui.robot.settings.labs.LabFeaturesPreferences import im.vector.app.ui.robot.withDeveloperMode import org.junit.Rule @@ -133,6 +132,10 @@ class UiAllScreensSanityTest { } } + // Some instability with the bottomsheet + // not sure what's the source, maybe the expanded state? + Thread.sleep(10_000) + elementRobot.space { selectSpace(spaceName) } elementRobot.layoutPreferences { @@ -175,7 +178,6 @@ class UiAllScreensSanityTest { * Testing multiple threads screens */ private fun testThreadScreens() { - elementRobot.toggleLabFeature(LabFeature.THREAD_MESSAGES) elementRobot.newRoom { createNewRoom { crawl() @@ -189,6 +191,5 @@ class UiAllScreensSanityTest { } } } - elementRobot.toggleLabFeature(LabFeature.THREAD_MESSAGES) } } diff --git a/vector-app/src/androidTest/java/im/vector/app/ui/robot/NewRoomRobot.kt b/vector-app/src/androidTest/java/im/vector/app/ui/robot/NewRoomRobot.kt index 0cea26d5cc..177feb9005 100644 --- a/vector-app/src/androidTest/java/im/vector/app/ui/robot/NewRoomRobot.kt +++ b/vector-app/src/androidTest/java/im/vector/app/ui/robot/NewRoomRobot.kt @@ -18,8 +18,10 @@ package im.vector.app.ui.robot import androidx.test.espresso.Espresso.pressBack import androidx.test.espresso.matcher.ViewMatchers.withId +import androidx.test.platform.app.InstrumentationRegistry import com.adevinta.android.barista.interaction.BaristaClickInteractions.clickOn import im.vector.app.R +import im.vector.app.core.resources.BooleanProvider import im.vector.app.espresso.tools.waitUntilViewVisible import im.vector.app.features.DefaultVectorFeatures import im.vector.app.features.VectorFeatures @@ -29,7 +31,8 @@ class NewRoomRobot( var createdRoom: Boolean = false, private val labsPreferences: LabFeaturesPreferences ) { - private val features: VectorFeatures = DefaultVectorFeatures() + private val context = InstrumentationRegistry.getInstrumentation().targetContext + private val features: VectorFeatures = DefaultVectorFeatures(BooleanProvider(context.resources)) fun createNewRoom(block: CreateNewRoomRobot.() -> Unit) { clickOn(R.string.create_new_room) diff --git a/vector-app/src/androidTest/java/im/vector/app/ui/robot/OnboardingRobot.kt b/vector-app/src/androidTest/java/im/vector/app/ui/robot/OnboardingRobot.kt index e72535c116..81638928a9 100644 --- a/vector-app/src/androidTest/java/im/vector/app/ui/robot/OnboardingRobot.kt +++ b/vector-app/src/androidTest/java/im/vector/app/ui/robot/OnboardingRobot.kt @@ -22,19 +22,27 @@ import androidx.test.espresso.Espresso.pressBack import androidx.test.espresso.matcher.ViewMatchers.isRoot import androidx.test.espresso.matcher.ViewMatchers.withId import androidx.test.espresso.matcher.ViewMatchers.withText +import androidx.test.platform.app.InstrumentationRegistry import com.adevinta.android.barista.assertion.BaristaEnabledAssertions.assertDisabled import com.adevinta.android.barista.assertion.BaristaEnabledAssertions.assertEnabled import com.adevinta.android.barista.assertion.BaristaVisibilityAssertions.assertDisplayed import com.adevinta.android.barista.interaction.BaristaClickInteractions.clickOn import com.adevinta.android.barista.interaction.BaristaEditTextInteractions.writeTo import im.vector.app.R +import im.vector.app.core.resources.BooleanProvider import im.vector.app.espresso.tools.waitUntilViewVisible import im.vector.app.features.DefaultVectorFeatures +import im.vector.app.features.debug.features.DebugFeatureKeys +import im.vector.app.features.debug.features.DebugVectorFeatures import im.vector.app.waitForView class OnboardingRobot { - private val defaultVectorFeatures = DefaultVectorFeatures() + // Tchap: Override default feature values for tests + private val context = InstrumentationRegistry.getInstrumentation().targetContext + private val defaultVectorFeatures = DebugVectorFeatures(context, DefaultVectorFeatures(BooleanProvider(context.resources))).apply { + override(true, DebugFeatureKeys.tchapIsKeyBackupEnabled) + } fun crawl() { waitUntilViewVisible(withId(R.id.loginSplashSubmit)) diff --git a/vector-app/src/androidTest/java/im/vector/app/ui/robot/space/SpaceCreateRobot.kt b/vector-app/src/androidTest/java/im/vector/app/ui/robot/space/SpaceCreateRobot.kt index e5147c2085..ad6d5e5df3 100644 --- a/vector-app/src/androidTest/java/im/vector/app/ui/robot/space/SpaceCreateRobot.kt +++ b/vector-app/src/androidTest/java/im/vector/app/ui/robot/space/SpaceCreateRobot.kt @@ -28,7 +28,6 @@ import androidx.test.espresso.matcher.ViewMatchers.withText import com.adevinta.android.barista.interaction.BaristaClickInteractions.clickOn import im.vector.app.R import im.vector.app.espresso.tools.waitUntilActivityVisible -import im.vector.app.espresso.tools.waitUntilDialogVisible import im.vector.app.espresso.tools.waitUntilViewVisible import im.vector.app.features.home.HomeActivity import im.vector.app.features.home.room.detail.RoomDetailActivity @@ -86,14 +85,17 @@ class SpaceCreateRobot { clickOn(R.id.nextButton) waitUntilViewVisible(withId(R.id.recyclerView)) clickOn(R.id.nextButton) +// waitUntilActivityVisible { +// waitUntilDialogVisible(withId(R.id.inviteByMxidButton)) +// } +// // close invite dialog +// pressBack() waitUntilActivityVisible { - waitUntilDialogVisible(withId(R.id.inviteByMxidButton)) + pressBack() } - // close invite dialog - pressBack() - waitUntilViewVisible(withId(R.id.timelineRecyclerView)) +// waitUntilViewVisible(withId(R.id.timelineRecyclerView)) // close room - pressBack() +// pressBack() waitUntilViewVisible(withId(R.id.roomListContainer)) } } diff --git a/vector-app/src/androidTest/java/im/vector/app/ui/robot/space/SpaceMenuRobot.kt b/vector-app/src/androidTest/java/im/vector/app/ui/robot/space/SpaceMenuRobot.kt index d04746bcd6..73a063857a 100644 --- a/vector-app/src/androidTest/java/im/vector/app/ui/robot/space/SpaceMenuRobot.kt +++ b/vector-app/src/androidTest/java/im/vector/app/ui/robot/space/SpaceMenuRobot.kt @@ -89,9 +89,8 @@ class SpaceMenuRobot { clickOnSheet(R.id.leaveSpace) waitUntilActivityVisible { waitUntilViewVisible(ViewMatchers.withId(R.id.roomList)) + clickOn(R.id.spaceLeaveSelectAll) + clickOn(R.id.spaceLeaveButton) } - clickOn(R.id.spaceLeaveSelectAll) - clickOn(R.id.spaceLeaveButton) - waitUntilViewVisible(ViewMatchers.withId(R.id.groupListView)) } } diff --git a/vector-app/src/androidTest/java/im/vector/app/ui/robot/space/SpaceRobot.kt b/vector-app/src/androidTest/java/im/vector/app/ui/robot/space/SpaceRobot.kt index e8ff58ba6a..a067b7d348 100644 --- a/vector-app/src/androidTest/java/im/vector/app/ui/robot/space/SpaceRobot.kt +++ b/vector-app/src/androidTest/java/im/vector/app/ui/robot/space/SpaceRobot.kt @@ -22,10 +22,12 @@ import androidx.test.espresso.action.ViewActions.click import androidx.test.espresso.action.ViewActions.longClick import androidx.test.espresso.contrib.RecyclerViewActions import androidx.test.espresso.matcher.ViewMatchers +import androidx.test.platform.app.InstrumentationRegistry import com.adevinta.android.barista.interaction.BaristaClickInteractions.clickOn import com.adevinta.android.barista.interaction.BaristaDrawerInteractions.openDrawer import com.adevinta.android.barista.internal.viewaction.ClickChildAction import im.vector.app.R +import im.vector.app.core.resources.BooleanProvider import im.vector.app.espresso.tools.waitUntilDialogVisible import im.vector.app.espresso.tools.waitUntilViewVisible import im.vector.app.features.DefaultVectorFeatures @@ -34,7 +36,8 @@ import im.vector.app.ui.robot.settings.labs.LabFeaturesPreferences import org.hamcrest.Matchers class SpaceRobot(private val labsPreferences: LabFeaturesPreferences) { - private val features: VectorFeatures = DefaultVectorFeatures() + private val context = InstrumentationRegistry.getInstrumentation().targetContext + private val features: VectorFeatures = DefaultVectorFeatures(BooleanProvider(context.resources)) fun createSpace(isFirstSpace: Boolean, block: SpaceCreateRobot.() -> Unit) { if (labsPreferences.isNewAppLayoutEnabled) { diff --git a/vector-app/src/debug/java/im/vector/app/features/debug/di/FeaturesModule.kt b/vector-app/src/debug/java/im/vector/app/features/debug/di/FeaturesModule.kt index 3a68a0b956..6948adc8b4 100644 --- a/vector-app/src/debug/java/im/vector/app/features/debug/di/FeaturesModule.kt +++ b/vector-app/src/debug/java/im/vector/app/features/debug/di/FeaturesModule.kt @@ -22,6 +22,7 @@ import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent +import im.vector.app.core.resources.BooleanProvider import im.vector.app.features.DefaultVectorFeatures import im.vector.app.features.DefaultVectorOverrides import im.vector.app.features.VectorFeatures @@ -42,8 +43,8 @@ interface FeaturesModule { companion object { @Provides - fun providesDefaultVectorFeatures(): DefaultVectorFeatures { - return DefaultVectorFeatures() + fun providesDefaultVectorFeatures(booleanProvider: BooleanProvider): DefaultVectorFeatures { + return DefaultVectorFeatures(booleanProvider) } @Provides diff --git a/vector-app/src/debug/java/im/vector/app/features/debug/features/DebugVectorFeatures.kt b/vector-app/src/debug/java/im/vector/app/features/debug/features/DebugVectorFeatures.kt index 5c497c24ec..40849a9558 100644 --- a/vector-app/src/debug/java/im/vector/app/features/debug/features/DebugVectorFeatures.kt +++ b/vector-app/src/debug/java/im/vector/app/features/debug/features/DebugVectorFeatures.kt @@ -40,6 +40,12 @@ class DebugVectorFeatures( private val dataStore = context.dataStore + override fun tchapIsVoipSupported() = vectorFeatures.tchapIsVoipSupported() + + override fun tchapIsCrossSigningEnabled() = vectorFeatures.tchapIsCrossSigningEnabled() + + override fun tchapIsKeyBackupEnabled() = read(DebugFeatureKeys.tchapIsKeyBackupEnabled) ?: vectorFeatures.tchapIsKeyBackupEnabled() + override fun onboardingVariant(): OnboardingVariant { return readPreferences().getEnum() ?: vectorFeatures.onboardingVariant() } @@ -88,6 +94,9 @@ class DebugVectorFeatures( override fun isVoiceBroadcastEnabled(): Boolean = read(DebugFeatureKeys.voiceBroadcastEnabled) ?: vectorFeatures.isVoiceBroadcastEnabled() + override fun isUnverifiedSessionsAlertEnabled(): Boolean = read(DebugFeatureKeys.unverifiedSessionsAlertEnabled) + ?: vectorFeatures.isUnverifiedSessionsAlertEnabled() + fun override(value: T?, key: Preferences.Key) = updatePreferences { if (value == null) { it.remove(key) @@ -136,6 +145,7 @@ private inline fun > enumPreferencesKey() = enumPreferencesK private fun > enumPreferencesKey(type: KClass) = stringPreferencesKey("enum-${type.simpleName}") object DebugFeatureKeys { + val tchapIsKeyBackupEnabled = booleanPreferencesKey("tchap_is_key_backup_enabled") val onboardingAlreadyHaveAnAccount = booleanPreferencesKey("onboarding-already-have-an-account") val onboardingSplashCarousel = booleanPreferencesKey("onboarding-splash-carousel") val onboardingUseCase = booleanPreferencesKey("onboarding-use-case") @@ -151,4 +161,5 @@ object DebugFeatureKeys { val qrCodeLoginForAllServers = booleanPreferencesKey("qr-code-login-for-all-servers") val reciprocateQrCodeLogin = booleanPreferencesKey("reciprocate-qr-code-login") val voiceBroadcastEnabled = booleanPreferencesKey("voice-broadcast-enabled") + val unverifiedSessionsAlertEnabled = booleanPreferencesKey("unverified-sessions-alert-enabled") } diff --git a/vector-app/src/fdroid/java/im/vector/app/di/FlavorModule.kt b/vector-app/src/fdroid/java/im/vector/app/di/FlavorModule.kt index 63b4c2a3cd..e7d9598b65 100644 --- a/vector-app/src/fdroid/java/im/vector/app/di/FlavorModule.kt +++ b/vector-app/src/fdroid/java/im/vector/app/di/FlavorModule.kt @@ -46,9 +46,9 @@ abstract class FlavorModule { @Provides fun provideNightlyProxy() = object : NightlyProxy { - override fun onHomeResumed() { - // no op - } + override fun canDisplayPopup() = false + override fun isNightlyBuild() = false + override fun updateApplication() = Unit } @Provides diff --git a/vector-app/src/fdroid/java/im/vector/app/push/fcm/FdroidFcmHelper.kt b/vector-app/src/fdroid/java/im/vector/app/push/fcm/FdroidFcmHelper.kt index 5b83769116..44fd92953e 100755 --- a/vector-app/src/fdroid/java/im/vector/app/push/fcm/FdroidFcmHelper.kt +++ b/vector-app/src/fdroid/java/im/vector/app/push/fcm/FdroidFcmHelper.kt @@ -17,7 +17,6 @@ package im.vector.app.push.fcm -import android.app.Activity import android.content.Context import im.vector.app.core.di.ActiveSessionHolder import im.vector.app.core.pushers.FcmHelper @@ -44,7 +43,7 @@ class FdroidFcmHelper @Inject constructor( // No op } - override fun ensureFcmTokenIsRetrieved(activity: Activity, pushersManager: PushersManager, registerPusher: Boolean) { + override fun ensureFcmTokenIsRetrieved(pushersManager: PushersManager, registerPusher: Boolean) { // No op } diff --git a/vector-app/src/gplay/java/im/vector/app/nightly/FirebaseNightlyProxy.kt b/vector-app/src/gplay/java/im/vector/app/nightly/FirebaseNightlyProxy.kt index 94a36036b6..71ffda7c36 100644 --- a/vector-app/src/gplay/java/im/vector/app/nightly/FirebaseNightlyProxy.kt +++ b/vector-app/src/gplay/java/im/vector/app/nightly/FirebaseNightlyProxy.kt @@ -34,8 +34,11 @@ class FirebaseNightlyProxy @Inject constructor( private val buildMeta: BuildMeta, ) : NightlyProxy { - override fun onHomeResumed() { - if (!canDisplayPopup()) return + override fun isNightlyBuild(): Boolean { + return buildMeta.applicationId in nightlyPackages + } + + override fun updateApplication() { val firebaseAppDistribution = FirebaseAppDistribution.getInstance() firebaseAppDistribution.updateIfNewReleaseAvailable() .addOnProgressListener { up -> @@ -46,6 +49,7 @@ class FirebaseNightlyProxy @Inject constructor( when (e.errorCode) { FirebaseAppDistributionException.Status.NOT_IMPLEMENTED -> { // SDK did nothing. This is expected when building for Play. + Timber.d("FirebaseAppDistribution NOT_IMPLEMENTED error") } else -> { // Handle other errors. @@ -56,10 +60,14 @@ class FirebaseNightlyProxy @Inject constructor( Timber.e(e, "FirebaseAppDistribution - other error") } } + .addOnSuccessListener { + Timber.d("FirebaseAppDistribution Success!") + } } - private fun canDisplayPopup(): Boolean { - if (buildMeta.applicationId != "im.vector.app.nightly") return false + override fun canDisplayPopup(): Boolean { + if (!POPUP_IS_ENABLED) return false + if (!isNightlyBuild()) return false val today = clock.epochMillis() / A_DAY_IN_MILLIS val lastDisplayPopupDay = sharedPreferences.getLong(SHARED_PREF_KEY, 0) return (today > lastDisplayPopupDay) @@ -73,7 +81,12 @@ class FirebaseNightlyProxy @Inject constructor( } companion object { + private const val POPUP_IS_ENABLED = false private const val A_DAY_IN_MILLIS = 8_600_000L private const val SHARED_PREF_KEY = "LAST_NIGHTLY_POPUP_DAY" + + private val nightlyPackages = listOf( + "im.vector.app.nightly" + ) } } diff --git a/vector-app/src/gplay/java/im/vector/app/push/fcm/GoogleFcmHelper.kt b/vector-app/src/gplay/java/im/vector/app/push/fcm/GoogleFcmHelper.kt index 7cf90cf874..53e65f88b4 100755 --- a/vector-app/src/gplay/java/im/vector/app/push/fcm/GoogleFcmHelper.kt +++ b/vector-app/src/gplay/java/im/vector/app/push/fcm/GoogleFcmHelper.kt @@ -15,7 +15,6 @@ */ package im.vector.app.push.fcm -import android.app.Activity import android.content.Context import android.content.SharedPreferences import android.widget.Toast @@ -23,6 +22,7 @@ import androidx.core.content.edit import com.google.android.gms.common.ConnectionResult import com.google.android.gms.common.GoogleApiAvailability import com.google.firebase.messaging.FirebaseMessaging +import dagger.hilt.android.qualifiers.ApplicationContext import im.vector.app.R import im.vector.app.core.di.ActiveSessionHolder import im.vector.app.core.di.DefaultPreferences @@ -36,8 +36,8 @@ import javax.inject.Inject * It has an alter ego in the fdroid variant. */ class GoogleFcmHelper @Inject constructor( - @DefaultPreferences - private val sharedPrefs: SharedPreferences, + @ApplicationContext private val context: Context, + @DefaultPreferences private val sharedPrefs: SharedPreferences, ) : FcmHelper { companion object { private const val PREFS_KEY_FCM_TOKEN = "FCM_TOKEN" @@ -56,10 +56,9 @@ class GoogleFcmHelper @Inject constructor( } } - override fun ensureFcmTokenIsRetrieved(activity: Activity, pushersManager: PushersManager, registerPusher: Boolean) { - // if (TextUtils.isEmpty(getFcmToken(activity))) { + override fun ensureFcmTokenIsRetrieved(pushersManager: PushersManager, registerPusher: Boolean) { // 'app should always check the device for a compatible Google Play services APK before accessing Google Play services features' - if (checkPlayServices(activity)) { + if (checkPlayServices(context)) { try { FirebaseMessaging.getInstance().token .addOnSuccessListener { token -> @@ -75,7 +74,7 @@ class GoogleFcmHelper @Inject constructor( Timber.e(e, "## ensureFcmTokenIsRetrieved() : failed") } } else { - Toast.makeText(activity, R.string.no_valid_google_play_services_apk, Toast.LENGTH_SHORT).show() + Toast.makeText(context, R.string.no_valid_google_play_services_apk, Toast.LENGTH_SHORT).show() Timber.e("No valid Google Play Services found. Cannot use FCM.") } } diff --git a/vector-app/src/main/AndroidManifest.xml b/vector-app/src/main/AndroidManifest.xml index 661bd3f934..68325ab512 100644 --- a/vector-app/src/main/AndroidManifest.xml +++ b/vector-app/src/main/AndroidManifest.xml @@ -10,6 +10,7 @@ android:hasFragileUserData="true" android:icon="@mipmap/ic_launcher" android:label="@string/app_name" + android:largeHeap="true" android:networkSecurityConfig="@xml/network_security_config" android:resizeableActivity="true" android:roundIcon="@mipmap/ic_launcher_round" diff --git a/vector-app/src/main/java/im/vector/app/VectorApplication.kt b/vector-app/src/main/java/im/vector/app/VectorApplication.kt index 3a98bed9fd..e6c0b5548a 100644 --- a/vector-app/src/main/java/im/vector/app/VectorApplication.kt +++ b/vector-app/src/main/java/im/vector/app/VectorApplication.kt @@ -50,6 +50,7 @@ import im.vector.app.core.debug.LeakDetector import im.vector.app.core.di.ActiveSessionHolder import im.vector.app.core.pushers.FcmHelper import im.vector.app.core.resources.BuildMeta +import im.vector.app.features.VectorFeatures import im.vector.app.features.analytics.VectorAnalytics import im.vector.app.features.call.webrtc.WebRtcCallManager import im.vector.app.features.configuration.VectorConfiguration @@ -84,6 +85,7 @@ class VectorApplication : WorkConfiguration.Provider { lateinit var appContext: Context + @Inject lateinit var vectorFeatures: VectorFeatures @Inject lateinit var legacySessionImporter: LegacySessionImporter @Inject lateinit var authenticationService: AuthenticationService @Inject lateinit var vectorConfiguration: VectorConfiguration @@ -134,7 +136,7 @@ class VectorApplication : vectorUncaughtExceptionHandler.activate() // Remove Log handler statically added by Jitsi - if (BuildConfig.IS_VOIP_SUPPORTED) { + if (vectorFeatures.tchapIsVoipSupported()) { Timber.forest() .filter { it::class.java.name == "org.jitsi.meet.sdk.log.JitsiMeetDefaultLogHandler" } .forEach { Timber.uproot(it) } @@ -240,7 +242,7 @@ class VectorApplication : } private fun logInfo() { - val appVersion = versionProvider.getVersion(longFormat = true, useBuildNumber = true) + val appVersion = versionProvider.getVersion(longFormat = true) val sdkVersion = Matrix.getSdkVersion() val date = SimpleDateFormat("MM-dd HH:mm:ss.SSSZ", Locale.US).format(Date()) diff --git a/vector-app/src/main/java/im/vector/app/core/di/SingletonModule.kt b/vector-app/src/main/java/im/vector/app/core/di/SingletonModule.kt index 2f3fd5dfd4..9e34d62cb2 100644 --- a/vector-app/src/main/java/im/vector/app/core/di/SingletonModule.kt +++ b/vector-app/src/main/java/im/vector/app/core/di/SingletonModule.kt @@ -47,8 +47,10 @@ import im.vector.app.core.utils.AndroidSystemSettingsProvider import im.vector.app.core.utils.SystemSettingsProvider import im.vector.app.features.analytics.AnalyticsTracker import im.vector.app.features.analytics.VectorAnalytics +import im.vector.app.features.analytics.errors.ErrorTracker import im.vector.app.features.analytics.impl.DefaultVectorAnalytics import im.vector.app.features.analytics.metrics.VectorPlugins +import im.vector.app.features.configuration.VectorCustomEventTypesProvider import im.vector.app.features.invite.AutoAcceptInvites import im.vector.app.features.invite.CompileTimeAutoAcceptInvites import im.vector.app.features.navigation.DefaultNavigator @@ -69,11 +71,13 @@ import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.SupervisorJob import org.matrix.android.sdk.api.Matrix import org.matrix.android.sdk.api.MatrixConfiguration +import org.matrix.android.sdk.api.SyncConfig import org.matrix.android.sdk.api.auth.AuthenticationService import org.matrix.android.sdk.api.auth.HomeServerHistoryService import org.matrix.android.sdk.api.legacy.LegacySessionImporter import org.matrix.android.sdk.api.raw.RawService import org.matrix.android.sdk.api.session.Session +import org.matrix.android.sdk.api.session.sync.filter.SyncFilterParams import org.matrix.android.sdk.api.settings.LightweightSettingsStorage import javax.inject.Singleton @@ -85,6 +89,9 @@ import javax.inject.Singleton @Binds abstract fun bindVectorAnalytics(analytics: DefaultVectorAnalytics): VectorAnalytics + @Binds + abstract fun bindErrorTracker(analytics: DefaultVectorAnalytics): ErrorTracker + @Binds abstract fun bindAnalyticsTracker(analytics: DefaultVectorAnalytics): AnalyticsTracker @@ -143,6 +150,7 @@ import javax.inject.Singleton flipperProxy: FlipperProxy, context: Context, vectorPlugins: VectorPlugins, + vectorCustomEventTypesProvider: VectorCustomEventTypesProvider, ): MatrixConfiguration { return MatrixConfiguration( applicationFlavor = BuildConfig.FLAVOR_DESCRIPTION, @@ -154,6 +162,10 @@ import javax.inject.Singleton // Tchap: Use custom permalink prefix clientPermalinkBaseUrl = context.getString(R.string.permalink_prefix), metricPlugins = vectorPlugins.plugins(), + customEventTypesProvider = vectorCustomEventTypesProvider, + syncConfig = SyncConfig( + syncFilterParams = SyncFilterParams(lazyLoadMembersForStateEvents = true, useThreadNotifications = true) + ) ) } @@ -225,7 +237,6 @@ import javax.inject.Singleton gitRevision = BuildConfig.GIT_REVISION, gitRevisionDate = BuildConfig.GIT_REVISION_DATE, gitBranchName = BuildConfig.GIT_BRANCH_NAME, - buildNumber = BuildConfig.BUILD_NUMBER, flavorDescription = BuildConfig.FLAVOR_DESCRIPTION, flavorShortDescription = BuildConfig.SHORT_FLAVOR_DESCRIPTION, ) diff --git a/vector-app/src/release/java/im/vector/app/core/di/FeaturesModule.kt b/vector-app/src/release/java/im/vector/app/core/di/FeaturesModule.kt index b20dd885c2..9c838dd103 100644 --- a/vector-app/src/release/java/im/vector/app/core/di/FeaturesModule.kt +++ b/vector-app/src/release/java/im/vector/app/core/di/FeaturesModule.kt @@ -20,6 +20,7 @@ import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent +import im.vector.app.core.resources.BooleanProvider import im.vector.app.features.DefaultVectorFeatures import im.vector.app.features.DefaultVectorOverrides import im.vector.app.features.VectorFeatures @@ -30,8 +31,8 @@ import im.vector.app.features.VectorOverrides object FeaturesModule { @Provides - fun providesFeatures(): VectorFeatures { - return DefaultVectorFeatures() + fun providesFeatures(booleanProvider: BooleanProvider): VectorFeatures { + return DefaultVectorFeatures(booleanProvider) } @Provides diff --git a/vector-config/build.gradle b/vector-config/build.gradle index 52dd61546a..39f1fdecfb 100644 --- a/vector-config/build.gradle +++ b/vector-config/build.gradle @@ -14,9 +14,13 @@ android { } // The 'target' dimension permits to specify which platform are used - flavorDimensions "target" + // The 'voip' flavor dimension permits to include/exclude jitsi at compilation time + flavorDimensions "target", "voip" productFlavors { + withvoip { dimension "voip" } + withoutvoip { dimension "voip" } + devTchap { dimension "target" } btchap { dimension "target" } tchap { dimension "target" } diff --git a/vector-config/src/btchap/res/values/config-features.xml b/vector-config/src/btchap/res/values/config-features.xml new file mode 100755 index 0000000000..5542daeda5 --- /dev/null +++ b/vector-config/src/btchap/res/values/config-features.xml @@ -0,0 +1,5 @@ + + + true + true + diff --git a/vector-config/src/btchap/res/values/config-settings.xml b/vector-config/src/btchap/res/values/config-settings.xml new file mode 100755 index 0000000000..8c8a269d16 --- /dev/null +++ b/vector-config/src/btchap/res/values/config-settings.xml @@ -0,0 +1,4 @@ + + + true + diff --git a/vector-config/src/debug/res/values/config-settings.xml b/vector-config/src/debug/res/values/config-settings.xml new file mode 100755 index 0000000000..87946442ba --- /dev/null +++ b/vector-config/src/debug/res/values/config-settings.xml @@ -0,0 +1,7 @@ + + + + + true + + diff --git a/vector-config/src/devTchap/res/values/config-features.xml b/vector-config/src/devTchap/res/values/config-features.xml new file mode 100755 index 0000000000..5542daeda5 --- /dev/null +++ b/vector-config/src/devTchap/res/values/config-features.xml @@ -0,0 +1,5 @@ + + + true + true + diff --git a/vector-config/src/devTchap/res/values/config-settings.xml b/vector-config/src/devTchap/res/values/config-settings.xml new file mode 100755 index 0000000000..8c8a269d16 --- /dev/null +++ b/vector-config/src/devTchap/res/values/config-settings.xml @@ -0,0 +1,4 @@ + + + true + diff --git a/vector-config/src/main/java/im/vector/app/config/Config.kt b/vector-config/src/main/java/im/vector/app/config/Config.kt index 21a5ee7bc2..516efe4a85 100644 --- a/vector-config/src/main/java/im/vector/app/config/Config.kt +++ b/vector-config/src/main/java/im/vector/app/config/Config.kt @@ -16,6 +16,8 @@ package im.vector.app.config +import kotlin.time.Duration.Companion.days + /** * Set of flags to configure the application. */ @@ -89,4 +91,6 @@ object Config { * Can be disabled by providing Analytics.Disabled */ val NIGHTLY_ANALYTICS_CONFIG = Analytics.Disabled // Tchap: No analytics + + val SHOW_UNVERIFIED_SESSIONS_ALERT_AFTER_MILLIS = 7.days.inWholeMilliseconds // 1 Week } diff --git a/vector-config/src/main/res/values/config-settings.xml b/vector-config/src/main/res/values/config-settings.xml index aa6b1a1534..eabd576453 100755 --- a/vector-config/src/main/res/values/config-settings.xml +++ b/vector-config/src/main/res/values/config-settings.xml @@ -29,9 +29,11 @@ + false + true + true - - true + true false false @@ -42,9 +44,11 @@ true + false + true true - false + true true false true @@ -57,10 +61,13 @@ true false + false + + false diff --git a/vector-config/src/tchap/res/values/config-features.xml b/vector-config/src/tchap/res/values/config-features.xml new file mode 100755 index 0000000000..5542daeda5 --- /dev/null +++ b/vector-config/src/tchap/res/values/config-features.xml @@ -0,0 +1,5 @@ + + + true + true + diff --git a/vector-config/src/tchap/res/values/config-settings.xml b/vector-config/src/tchap/res/values/config-settings.xml new file mode 100755 index 0000000000..6b542c4f3f --- /dev/null +++ b/vector-config/src/tchap/res/values/config-settings.xml @@ -0,0 +1,4 @@ + + + false + diff --git a/vector-config/src/withoutvoip/res/values/config-features.xml b/vector-config/src/withoutvoip/res/values/config-features.xml new file mode 100755 index 0000000000..bcf6dbbff3 --- /dev/null +++ b/vector-config/src/withoutvoip/res/values/config-features.xml @@ -0,0 +1,4 @@ + + + false + diff --git a/vector-config/src/withvoip/res/values/config-features.xml b/vector-config/src/withvoip/res/values/config-features.xml new file mode 100755 index 0000000000..6f4b43e696 --- /dev/null +++ b/vector-config/src/withvoip/res/values/config-features.xml @@ -0,0 +1,4 @@ + + + true + diff --git a/vector/build.gradle b/vector/build.gradle index 7e2e54ead6..100899e5de 100644 --- a/vector/build.gradle +++ b/vector/build.gradle @@ -40,15 +40,6 @@ android { minSdk versions.minSdk targetSdk versions.targetSdk - // Tchap: Disable Location Sharing - resValue "bool", "enable_location_sharing", "false" - - // Tchap: The spaces feature is hidden, show all rooms in the home - def showSpaces = false - def showAllInHome = !showSpaces - resValue "bool", "show_spaces", "${showSpaces}" - resValue "bool", "spaces_show_all_in_home", "${showAllInHome}" - testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" // Tchap: Disable universalApk @@ -82,17 +73,10 @@ android { buildTypes { debug { - // Tchap: Show developer mode only in debug - resValue "bool", "developer_mode_visible", "true" - if (project.hasProperty("coverage")) { testCoverageEnabled = coverage.enableTestCoverage } } - release { - // Tchap: Show developer mode only in debug - resValue "bool", "developer_mode_visible", "false" - } } // The 'store' dimension permits to deal with GooglePlay/Fdroid app @@ -110,72 +94,15 @@ android { dimension "store" } - withvoip { - dimension "voip" - - buildConfigField "boolean", "IS_VOIP_SUPPORTED", "true" - resValue "bool", "is_voip_supported", "true" - } - - withoutvoip { - dimension "voip" - - buildConfigField "boolean", "IS_VOIP_SUPPORTED", "false" - resValue "bool", "is_voip_supported", "false" - } - - withpinning { - dimension "pinning" - } - - withoutpinning { - dimension "pinning" - } - - devTchap { - dimension "target" - - // Tchap: Key Backup is not supported - buildConfigField "Boolean", "IS_KEY_BACKUP_SUPPORTED", "true" - - // Tchap: CrossSigning initialization is disabled - def enableCrossSigning = true - buildConfigField "Boolean", "ENABLE_CROSS_SIGNING", "${enableCrossSigning}" - resValue "bool", "enable_cross_signing", "${enableCrossSigning}" - - // Tchap: Show labs menu item in devTchap - resValue "bool", "labs_settings_visible", "true" - } - - btchap { - dimension "target" - - // Tchap: Key Backup is not supported - buildConfigField "Boolean", "IS_KEY_BACKUP_SUPPORTED", "false" + withvoip { dimension "voip" } + withoutvoip { dimension "voip" } - // Tchap: CrossSigning initialization is disabled - def enableCrossSigning = false - buildConfigField "Boolean", "ENABLE_CROSS_SIGNING", "${enableCrossSigning}" - resValue "bool", "enable_cross_signing", "${enableCrossSigning}" + withpinning { dimension "pinning" } + withoutpinning { dimension "pinning" } - // Tchap: Show labs menu item in btchap - resValue "bool", "labs_settings_visible", "true" - } - - tchap { - dimension "target" - - // Tchap: Key Backup is not supported - buildConfigField "Boolean", "IS_KEY_BACKUP_SUPPORTED", "false" - - // Tchap: CrossSigning initialization is disabled - def enableCrossSigning = false - buildConfigField "Boolean", "ENABLE_CROSS_SIGNING", "${enableCrossSigning}" - resValue "bool", "enable_cross_signing", "${enableCrossSigning}" - - // Tchap: Hide labs menu item in tchap - resValue "bool", "labs_settings_visible", "false" - } + devTchap { dimension "target" } + btchap { dimension "target" } + tchap { dimension "target" } } compileOptions { @@ -237,7 +164,7 @@ dependencies { implementation libs.androidx.biometric api "org.threeten:threetenbp:1.4.0:no-tzdb" - api "com.gabrielittner.threetenbp:lazythreetenbp:0.11.0" + api "com.gabrielittner.threetenbp:lazythreetenbp:0.12.0" implementation libs.squareup.moshi kapt libs.squareup.moshiKotlin @@ -338,7 +265,7 @@ dependencies { kapt libs.dagger.hiltCompiler // Analytics - implementation('com.posthog.android:posthog:1.1.2') { + implementation('com.posthog.android:posthog:2.0.0') { exclude group: 'com.android.support', module: 'support-annotations' } implementation libs.sentry.sentryAndroid @@ -417,7 +344,7 @@ dependencies { // Fix issue with Jitsi. Inspired from https://github.com/android/android-test/issues/861#issuecomment-872067868 // Error was lots of `Duplicate class org.checkerframework.common.reflection.qual.MethodVal found in modules jetified-checker-3.1 (org.checkerframework:checker:3.1.1) and jetified-checker-qual-3.12.0 (org.checkerframework:checker-qual:3.12.0) //noinspection GradleDependency Cannot use latest 3.15.0 since it required min API 26. - implementation "org.checkerframework:checker:3.11.0" + implementation "org.checkerframework:checker:3.27.0" androidTestImplementation libs.androidx.testCore androidTestImplementation libs.androidx.testRunner @@ -434,11 +361,11 @@ dependencies { // Plant Timber tree for test androidTestImplementation libs.tests.timberJunitRule // "The one who serves a great Espresso" - androidTestImplementation('com.adevinta.android:barista:4.2.0') { + androidTestImplementation('com.adevinta.android:barista:4.3.0') { exclude group: 'org.jetbrains.kotlin' } androidTestImplementation libs.mockk.mockkAndroid androidTestUtil libs.androidx.orchestrator debugImplementation libs.androidx.fragmentTesting - androidTestImplementation "org.jetbrains.kotlin:kotlin-reflect:1.7.20" + androidTestImplementation "org.jetbrains.kotlin:kotlin-reflect:1.7.22" } diff --git a/vector/src/androidTest/java/im/vector/app/core/utils/TestSpan.kt b/vector/src/androidTest/java/im/vector/app/core/utils/TestSpan.kt index 9e23e76f0c..e31dc6942c 100644 --- a/vector/src/androidTest/java/im/vector/app/core/utils/TestSpan.kt +++ b/vector/src/androidTest/java/im/vector/app/core/utils/TestSpan.kt @@ -29,6 +29,7 @@ import io.mockk.verify import io.noties.markwon.core.spans.EmphasisSpan import io.noties.markwon.core.spans.OrderedListItemSpan import io.noties.markwon.core.spans.StrongEmphasisSpan +import me.gujun.android.span.style.CustomTypefaceSpan fun Spannable.toTestSpan(): String { var output = toString() @@ -54,7 +55,7 @@ private fun Any.readTags(): SpanTags { OrderedListItemSpan::class -> SpanTags("[list item]", "[/list item]") HtmlCodeSpan::class -> SpanTags("[code]", "[/code]") StrongEmphasisSpan::class -> SpanTags("[bold]", "[/bold]") - EmphasisSpan::class -> SpanTags("[italic]", "[/italic]") + EmphasisSpan::class, CustomTypefaceSpan::class -> SpanTags("[italic]", "[/italic]") else -> throw IllegalArgumentException("Unknown ${this::class}") } } diff --git a/vector/src/androidTest/java/im/vector/app/features/voice/VoiceRecorderProviderTests.kt b/vector/src/androidTest/java/im/vector/app/features/voice/VoiceRecorderProviderTests.kt index 0610496dfe..c531c7b8da 100644 --- a/vector/src/androidTest/java/im/vector/app/features/voice/VoiceRecorderProviderTests.kt +++ b/vector/src/androidTest/java/im/vector/app/features/voice/VoiceRecorderProviderTests.kt @@ -19,6 +19,7 @@ package im.vector.app.features.voice import android.os.Build import androidx.test.platform.app.InstrumentationRegistry import im.vector.app.TestBuildVersionSdkIntProvider +import im.vector.app.core.resources.BooleanProvider import im.vector.app.features.DefaultVectorFeatures import io.mockk.every import io.mockk.spyk @@ -29,7 +30,7 @@ class VoiceRecorderProviderTests { private val context = InstrumentationRegistry.getInstrumentation().targetContext private val buildVersionSdkIntProvider = TestBuildVersionSdkIntProvider() - private val provider = spyk(VoiceRecorderProvider(context, DefaultVectorFeatures(), buildVersionSdkIntProvider)) + private val provider = spyk(VoiceRecorderProvider(context, DefaultVectorFeatures(BooleanProvider(context.resources)), buildVersionSdkIntProvider)) @Test fun provideVoiceRecorderOnAndroidQAndCodecReturnsQRecorder() { diff --git a/vector/src/main/AndroidManifest.xml b/vector/src/main/AndroidManifest.xml index 6201f8c815..5fa44cd99d 100644 --- a/vector/src/main/AndroidManifest.xml +++ b/vector/src/main/AndroidManifest.xml @@ -73,7 +73,9 @@ - + - + + android:parentActivityName=".features.home.HomeActivity" + android:windowSoftInputMode="adjustResize"> @@ -329,6 +332,7 @@ @@ -336,10 +340,11 @@ - + = AtomicReference() @@ -72,7 +71,7 @@ class ActiveSessionHolder @Inject constructor( suspend fun clearActiveSession() { // Do some cleanup first - getSafeActiveSession(startSync = false)?.let { + getSafeActiveSession()?.let { Timber.w("clearActiveSession of ${it.myUserId}") it.callSignalingService().removeCallListener(callManager) it.removeListener(sessionListener) @@ -85,7 +84,7 @@ class ActiveSessionHolder @Inject constructor( incomingVerificationRequestHandler.stop() pushRuleTriggerListener.stop() // No need to unregister the pusher, the sign out will (should?) do it server side. - unifiedPushHelper.unregister(pushersManager = null) + unregisterUnifiedPushUseCase.execute(pushersManager = null) guardServiceStarter.stop() } @@ -93,8 +92,8 @@ class ActiveSessionHolder @Inject constructor( return activeSessionReference.get() != null || authenticationService.hasAuthenticatedSessions() } - fun getSafeActiveSession(startSync: Boolean = true): Session? { - return runBlocking { getOrInitializeSession(startSync = startSync) } + fun getSafeActiveSession(): Session? { + return runBlocking { getOrInitializeSession() } } fun getActiveSession(): Session { @@ -102,18 +101,11 @@ class ActiveSessionHolder @Inject constructor( ?: throw IllegalStateException("You should authenticate before using this") } - suspend fun getOrInitializeSession(startSync: Boolean): Session? { + suspend fun getOrInitializeSession(): Session? { return activeSessionReference.get() - ?.also { - if (startSync && !it.syncService().isSyncThreadAlive()) { - it.startSyncing(applicationContext) - } - } ?: sessionInitializer.tryInitialize(readCurrentSession = { activeSessionReference.get() }) { session -> setActiveSession(session) - runBlocking { - configureAndStartSessionUseCase.execute(session, startSyncing = startSync) - } + configureAndStartSessionUseCase.execute(session, startSyncing = false) } } diff --git a/vector/src/main/java/im/vector/app/core/di/MavericksViewModelModule.kt b/vector/src/main/java/im/vector/app/core/di/MavericksViewModelModule.kt index 2242abb7aa..d22ab51e7a 100644 --- a/vector/src/main/java/im/vector/app/core/di/MavericksViewModelModule.kt +++ b/vector/src/main/java/im/vector/app/core/di/MavericksViewModelModule.kt @@ -46,6 +46,7 @@ import im.vector.app.features.home.UserColorAccountDataViewModel import im.vector.app.features.home.room.breadcrumbs.BreadcrumbsViewModel import im.vector.app.features.home.room.detail.TimelineViewModel import im.vector.app.features.home.room.detail.composer.MessageComposerViewModel +import im.vector.app.features.home.room.detail.composer.link.SetLinkViewModel import im.vector.app.features.home.room.detail.search.SearchViewModel import im.vector.app.features.home.room.detail.timeline.action.MessageActionsViewModel import im.vector.app.features.home.room.detail.timeline.edithistory.ViewEditHistoryViewModel @@ -105,6 +106,7 @@ import im.vector.app.features.settings.ignored.IgnoredUsersViewModel import im.vector.app.features.settings.labs.VectorSettingsLabsViewModel import im.vector.app.features.settings.legals.LegalsViewModel import im.vector.app.features.settings.locale.LocalePickerViewModel +import im.vector.app.features.settings.notifications.VectorSettingsNotificationPreferenceViewModel import im.vector.app.features.settings.push.PushGatewaysViewModel import im.vector.app.features.settings.threepids.ThreePidsSettingsViewModel import im.vector.app.features.share.IncomingShareViewModel @@ -683,4 +685,16 @@ interface MavericksViewModelModule { @IntoMap @MavericksViewModelKey(AttachmentTypeSelectorViewModel::class) fun attachmentTypeSelectorViewModelFactory(factory: AttachmentTypeSelectorViewModel.Factory): MavericksAssistedViewModelFactory<*, *> + + @Binds + @IntoMap + @MavericksViewModelKey(VectorSettingsNotificationPreferenceViewModel::class) + fun vectorSettingsNotificationPreferenceViewModelFactory( + factory: VectorSettingsNotificationPreferenceViewModel.Factory + ): MavericksAssistedViewModelFactory<*, *> + + @Binds + @IntoMap + @MavericksViewModelKey(SetLinkViewModel::class) + fun setLinkViewModelFactory(factory: SetLinkViewModel.Factory): MavericksAssistedViewModelFactory<*, *> } diff --git a/vector/src/main/java/im/vector/app/core/di/VectorViewModelFactory.kt b/vector/src/main/java/im/vector/app/core/di/VectorViewModelFactory.kt index ded2a562c5..a14b12e7a5 100644 --- a/vector/src/main/java/im/vector/app/core/di/VectorViewModelFactory.kt +++ b/vector/src/main/java/im/vector/app/core/di/VectorViewModelFactory.kt @@ -37,9 +37,7 @@ class VectorViewModelFactory @Inject constructor( } } } - if (creator == null) { - throw IllegalArgumentException("Unknown model class: $modelClass") - } + require(creator != null) { "Unknown model class: $modelClass" } try { @Suppress("UNCHECKED_CAST") return creator.get() as T diff --git a/vector/src/main/java/im/vector/app/core/di/VoiceModule.kt b/vector/src/main/java/im/vector/app/core/di/VoiceModule.kt index 54d556ea91..40fb4ecea7 100644 --- a/vector/src/main/java/im/vector/app/core/di/VoiceModule.kt +++ b/vector/src/main/java/im/vector/app/core/di/VoiceModule.kt @@ -18,24 +18,42 @@ package im.vector.app.core.di import android.content.Context import android.os.Build +import dagger.Binds import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.components.SingletonComponent -import im.vector.app.features.voicebroadcast.VoiceBroadcastRecorder -import im.vector.app.features.voicebroadcast.VoiceBroadcastRecorderQ +import im.vector.app.features.voicebroadcast.listening.VoiceBroadcastPlayer +import im.vector.app.features.voicebroadcast.listening.VoiceBroadcastPlayerImpl +import im.vector.app.features.voicebroadcast.recording.VoiceBroadcastRecorder +import im.vector.app.features.voicebroadcast.recording.VoiceBroadcastRecorderQ +import im.vector.app.features.voicebroadcast.usecase.GetVoiceBroadcastStateEventLiveUseCase import javax.inject.Singleton -@Module @InstallIn(SingletonComponent::class) -object VoiceModule { - @Provides - @Singleton - fun providesVoiceBroadcastRecorder(context: Context): VoiceBroadcastRecorder? { - return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { - VoiceBroadcastRecorderQ(context) - } else { - null +@Module +abstract class VoiceModule { + + companion object { + @Provides + @Singleton + fun providesVoiceBroadcastRecorder( + context: Context, + sessionHolder: ActiveSessionHolder, + getVoiceBroadcastStateEventLiveUseCase: GetVoiceBroadcastStateEventLiveUseCase, + ): VoiceBroadcastRecorder? { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + VoiceBroadcastRecorderQ( + context = context, + sessionHolder = sessionHolder, + getVoiceBroadcastEventUseCase = getVoiceBroadcastStateEventLiveUseCase + ) + } else { + null + } } } + + @Binds + abstract fun bindVoiceBroadcastPlayer(player: VoiceBroadcastPlayerImpl): VoiceBroadcastPlayer } diff --git a/vector/src/main/java/im/vector/app/core/error/ErrorFormatter.kt b/vector/src/main/java/im/vector/app/core/error/ErrorFormatter.kt index 16a7cb8600..715c9977a9 100644 --- a/vector/src/main/java/im/vector/app/core/error/ErrorFormatter.kt +++ b/vector/src/main/java/im/vector/app/core/error/ErrorFormatter.kt @@ -21,6 +21,8 @@ import im.vector.app.R import im.vector.app.core.resources.StringProvider import im.vector.app.features.call.dialpad.DialPadLookup import im.vector.app.features.voice.VoiceFailure +import im.vector.app.features.voicebroadcast.VoiceBroadcastFailure +import im.vector.app.features.voicebroadcast.VoiceBroadcastFailure.RecordingError import org.matrix.android.sdk.api.failure.Failure import org.matrix.android.sdk.api.failure.MatrixError import org.matrix.android.sdk.api.failure.MatrixIdFailure @@ -152,6 +154,7 @@ class DefaultErrorFormatter @Inject constructor( is MatrixIdFailure.InvalidMatrixId -> stringProvider.getString(R.string.login_signin_matrix_id_error_invalid_matrix_id) is VoiceFailure -> voiceMessageError(throwable) + is VoiceBroadcastFailure -> voiceBroadcastMessageError(throwable) is ActivityNotFoundException -> stringProvider.getString(R.string.error_no_external_application_found) else -> throwable.localizedMessage @@ -166,6 +169,14 @@ class DefaultErrorFormatter @Inject constructor( } } + private fun voiceBroadcastMessageError(throwable: VoiceBroadcastFailure): String { + return when (throwable) { + RecordingError.BlockedBySomeoneElse -> stringProvider.getString(R.string.error_voice_broadcast_blocked_by_someone_else_message) + RecordingError.NoPermission -> stringProvider.getString(R.string.error_voice_broadcast_permission_denied_message) + RecordingError.UserAlreadyBroadcasting -> stringProvider.getString(R.string.error_voice_broadcast_already_in_progress_message) + } + } + private fun limitExceededError(error: MatrixError): String { val delay = error.retryAfterMillis diff --git a/vector/src/main/java/im/vector/app/core/extensions/Flow.kt b/vector/src/main/java/im/vector/app/core/extensions/Flow.kt new file mode 100644 index 0000000000..82e6e5f9a6 --- /dev/null +++ b/vector/src/main/java/im/vector/app/core/extensions/Flow.kt @@ -0,0 +1,35 @@ +/* + * Copyright 2022 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 im.vector.app.core.extensions + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow + +/** + * Returns a flow that invokes the given action after the first value of the upstream flow is emitted downstream. + */ +fun Flow.onFirst(action: (T) -> Unit): Flow = flow { + var emitted = false + collect { value -> + emit(value) // always emit value + + if (!emitted) { + action(value) // execute the action after the first emission + emitted = true + } + } +} diff --git a/vector/src/main/java/im/vector/app/core/extensions/MenuItemExt.kt b/vector/src/main/java/im/vector/app/core/extensions/MenuItemExt.kt new file mode 100644 index 0000000000..7d62a0c357 --- /dev/null +++ b/vector/src/main/java/im/vector/app/core/extensions/MenuItemExt.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2022 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 im.vector.app.core.extensions + +import android.view.MenuItem +import androidx.annotation.ColorInt +import androidx.core.text.toSpannable +import im.vector.app.core.utils.colorizeMatchingText + +fun MenuItem.setTextColor(@ColorInt color: Int) { + val currentTitle = title.orEmpty().toString() + title = currentTitle + .toSpannable() + .colorizeMatchingText(currentTitle, color) +} diff --git a/vector/src/main/java/im/vector/app/core/extensions/Service.kt b/vector/src/main/java/im/vector/app/core/extensions/Service.kt new file mode 100644 index 0000000000..de301df85e --- /dev/null +++ b/vector/src/main/java/im/vector/app/core/extensions/Service.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2022 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 im.vector.app.core.extensions + +import android.app.Notification +import android.app.Service +import android.content.pm.ServiceInfo +import android.os.Build + +fun Service.startForegroundCompat( + id: Int, + notification: Notification, + provideForegroundServiceType: (() -> Int)? = null +) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + startForeground( + id, + notification, + provideForegroundServiceType?.invoke() ?: ServiceInfo.FOREGROUND_SERVICE_TYPE_MANIFEST + ) + } else { + startForeground(id, notification) + } +} diff --git a/vector/src/main/java/im/vector/app/core/extensions/TimelineEvent.kt b/vector/src/main/java/im/vector/app/core/extensions/TimelineEvent.kt index 5c3393416b..c94f9cd921 100644 --- a/vector/src/main/java/im/vector/app/core/extensions/TimelineEvent.kt +++ b/vector/src/main/java/im/vector/app/core/extensions/TimelineEvent.kt @@ -27,7 +27,7 @@ import org.matrix.android.sdk.api.session.room.timeline.getLastMessageContent fun TimelineEvent.canReact(): Boolean { // Only event of type EventType.MESSAGE, EventType.STICKER and EventType.POLL_START are supported for the moment - return root.getClearType() in listOf(EventType.MESSAGE, EventType.STICKER) + EventType.POLL_START && + return root.getClearType() in listOf(EventType.MESSAGE, EventType.STICKER) + EventType.POLL_START.values && root.sendState == SendState.SYNCED && !root.isRedacted() } @@ -40,7 +40,8 @@ fun TimelineEvent.getVectorLastMessageContent(): MessageContent? { // Iterate on event types which are not part of the matrix sdk, otherwise fallback to the sdk method return when (root.getClearType()) { VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO -> { - (annotations?.editSummary?.latestContent ?: root.getClearContent()).toModel() + (annotations?.editSummary?.latestEdit?.getClearContent()?.toModel() + ?: root.getClearContent().toModel()) } else -> getLastMessageContent() } diff --git a/vector/src/main/java/im/vector/app/core/extensions/ViewExtensions.kt b/vector/src/main/java/im/vector/app/core/extensions/ViewExtensions.kt index 625ff15ef7..156809d5ad 100644 --- a/vector/src/main/java/im/vector/app/core/extensions/ViewExtensions.kt +++ b/vector/src/main/java/im/vector/app/core/extensions/ViewExtensions.kt @@ -29,7 +29,13 @@ import androidx.appcompat.widget.SearchView import androidx.core.content.ContextCompat import androidx.core.graphics.drawable.DrawableCompat import androidx.core.view.isVisible +import androidx.transition.ChangeBounds +import androidx.transition.Fade +import androidx.transition.Transition +import androidx.transition.TransitionManager +import androidx.transition.TransitionSet import im.vector.app.R +import im.vector.app.core.animations.SimpleTransitionListener import im.vector.app.features.themes.ThemeUtils /** @@ -90,3 +96,18 @@ fun View.setAttributeBackground(@AttrRes attributeId: Int) { val attribute = ThemeUtils.getAttribute(context, attributeId)!! setBackgroundResource(attribute.resourceId) } + +fun ViewGroup.animateLayoutChange(animationDuration: Long, transitionComplete: (() -> Unit)? = null) { + val transition = TransitionSet().apply { + ordering = TransitionSet.ORDERING_SEQUENTIAL + addTransition(ChangeBounds()) + addTransition(Fade(Fade.IN)) + duration = animationDuration + addListener(object : SimpleTransitionListener() { + override fun onTransitionEnd(transition: Transition) { + transitionComplete?.invoke() + } + }) + } + TransitionManager.beginDelayedTransition((parent as? ViewGroup ?: this), transition) +} diff --git a/vector/src/main/java/im/vector/app/core/notification/NotificationsSettingUpdater.kt b/vector/src/main/java/im/vector/app/core/notification/NotificationsSettingUpdater.kt new file mode 100644 index 0000000000..a4d18baa64 --- /dev/null +++ b/vector/src/main/java/im/vector/app/core/notification/NotificationsSettingUpdater.kt @@ -0,0 +1,46 @@ +/* + * Copyright (c) 2022 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 im.vector.app.core.notification + +import im.vector.app.features.session.coroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import org.matrix.android.sdk.api.session.Session +import javax.inject.Inject +import javax.inject.Singleton + +/** + * Listen changes in Pusher or Account Data to update the local setting for notification toggle. + */ +@Singleton +class NotificationsSettingUpdater @Inject constructor( + private val updateEnableNotificationsSettingOnChangeUseCase: UpdateEnableNotificationsSettingOnChangeUseCase, +) { + + private var job: Job? = null + + fun onSessionStarted(session: Session) { + updateEnableNotificationsSettingOnChange(session) + } + + private fun updateEnableNotificationsSettingOnChange(session: Session) { + job?.cancel() + job = session.coroutineScope.launch { + updateEnableNotificationsSettingOnChangeUseCase.execute(session) + } + } +} diff --git a/vector/src/main/java/im/vector/app/core/notification/UpdateEnableNotificationsSettingOnChangeUseCase.kt b/vector/src/main/java/im/vector/app/core/notification/UpdateEnableNotificationsSettingOnChangeUseCase.kt new file mode 100644 index 0000000000..36df939bad --- /dev/null +++ b/vector/src/main/java/im/vector/app/core/notification/UpdateEnableNotificationsSettingOnChangeUseCase.kt @@ -0,0 +1,50 @@ +/* + * Copyright (c) 2022 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 im.vector.app.core.notification + +import im.vector.app.features.settings.VectorPreferences +import im.vector.app.features.settings.devices.v2.notification.GetNotificationsStatusUseCase +import im.vector.app.features.settings.devices.v2.notification.NotificationsStatus +import kotlinx.coroutines.flow.collect +import kotlinx.coroutines.flow.onEach +import org.matrix.android.sdk.api.session.Session +import javax.inject.Inject + +/** + * Listen for changes in either Pusher or Account data to update the local enable notifications + * setting for the current device. + */ +class UpdateEnableNotificationsSettingOnChangeUseCase @Inject constructor( + private val vectorPreferences: VectorPreferences, + private val getNotificationsStatusUseCase: GetNotificationsStatusUseCase, +) { + + suspend fun execute(session: Session) { + val deviceId = session.sessionParams.deviceId ?: return + getNotificationsStatusUseCase.execute(session, deviceId) + .onEach(::updatePreference) + .collect() + } + + private fun updatePreference(notificationStatus: NotificationsStatus) { + when (notificationStatus) { + NotificationsStatus.ENABLED -> vectorPreferences.setNotificationEnabledForDevice(true) + NotificationsStatus.DISABLED -> vectorPreferences.setNotificationEnabledForDevice(false) + else -> Unit + } + } +} diff --git a/vector/src/main/java/im/vector/app/core/platform/VectorBaseDialogFragment.kt b/vector/src/main/java/im/vector/app/core/platform/VectorBaseDialogFragment.kt new file mode 100644 index 0000000000..5a817b989e --- /dev/null +++ b/vector/src/main/java/im/vector/app/core/platform/VectorBaseDialogFragment.kt @@ -0,0 +1,155 @@ +/* + * Copyright 2019 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 im.vector.app.core.platform + +import android.content.Context +import android.os.Bundle +import android.os.Parcelable +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.annotation.CallSuper +import androidx.fragment.app.DialogFragment +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.lifecycleScope +import androidx.viewbinding.ViewBinding +import com.airbnb.mvrx.MavericksView +import dagger.hilt.android.EntryPointAccessors +import im.vector.app.R +import im.vector.app.core.di.ActivityEntryPoint +import im.vector.app.core.extensions.singletonEntryPoint +import im.vector.app.core.extensions.toMvRxBundle +import im.vector.app.features.analytics.AnalyticsTracker +import im.vector.app.features.analytics.plan.MobileScreen +import im.vector.app.features.themes.ThemeUtils +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import reactivecircus.flowbinding.android.view.clicks +import timber.log.Timber + +/** + * Add Mavericks capabilities, handle DI and bindings. + */ +abstract class VectorBaseDialogFragment : DialogFragment(), MavericksView { + /* ========================================================================================== + * Analytics + * ========================================================================================== */ + + protected var analyticsScreenName: MobileScreen.ScreenName? = null + + protected lateinit var analyticsTracker: AnalyticsTracker + + /* ========================================================================================== + * View + * ========================================================================================== */ + + private var _binding: VB? = null + + // This property is only valid between onCreateView and onDestroyView. + protected val views: VB + get() = _binding!! + + abstract fun getBinding(inflater: LayoutInflater, container: ViewGroup?): VB + + /* ========================================================================================== + * View model + * ========================================================================================== */ + + private lateinit var viewModelFactory: ViewModelProvider.Factory + + protected val activityViewModelProvider + get() = ViewModelProvider(requireActivity(), viewModelFactory) + + protected val fragmentViewModelProvider + get() = ViewModelProvider(this, viewModelFactory) + + val vectorBaseActivity: VectorBaseActivity<*> by lazy { + activity as VectorBaseActivity<*> + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + setStyle(STYLE_NORMAL, ThemeUtils.getApplicationThemeRes(requireContext())) + } + + override fun onCreateView(inflater: LayoutInflater, container: ViewGroup?, savedInstanceState: Bundle?): View? { + _binding = getBinding(inflater, container) + return views.root + } + + @CallSuper + override fun onDestroyView() { + _binding = null + super.onDestroyView() + } + + @CallSuper + override fun onDestroy() { + super.onDestroy() + } + + override fun onAttach(context: Context) { + val activityEntryPoint = EntryPointAccessors.fromActivity(vectorBaseActivity, ActivityEntryPoint::class.java) + viewModelFactory = activityEntryPoint.viewModelFactory() + val singletonEntryPoint = context.singletonEntryPoint() + analyticsTracker = singletonEntryPoint.analyticsTracker() + super.onAttach(context) + } + + override fun onResume() { + super.onResume() + Timber.i("onResume BottomSheet ${javaClass.simpleName}") + analyticsScreenName?.let { + analyticsTracker.screen(MobileScreen(screenName = it)) + } + } + + override fun onStart() { + super.onStart() + // This ensures that invalidate() is called for static screens that don't + // subscribe to a ViewModel. + postInvalidate() + requireDialog().window?.setWindowAnimations(R.style.Animation_AppCompat_Dialog) + } + + protected fun setArguments(args: Parcelable? = null) { + arguments = args.toMvRxBundle() + } + + /* ========================================================================================== + * Views + * ========================================================================================== */ + + protected fun View.debouncedClicks(onClicked: () -> Unit) { + clicks() + .onEach { onClicked() } + .launchIn(viewLifecycleOwner.lifecycleScope) + } + + /* ========================================================================================== + * ViewEvents + * ========================================================================================== */ + + protected fun VectorViewModel<*, *, T>.observeViewEvents(observer: (T) -> Unit) { + viewEvents + .stream() + .onEach { + observer(it) + } + .launchIn(viewLifecycleOwner.lifecycleScope) + } +} diff --git a/vector/src/main/java/im/vector/app/core/pushers/EnsureFcmTokenIsRetrievedUseCase.kt b/vector/src/main/java/im/vector/app/core/pushers/EnsureFcmTokenIsRetrievedUseCase.kt new file mode 100644 index 0000000000..cb955e01f7 --- /dev/null +++ b/vector/src/main/java/im/vector/app/core/pushers/EnsureFcmTokenIsRetrievedUseCase.kt @@ -0,0 +1,41 @@ +/* + * Copyright (c) 2022 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 im.vector.app.core.pushers + +import im.vector.app.core.di.ActiveSessionHolder +import javax.inject.Inject + +class EnsureFcmTokenIsRetrievedUseCase @Inject constructor( + private val unifiedPushHelper: UnifiedPushHelper, + private val fcmHelper: FcmHelper, + private val activeSessionHolder: ActiveSessionHolder, +) { + + fun execute(pushersManager: PushersManager, registerPusher: Boolean) { + if (unifiedPushHelper.isEmbeddedDistributor()) { + fcmHelper.ensureFcmTokenIsRetrieved(pushersManager, shouldAddHttpPusher(registerPusher)) + } + } + + private fun shouldAddHttpPusher(registerPusher: Boolean) = if (registerPusher) { + val currentSession = activeSessionHolder.getActiveSession() + val currentPushers = currentSession.pushersService().getPushers() + currentPushers.none { it.deviceId == currentSession.sessionParams.deviceId } + } else { + false + } +} diff --git a/vector/src/main/java/im/vector/app/core/pushers/FcmHelper.kt b/vector/src/main/java/im/vector/app/core/pushers/FcmHelper.kt index 7b2c5e3959..381348638d 100644 --- a/vector/src/main/java/im/vector/app/core/pushers/FcmHelper.kt +++ b/vector/src/main/java/im/vector/app/core/pushers/FcmHelper.kt @@ -16,7 +16,6 @@ package im.vector.app.core.pushers -import android.app.Activity import im.vector.app.core.di.ActiveSessionHolder interface FcmHelper { @@ -39,11 +38,10 @@ interface FcmHelper { /** * onNewToken may not be called on application upgrade, so ensure my shared pref is set. * - * @param activity the first launch Activity. * @param pushersManager the instance to register the pusher on. * @param registerPusher whether the pusher should be registered. */ - fun ensureFcmTokenIsRetrieved(activity: Activity, pushersManager: PushersManager, registerPusher: Boolean) + fun ensureFcmTokenIsRetrieved(pushersManager: PushersManager, registerPusher: Boolean) fun onEnterForeground(activeSessionHolder: ActiveSessionHolder) diff --git a/vector/src/main/java/im/vector/app/core/pushers/PushersManager.kt b/vector/src/main/java/im/vector/app/core/pushers/PushersManager.kt index cda6f5bae8..6f186262fc 100644 --- a/vector/src/main/java/im/vector/app/core/pushers/PushersManager.kt +++ b/vector/src/main/java/im/vector/app/core/pushers/PushersManager.kt @@ -97,12 +97,6 @@ class PushersManager @Inject constructor( return session.pushersService().getPushers().firstOrNull { it.deviceId == deviceId } } - suspend fun togglePusherForCurrentSession(enable: Boolean) { - val session = activeSessionHolder.getSafeActiveSession() ?: return - val pusher = getPusherForCurrentSession() ?: return - session.pushersService().togglePusher(pusher, enable) - } - suspend fun unregisterEmailPusher(email: String) { val currentSession = activeSessionHolder.getSafeActiveSession() ?: return currentSession.pushersService().removeEmailPusher(email) diff --git a/vector/src/main/java/im/vector/app/core/pushers/RegisterUnifiedPushUseCase.kt b/vector/src/main/java/im/vector/app/core/pushers/RegisterUnifiedPushUseCase.kt new file mode 100644 index 0000000000..aa3652a54f --- /dev/null +++ b/vector/src/main/java/im/vector/app/core/pushers/RegisterUnifiedPushUseCase.kt @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2022 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 im.vector.app.core.pushers + +import android.content.Context +import dagger.hilt.android.qualifiers.ApplicationContext +import im.vector.app.features.VectorFeatures +import org.unifiedpush.android.connector.UnifiedPush +import javax.inject.Inject + +class RegisterUnifiedPushUseCase @Inject constructor( + @ApplicationContext private val context: Context, + private val vectorFeatures: VectorFeatures, +) { + + sealed interface RegisterUnifiedPushResult { + object Success : RegisterUnifiedPushResult + object NeedToAskUserForDistributor : RegisterUnifiedPushResult + } + + fun execute(distributor: String = ""): RegisterUnifiedPushResult { + if (distributor.isNotEmpty()) { + saveAndRegisterApp(distributor) + return RegisterUnifiedPushResult.Success + } + + if (!vectorFeatures.allowExternalUnifiedPushDistributors()) { + saveAndRegisterApp(context.packageName) + return RegisterUnifiedPushResult.Success + } + + if (UnifiedPush.getDistributor(context).isNotEmpty()) { + registerApp() + return RegisterUnifiedPushResult.Success + } + + val distributors = UnifiedPush.getDistributors(context) + + return if (distributors.size == 1) { + saveAndRegisterApp(distributors.first()) + RegisterUnifiedPushResult.Success + } else { + RegisterUnifiedPushResult.NeedToAskUserForDistributor + } + } + + private fun saveAndRegisterApp(distributor: String) { + UnifiedPush.saveDistributor(context, distributor) + registerApp() + } + + private fun registerApp() { + UnifiedPush.registerApp(context) + } +} diff --git a/vector/src/main/java/im/vector/app/core/pushers/UnifiedPushHelper.kt b/vector/src/main/java/im/vector/app/core/pushers/UnifiedPushHelper.kt index aab94eca93..9f96f13ee7 100644 --- a/vector/src/main/java/im/vector/app/core/pushers/UnifiedPushHelper.kt +++ b/vector/src/main/java/im/vector/app/core/pushers/UnifiedPushHelper.kt @@ -17,18 +17,13 @@ package im.vector.app.core.pushers import android.content.Context -import androidx.fragment.app.FragmentActivity -import androidx.lifecycle.lifecycleScope +import androidx.annotation.MainThread import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.squareup.moshi.Json import com.squareup.moshi.JsonClass import im.vector.app.R import im.vector.app.core.resources.StringProvider import im.vector.app.core.utils.getApplicationLabel -import im.vector.app.features.VectorFeatures -import im.vector.app.features.settings.BackgroundSyncMode -import im.vector.app.features.settings.VectorPreferences -import kotlinx.coroutines.launch import org.matrix.android.sdk.api.Matrix import org.matrix.android.sdk.api.cache.CacheStrategy import org.matrix.android.sdk.api.util.MatrixJsonParser @@ -41,90 +36,14 @@ class UnifiedPushHelper @Inject constructor( private val context: Context, private val unifiedPushStore: UnifiedPushStore, private val stringProvider: StringProvider, - private val vectorPreferences: VectorPreferences, private val matrix: Matrix, - private val vectorFeatures: VectorFeatures, private val fcmHelper: FcmHelper, ) { - // Called when the home activity starts - // or when notifications are enabled - fun register( - activity: FragmentActivity, - onDoneRunnable: Runnable? = null, - ) { - registerInternal( - activity, - onDoneRunnable = onDoneRunnable - ) - } - - // If registration is forced: - // * the current distributor (if any) is removed - // * The dialog is opened - // - // The registration is forced in 2 cases : - // * in the settings - // * in the troubleshoot list (doFix) - fun forceRegister( - activity: FragmentActivity, - pushersManager: PushersManager, - onDoneRunnable: Runnable? = null - ) { - registerInternal( - activity, - force = true, - pushersManager = pushersManager, - onDoneRunnable = onDoneRunnable - ) - } - - private fun registerInternal( - activity: FragmentActivity, - force: Boolean = false, - pushersManager: PushersManager? = null, - onDoneRunnable: Runnable? = null - ) { - activity.lifecycleScope.launch { - if (!vectorFeatures.allowExternalUnifiedPushDistributors()) { - UnifiedPush.saveDistributor(context, context.packageName) - UnifiedPush.registerApp(context) - onDoneRunnable?.run() - return@launch - } - if (force) { - // Un-register first - unregister(pushersManager) - } - // the !force should not be needed - if (!force && UnifiedPush.getDistributor(context).isNotEmpty()) { - UnifiedPush.registerApp(context) - onDoneRunnable?.run() - return@launch - } - - val distributors = UnifiedPush.getDistributors(context) - - if (!force && distributors.size == 1) { - UnifiedPush.saveDistributor(context, distributors.first()) - UnifiedPush.registerApp(context) - onDoneRunnable?.run() - } else { - openDistributorDialogInternal( - activity = activity, - onDoneRunnable = onDoneRunnable, - distributors = distributors - ) - } - } - } - - // There is no case where this function is called - // with a saved distributor and/or a pusher - private fun openDistributorDialogInternal( - activity: FragmentActivity, - onDoneRunnable: Runnable?, - distributors: List + @MainThread + fun showSelectDistributorDialog( + context: Context, + onDistributorSelected: (String) -> Unit, ) { val internalDistributorName = stringProvider.getString( if (fcmHelper.isFirebaseAvailable()) { @@ -134,6 +53,7 @@ class UnifiedPushHelper @Inject constructor( } ) + val distributors = UnifiedPush.getDistributors(context) val distributorsName = distributors.map { if (it == context.packageName) { internalDistributorName @@ -142,44 +62,23 @@ class UnifiedPushHelper @Inject constructor( } } - MaterialAlertDialogBuilder(activity) + MaterialAlertDialogBuilder(context) .setTitle(stringProvider.getString(R.string.unifiedpush_getdistributors_dialog_title)) .setItems(distributorsName.toTypedArray()) { _, which -> val distributor = distributors[which] - - activity.lifecycleScope.launch { - UnifiedPush.saveDistributor(context, distributor) - Timber.i("Saving distributor: $distributor") - UnifiedPush.registerApp(context) - onDoneRunnable?.run() - } + onDistributorSelected(distributor) } .setOnCancelListener { - // By default, use internal solution (fcm/background sync) - UnifiedPush.saveDistributor(context, context.packageName) - UnifiedPush.registerApp(context) - onDoneRunnable?.run() + // we do not want to change the distributor on behalf of the user + if (UnifiedPush.getDistributor(context).isEmpty()) { + // By default, use internal solution (fcm/background sync) + onDistributorSelected(context.packageName) + } } .setCancelable(true) .show() } - suspend fun unregister(pushersManager: PushersManager? = null) { - val mode = BackgroundSyncMode.FDROID_BACKGROUND_SYNC_MODE_FOR_REALTIME - vectorPreferences.setFdroidSyncBackgroundMode(mode) - try { - getEndpointOrToken()?.let { - Timber.d("Removing $it") - pushersManager?.unregisterPusher(it) - } - } catch (e: Exception) { - Timber.d(e, "Probably unregistering a non existing pusher") - } - unifiedPushStore.storeUpEndpoint(null) - unifiedPushStore.storePushGateway(null) - UnifiedPush.unregisterApp(context) - } - @JsonClass(generateAdapter = true) internal data class DiscoveryResponse( @Json(name = "unifiedpush") val unifiedpush: DiscoveryUnifiedPush = DiscoveryUnifiedPush() diff --git a/vector/src/main/java/im/vector/app/core/pushers/UnregisterUnifiedPushUseCase.kt b/vector/src/main/java/im/vector/app/core/pushers/UnregisterUnifiedPushUseCase.kt new file mode 100644 index 0000000000..acad3e649f --- /dev/null +++ b/vector/src/main/java/im/vector/app/core/pushers/UnregisterUnifiedPushUseCase.kt @@ -0,0 +1,49 @@ +/* + * Copyright (c) 2022 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 im.vector.app.core.pushers + +import android.content.Context +import dagger.hilt.android.qualifiers.ApplicationContext +import im.vector.app.features.settings.BackgroundSyncMode +import im.vector.app.features.settings.VectorPreferences +import org.unifiedpush.android.connector.UnifiedPush +import timber.log.Timber +import javax.inject.Inject + +class UnregisterUnifiedPushUseCase @Inject constructor( + @ApplicationContext private val context: Context, + private val vectorPreferences: VectorPreferences, + private val unifiedPushStore: UnifiedPushStore, + private val unifiedPushHelper: UnifiedPushHelper, +) { + + suspend fun execute(pushersManager: PushersManager?) { + val mode = BackgroundSyncMode.FDROID_BACKGROUND_SYNC_MODE_FOR_REALTIME + vectorPreferences.setFdroidSyncBackgroundMode(mode) + try { + unifiedPushHelper.getEndpointOrToken()?.let { + Timber.d("Removing $it") + pushersManager?.unregisterPusher(it) + } + } catch (e: Exception) { + Timber.d(e, "Probably unregistering a non existing pusher") + } + unifiedPushStore.storeUpEndpoint(null) + unifiedPushStore.storePushGateway(null) + UnifiedPush.unregisterApp(context) + } +} diff --git a/vector/src/main/java/im/vector/app/core/pushers/VectorPushHandler.kt b/vector/src/main/java/im/vector/app/core/pushers/VectorPushHandler.kt index b74028d579..04c71e0412 100644 --- a/vector/src/main/java/im/vector/app/core/pushers/VectorPushHandler.kt +++ b/vector/src/main/java/im/vector/app/core/pushers/VectorPushHandler.kt @@ -28,6 +28,7 @@ import im.vector.app.core.network.WifiDetector import im.vector.app.core.pushers.model.PushData import im.vector.app.core.resources.BuildMeta import im.vector.app.features.notifications.NotifiableEventResolver +import im.vector.app.features.notifications.NotifiableMessageEvent import im.vector.app.features.notifications.NotificationActionIds import im.vector.app.features.notifications.NotificationDrawerManager import im.vector.app.features.settings.VectorDataStore @@ -117,7 +118,7 @@ class VectorPushHandler @Inject constructor( Timber.tag(loggerTag.value).d("## handleInternal()") } - val session = activeSessionHolder.getOrInitializeSession(startSync = false) + val session = activeSessionHolder.getOrInitializeSession() if (session == null) { Timber.tag(loggerTag.value).w("## Can't sync from push, no current session") @@ -142,11 +143,6 @@ class VectorPushHandler @Inject constructor( pushData.roomId ?: return pushData.eventId ?: return - // If the room is currently displayed, we will not show a notification, so no need to get the Event faster - if (notificationDrawerManager.shouldIgnoreMessageEventInRoom(pushData.roomId)) { - return - } - if (wifiDetector.isConnectedToWifi().not()) { Timber.tag(loggerTag.value).d("No WiFi network, do not get Event") return @@ -157,6 +153,13 @@ class VectorPushHandler @Inject constructor( val resolvedEvent = notifiableEventResolver.resolveInMemoryEvent(session, event, canBeReplaced = true) + if (resolvedEvent is NotifiableMessageEvent) { + // If the room is currently displayed, we will not show a notification, so no need to get the Event faster + if (notificationDrawerManager.shouldIgnoreMessageEventInRoom(resolvedEvent)) { + return + } + } + resolvedEvent ?.also { Timber.tag(loggerTag.value).d("Fast lane: notify drawer") } ?.let { diff --git a/vector/src/main/java/im/vector/app/core/resources/BuildMeta.kt b/vector/src/main/java/im/vector/app/core/resources/BuildMeta.kt index 6c25348ea1..ddc6f992a9 100644 --- a/vector/src/main/java/im/vector/app/core/resources/BuildMeta.kt +++ b/vector/src/main/java/im/vector/app/core/resources/BuildMeta.kt @@ -24,7 +24,6 @@ data class BuildMeta( val gitRevision: String, val gitRevisionDate: String, val gitBranchName: String, - val buildNumber: String, val flavorDescription: String, val flavorShortDescription: String, ) diff --git a/vector/src/main/java/im/vector/app/core/services/CallAndroidService.kt b/vector/src/main/java/im/vector/app/core/services/CallAndroidService.kt index 85ea7f1a1b..a4e3872e0f 100644 --- a/vector/src/main/java/im/vector/app/core/services/CallAndroidService.kt +++ b/vector/src/main/java/im/vector/app/core/services/CallAndroidService.kt @@ -28,6 +28,7 @@ import androidx.media.session.MediaButtonReceiver import com.airbnb.mvrx.Mavericks import dagger.hilt.android.AndroidEntryPoint import im.vector.app.core.extensions.singletonEntryPoint +import im.vector.app.core.extensions.startForegroundCompat import im.vector.app.features.call.CallArgs import im.vector.app.features.call.VectorCallActivity import im.vector.app.features.call.telecom.CallConnection @@ -181,7 +182,7 @@ class CallAndroidService : VectorAndroidService() { fromBg = fromBg ) if (knownCalls.isEmpty()) { - startForeground(callId.hashCode(), notification) + startForegroundCompat(callId.hashCode(), notification) } else { notificationManager.notify(callId.hashCode(), notification) } @@ -201,7 +202,7 @@ class CallAndroidService : VectorAndroidService() { } val notification = notificationUtils.buildCallEndedNotification(false) val notificationId = callId.hashCode() - startForeground(notificationId, notification) + startForegroundCompat(notificationId, notification) if (knownCalls.isEmpty()) { Timber.tag(loggerTag.value).v("No more call, stop the service") stopForegroundCompat() @@ -236,7 +237,7 @@ class CallAndroidService : VectorAndroidService() { title = callInformation.opponentMatrixItem?.getBestName() ?: callInformation.opponentUserId ) if (knownCalls.isEmpty()) { - startForeground(callId.hashCode(), notification) + startForegroundCompat(callId.hashCode(), notification) } else { notificationManager.notify(callId.hashCode(), notification) } @@ -260,7 +261,7 @@ class CallAndroidService : VectorAndroidService() { title = callInformation.opponentMatrixItem?.getBestName() ?: callInformation.opponentUserId ) if (knownCalls.isEmpty()) { - startForeground(callId.hashCode(), notification) + startForegroundCompat(callId.hashCode(), notification) } else { notificationManager.notify(callId.hashCode(), notification) } @@ -273,9 +274,9 @@ class CallAndroidService : VectorAndroidService() { callRingPlayerOutgoing?.stop() val notification = notificationUtils.buildCallEndedNotification(false) if (callId != null) { - startForeground(callId.hashCode(), notification) + startForegroundCompat(callId.hashCode(), notification) } else { - startForeground(DEFAULT_NOTIFICATION_ID, notification) + startForegroundCompat(DEFAULT_NOTIFICATION_ID, notification) } if (knownCalls.isEmpty()) { mediaSession?.isActive = false diff --git a/vector/src/main/java/im/vector/app/core/services/VectorSyncAndroidService.kt b/vector/src/main/java/im/vector/app/core/services/VectorSyncAndroidService.kt index 864f69a136..f746c0749b 100644 --- a/vector/src/main/java/im/vector/app/core/services/VectorSyncAndroidService.kt +++ b/vector/src/main/java/im/vector/app/core/services/VectorSyncAndroidService.kt @@ -32,6 +32,7 @@ import androidx.work.Worker import androidx.work.WorkerParameters import dagger.hilt.android.AndroidEntryPoint import im.vector.app.R +import im.vector.app.core.extensions.startForegroundCompat import im.vector.app.core.platform.PendingIntentCompat import im.vector.app.core.time.Clock import im.vector.app.core.time.DefaultClock @@ -98,7 +99,7 @@ class VectorSyncAndroidService : SyncAndroidService() { R.string.notification_listening_for_notifications } val notification = notificationUtils.buildForegroundServiceNotification(notificationSubtitleRes, false) - startForeground(NotificationUtils.NOTIFICATION_ID_FOREGROUND_SERVICE, notification) + startForegroundCompat(NotificationUtils.NOTIFICATION_ID_FOREGROUND_SERVICE, notification) } override fun onRescheduleAsked( diff --git a/vector/src/main/java/im/vector/app/core/session/ConfigureAndStartSessionUseCase.kt b/vector/src/main/java/im/vector/app/core/session/ConfigureAndStartSessionUseCase.kt index 8b1a366248..7a8b2203d4 100644 --- a/vector/src/main/java/im/vector/app/core/session/ConfigureAndStartSessionUseCase.kt +++ b/vector/src/main/java/im/vector/app/core/session/ConfigureAndStartSessionUseCase.kt @@ -19,11 +19,14 @@ package im.vector.app.core.session import android.content.Context import dagger.hilt.android.qualifiers.ApplicationContext import im.vector.app.core.extensions.startSyncing +import im.vector.app.core.notification.NotificationsSettingUpdater import im.vector.app.core.session.clientinfo.UpdateMatrixClientInfoUseCase import im.vector.app.features.call.webrtc.WebRtcCallManager +import im.vector.app.features.session.coroutineScope import im.vector.app.features.settings.VectorPreferences +import im.vector.app.features.settings.devices.v2.notification.UpdateNotificationSettingsAccountDataUseCase +import kotlinx.coroutines.launch import org.matrix.android.sdk.api.session.Session -import org.matrix.android.sdk.api.session.sync.FilterService import timber.log.Timber import javax.inject.Inject @@ -32,20 +35,35 @@ class ConfigureAndStartSessionUseCase @Inject constructor( private val webRtcCallManager: WebRtcCallManager, private val updateMatrixClientInfoUseCase: UpdateMatrixClientInfoUseCase, private val vectorPreferences: VectorPreferences, + private val notificationsSettingUpdater: NotificationsSettingUpdater, + private val updateNotificationSettingsAccountDataUseCase: UpdateNotificationSettingsAccountDataUseCase, ) { - suspend fun execute(session: Session, startSyncing: Boolean = true) { + fun execute(session: Session, startSyncing: Boolean = true) { Timber.i("Configure and start session for ${session.myUserId}. startSyncing: $startSyncing") session.open() - session.filterService().setFilter(FilterService.FilterPreset.ElementFilter) if (startSyncing) { session.startSyncing(context) } session.pushersService().refreshPushers() configureContentScanner(session) webRtcCallManager.checkForProtocolsSupportIfNeeded() - if (vectorPreferences.isClientInfoRecordingEnabled()) { - updateMatrixClientInfoUseCase.execute(session) + updateMatrixClientInfoIfNeeded(session) + createNotificationSettingsAccountDataIfNeeded(session) + notificationsSettingUpdater.onSessionStarted(session) + } + + private fun updateMatrixClientInfoIfNeeded(session: Session) { + session.coroutineScope.launch { + if (vectorPreferences.isClientInfoRecordingEnabled()) { + updateMatrixClientInfoUseCase.execute(session) + } + } + } + + private fun createNotificationSettingsAccountDataIfNeeded(session: Session) { + session.coroutineScope.launch { + updateNotificationSettingsAccountDataUseCase.execute(session) } } diff --git a/vector/src/main/java/im/vector/app/core/session/clientinfo/DeleteUnusedClientInformationUseCase.kt b/vector/src/main/java/im/vector/app/core/session/clientinfo/DeleteUnusedClientInformationUseCase.kt new file mode 100644 index 0000000000..dcd5c58480 --- /dev/null +++ b/vector/src/main/java/im/vector/app/core/session/clientinfo/DeleteUnusedClientInformationUseCase.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2022 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 im.vector.app.core.session.clientinfo + +import im.vector.app.core.di.ActiveSessionHolder +import org.matrix.android.sdk.api.session.crypto.model.DeviceInfo +import javax.inject.Inject + +class DeleteUnusedClientInformationUseCase @Inject constructor( + private val activeSessionHolder: ActiveSessionHolder, +) { + + suspend fun execute(deviceInfoList: List): Result = runCatching { + // A defensive approach against local storage reports an empty device list (although it is not a seen situation). + if (deviceInfoList.isEmpty()) return Result.success(Unit) + + val expectedClientInfoKeyList = deviceInfoList.map { MATRIX_CLIENT_INFO_KEY_PREFIX + it.deviceId } + activeSessionHolder + .getSafeActiveSession() + ?.accountDataService() + ?.getUserAccountDataEventsStartWith(MATRIX_CLIENT_INFO_KEY_PREFIX) + ?.map { it.type } + ?.subtract(expectedClientInfoKeyList.toSet()) + ?.forEach { userAccountDataKeyToDelete -> + activeSessionHolder.getSafeActiveSession()?.accountDataService()?.deleteUserAccountData(userAccountDataKeyToDelete) + } + } +} diff --git a/vector/src/main/java/im/vector/app/core/ui/views/ShieldImageView.kt b/vector/src/main/java/im/vector/app/core/ui/views/ShieldImageView.kt index aaf07dccdf..2baa60ebcd 100644 --- a/vector/src/main/java/im/vector/app/core/ui/views/ShieldImageView.kt +++ b/vector/src/main/java/im/vector/app/core/ui/views/ShieldImageView.kt @@ -40,6 +40,31 @@ class ShieldImageView @JvmOverloads constructor( isVisible = false } + /** + * Renders device shield with the support of unknown shields instead of black shields which is used for rooms. + * @param roomEncryptionTrustLevel trust level that is usually calculated with [im.vector.app.features.settings.devices.TrustUtils.shieldForTrust] + * @param borderLess if true then the shield icon with border around is used + */ + fun renderDeviceShield(roomEncryptionTrustLevel: RoomEncryptionTrustLevel?, borderLess: Boolean = false) { + when (roomEncryptionTrustLevel) { + null -> { + contentDescription = context.getString(R.string.a11y_trust_level_warning) + setImageResource( + if (borderLess) R.drawable.ic_shield_warning_no_border + else R.drawable.ic_shield_warning + ) + } + RoomEncryptionTrustLevel.Default -> { + contentDescription = context.getString(R.string.a11y_trust_level_default) + setImageResource( + if (borderLess) R.drawable.ic_shield_unknown_no_border + else R.drawable.ic_shield_unknown + ) + } + else -> render(roomEncryptionTrustLevel, borderLess) + } + } + fun render(roomEncryptionTrustLevel: RoomEncryptionTrustLevel?, borderLess: Boolean = false) { // Tchap: Hide the shield isVisible = false @@ -48,8 +73,8 @@ class ShieldImageView @JvmOverloads constructor( RoomEncryptionTrustLevel.Default -> { contentDescription = context.getString(R.string.a11y_trust_level_default) setImageResource( - if (borderLess) R.drawable.ic_shield_unknown_no_border - else R.drawable.ic_shield_unknown + if (borderLess) R.drawable.ic_shield_black_no_border + else R.drawable.ic_shield_black ) } RoomEncryptionTrustLevel.Warning -> { @@ -140,7 +165,7 @@ class ShieldImageView @JvmOverloads constructor( @DrawableRes fun RoomEncryptionTrustLevel.toDrawableRes(): Int { return when (this) { - RoomEncryptionTrustLevel.Default -> R.drawable.ic_shield_unknown + RoomEncryptionTrustLevel.Default -> R.drawable.ic_shield_black RoomEncryptionTrustLevel.Warning -> R.drawable.ic_shield_warning RoomEncryptionTrustLevel.Trusted -> R.drawable.ic_shield_trusted RoomEncryptionTrustLevel.E2EWithUnsupportedAlgorithm -> R.drawable.ic_warning_badge diff --git a/vector/src/main/java/im/vector/app/core/ui/views/TypingMessageView.kt b/vector/src/main/java/im/vector/app/core/ui/views/TypingMessageView.kt index 263f043fad..b6dc404d01 100644 --- a/vector/src/main/java/im/vector/app/core/ui/views/TypingMessageView.kt +++ b/vector/src/main/java/im/vector/app/core/ui/views/TypingMessageView.kt @@ -48,9 +48,4 @@ class TypingMessageView @JvmOverloads constructor( views.typingUserText.text = typingHelper.getNotificationTypingMessage(typingUsers) views.typingUserAvatars.render(typingUsers, avatarRenderer) } - - override fun onDetachedFromWindow() { - super.onDetachedFromWindow() - removeAllViews() - } } diff --git a/vector/src/main/java/im/vector/app/core/utils/ExpandingBottomSheetBehavior.kt b/vector/src/main/java/im/vector/app/core/utils/ExpandingBottomSheetBehavior.kt new file mode 100644 index 0000000000..47326bca76 --- /dev/null +++ b/vector/src/main/java/im/vector/app/core/utils/ExpandingBottomSheetBehavior.kt @@ -0,0 +1,798 @@ +package im.vector.app.core.utils + +import android.content.Context +import android.util.AttributeSet +import android.view.MotionEvent +import android.view.VelocityTracker +import android.view.View +import android.view.View.MeasureSpec +import android.view.ViewGroup +import androidx.coordinatorlayout.widget.CoordinatorLayout +import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsAnimationCompat +import androidx.core.view.WindowInsetsCompat +import androidx.core.view.children +import androidx.core.view.isVisible +import androidx.core.view.updatePadding +import androidx.customview.widget.ViewDragHelper +import com.google.android.material.appbar.AppBarLayout +import com.google.android.material.bottomsheet.BottomSheetBehavior +import timber.log.Timber +import java.lang.ref.WeakReference +import kotlin.math.abs +import kotlin.math.max +import kotlin.math.min + +/** + * BottomSheetBehavior that dynamically resizes its contents as it grows or shrinks. + * Most of the nested scrolling and touch events code is the same as in [BottomSheetBehavior], but we couldn't just extend it. + */ +class ExpandingBottomSheetBehavior : CoordinatorLayout.Behavior { + + companion object { + /** Gets a [ExpandingBottomSheetBehavior] from the passed [view] if it exists. */ + @Suppress("UNCHECKED_CAST") + fun from(view: V): ExpandingBottomSheetBehavior? { + val params = view.layoutParams as? CoordinatorLayout.LayoutParams ?: return null + return params.behavior as? ExpandingBottomSheetBehavior + } + } + + /** [Callback] to notify changes in dragging state and position. */ + interface Callback { + /** Called when the dragging state of the BottomSheet changes. */ + fun onStateChanged(state: State) {} + + /** Called when the position of the BottomSheet changes while dragging. */ + fun onSlidePositionChanged(view: View, yPosition: Float) {} + } + + /** Represents the 4 possible states of the BottomSheet. */ + enum class State(val value: Int) { + /** BottomSheet is at min height, collapsed at the bottom. */ + Collapsed(0), + + /** BottomSheet is being dragged by the user. */ + Dragging(1), + + /** BottomSheet has been released after being dragged by the user and is animating to its destination. */ + Settling(2), + + /** BottomSheet is at its max height. */ + Expanded(3); + + /** Returns whether the BottomSheet is being dragged or is settling after being dragged. */ + fun isDraggingOrSettling(): Boolean = this == Dragging || this == Settling + } + + /** Set to true to enable debug logging of sizes and offsets. Defaults to `false`. */ + var enableDebugLogs = false + + /** Current BottomSheet state. Default to [State.Collapsed]. */ + var state: State = State.Collapsed + private set + + /** Whether the BottomSheet can be dragged by the user or not. Defaults to `true`. */ + var isDraggable = true + + /** [Callback] to notify changes in dragging state and position. */ + var callback: Callback? = null + set(value) { + field = value + // Send initial state + value?.onStateChanged(state) + } + + /** Additional top offset in `dps` to add to the BottomSheet so it doesn't fill the whole screen. Defaults to `0`. */ + var topOffset = 0 + set(value) { + field = value + expandedOffset = -1 + } + + /** Whether the BottomSheet should be expanded up to the bottom of any [AppBarLayout] found in the parent [CoordinatorLayout]. Defaults to `false`. */ + var avoidAppBarLayout = false + set(value) { + field = value + expandedOffset = -1 + } + + /** + * Whether to add the [scrimView], a 'shadow layer' that will be displayed while dragging/expanded so it obscures the content below the BottomSheet. + * Defaults to `false`. + */ + var useScrimView = false + + /** Color to use for the [scrimView] shadow layer. */ + var scrimViewColor = 0x60000000 + + /** [View.TRANSLATION_Z] in `dps` to apply to the [scrimView]. Defaults to `0dp`. */ + var scrimViewTranslationZ = 0 + + /** Whether the content view should be layout to the top of the BottomSheet when it's collapsed. Defaults to true. */ + var applyInsetsToContentViewWhenCollapsed = true + + /** Lambda used to calculate a min collapsed when the view using the behavior should have a special 'collapsed' layout. It's null by default. */ + var minCollapsedHeight: (() -> Int)? = null + + // Internal BottomSheet implementation properties + private var ignoreEvents = false + private var touchingScrollingChild = false + + private var lastY: Int = -1 + private var collapsedOffset = -1 + private var expandedOffset = -1 + private var parentWidth = -1 + private var parentHeight = -1 + + private var activePointerId = -1 + + private var lastNestedScrollDy = -1 + private var isNestedScrolled = false + + private var viewRef: WeakReference? = null + private var nestedScrollingChildRef: WeakReference? = null + private var velocityTracker: VelocityTracker? = null + + private var dragHelper: ViewDragHelper? = null + private var scrimView: View? = null + + private val stateSettlingTracker = StateSettlingTracker() + private var prevState: State? = null + + private var insetBottom = 0 + private var insetTop = 0 + private var insetLeft = 0 + private var insetRight = 0 + + private var initialPaddingTop = 0 + private var initialPaddingBottom = 0 + private var initialPaddingLeft = 0 + private var initialPaddingRight = 0 + private val minCollapsedOffset: Int? + get() { + val minHeight = minCollapsedHeight?.invoke() ?: return null + if (minHeight == -1) return null + return parentHeight - minHeight - insetBottom + } + + constructor(context: Context, attrs: AttributeSet?) : super(context, attrs) + constructor() : super() + + override fun onLayoutChild(parent: CoordinatorLayout, child: V, layoutDirection: Int): Boolean { + parentWidth = parent.width + parentHeight = parent.height + + if (viewRef == null) { + viewRef = WeakReference(child) + setWindowInsetsListener(child) + // Prevents clicking on overlapped items below the BottomSheet + child.isClickable = true + } + + parent.updatePadding(left = insetLeft, right = insetRight) + + ensureViewDragHelper(parent) + + // Top coordinate before this layout pass + val savedTop = child.top + + // Calculate default position of the BottomSheet's children + parent.onLayoutChild(child, layoutDirection) + + // This should optimise calculations when they're not needed + if (state == State.Collapsed) { + calculateCollapsedOffset(child) + } + calculateExpandedOffset(parent) + + // Apply top and bottom insets to contentView if needed + val appBar = findAppBarLayout(parent) + val contentView = parent.children.find { it !== appBar && it !== child && it !== scrimView } + if (applyInsetsToContentViewWhenCollapsed && state == State.Collapsed && contentView != null) { + val topOffset = appBar?.measuredHeight ?: 0 + val bottomOffset = parentHeight - collapsedOffset + insetTop + val params = contentView.layoutParams as CoordinatorLayout.LayoutParams + if (params.bottomMargin != bottomOffset || params.topMargin != topOffset) { + params.topMargin = topOffset + params.bottomMargin = bottomOffset + contentView.layoutParams = params + } + } + + // Add scrimView if needed + if (useScrimView && scrimView == null) { + val scrimView = View(parent.context) + scrimView.setBackgroundColor(scrimViewColor) + scrimView.translationZ = scrimViewTranslationZ * child.resources.displayMetrics.scaledDensity + scrimView.isVisible = false + val params = CoordinatorLayout.LayoutParams( + CoordinatorLayout.LayoutParams.MATCH_PARENT, + CoordinatorLayout.LayoutParams.MATCH_PARENT + ) + scrimView.layoutParams = params + val currentIndex = parent.children.indexOf(child) + parent.addView(scrimView, currentIndex) + this.scrimView = scrimView + } else if (!useScrimView && scrimView != null) { + parent.removeView(scrimView) + scrimView = null + } + + // Apply insets and resize child based on the current State + when (state) { + State.Collapsed -> { + scrimView?.alpha = 0f + val newHeight = parentHeight - collapsedOffset + insetTop + val params = child.layoutParams + if (params.height != newHeight) { + params.height = newHeight + child.layoutParams = params + } + // If the offset is < insetTop it will cover the status bar too + val newOffset = max(insetTop, collapsedOffset - insetTop) + ViewCompat.offsetTopAndBottom(child, newOffset) + log("State: Collapsed | Offset: $newOffset | Height: $newHeight") + } + State.Dragging, State.Settling -> { + val newOffset = savedTop - child.top + val percentage = max(0f, 1f - (newOffset.toFloat() / collapsedOffset.toFloat())) + scrimView?.let { + if (percentage == 0f) { + it.isVisible = false + } else { + it.alpha = percentage + it.isVisible = true + } + } + val params = child.layoutParams + params.height = parentHeight - savedTop + child.layoutParams = params + ViewCompat.offsetTopAndBottom(child, newOffset) + val stateStr = if (state == State.Dragging) "Dragging" else "Settling" + log("State: $stateStr | Offset: $newOffset | Percentage: $percentage") + } + State.Expanded -> { + val params = child.layoutParams + val newHeight = parentHeight - expandedOffset + if (params.height != newHeight) { + params.height = newHeight + child.layoutParams = params + } + ViewCompat.offsetTopAndBottom(child, expandedOffset) + log("State: Expanded | Offset: $expandedOffset | Height: $newHeight") + } + } + + // Find a nested scrolling child to take into account for touch events + if (nestedScrollingChildRef == null) { + nestedScrollingChildRef = findScrollingChild(child)?.let { WeakReference(it) } + } + + return true + } + + // region: Touch events + override fun onInterceptTouchEvent( + parent: CoordinatorLayout, + child: V, + ev: MotionEvent + ): Boolean { + // Almost everything inside here is verbatim to BottomSheetBehavior's onTouchEvent + if (viewRef != null && viewRef?.get() !== child) { + return true + } + val action = ev.actionMasked + + if (action == MotionEvent.ACTION_DOWN) { + resetTouchEventTracking() + } + if (velocityTracker == null) { + velocityTracker = VelocityTracker.obtain() + } + velocityTracker?.addMovement(ev) + + when (action) { + MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> { + touchingScrollingChild = false + activePointerId = MotionEvent.INVALID_POINTER_ID + if (ignoreEvents) { + ignoreEvents = false + return false + } + } + MotionEvent.ACTION_DOWN -> { + val x = ev.x.toInt() + lastY = ev.y.toInt() + + // Only intercept nested scrolling events here if the view not being moved by the + // ViewDragHelper. + val scroll = nestedScrollingChildRef?.get() + if (state != State.Settling) { + if (scroll != null && parent.isPointInChildBounds(scroll, x, lastY)) { + activePointerId = ev.getPointerId(ev.actionIndex) + touchingScrollingChild = true + } + } + ignoreEvents = (activePointerId == MotionEvent.INVALID_POINTER_ID && + !parent.isPointInChildBounds(child, x, lastY)) + } + else -> Unit + } + + if (!ignoreEvents && isDraggable && dragHelper?.shouldInterceptTouchEvent(ev) == true) { + return true + } + + // If using scrim view, a click on it should collapse the bottom sheet + if (useScrimView && state == State.Expanded && action == MotionEvent.ACTION_DOWN) { + val y = ev.y.toInt() + if (y <= expandedOffset) { + setState(State.Collapsed) + return true + } + } + + // We have to handle cases that the ViewDragHelper does not capture the bottom sheet because + // it is not the top most view of its parent. This is not necessary when the touch event is + // happening over the scrolling content as nested scrolling logic handles that case. + val scroll = nestedScrollingChildRef?.get() + return (action == MotionEvent.ACTION_MOVE && + scroll != null && + !ignoreEvents && + state != State.Dragging && + !parent.isPointInChildBounds(scroll, ev.x.toInt(), ev.y.toInt()) && + dragHelper != null && + abs(lastY - ev.y.toInt()) > (dragHelper?.touchSlop ?: 0)) + } + + override fun onTouchEvent(parent: CoordinatorLayout, child: V, ev: MotionEvent): Boolean { + // Almost everything inside here is verbatim to BottomSheetBehavior's onTouchEvent + val action = ev.actionMasked + if (state == State.Dragging && action == MotionEvent.ACTION_DOWN) { + return true + } + if (shouldHandleDraggingWithHelper()) { + dragHelper?.processTouchEvent(ev) + } + + // Record the velocity + if (action == MotionEvent.ACTION_DOWN) { + resetTouchEventTracking() + } + if (velocityTracker == null) { + velocityTracker = VelocityTracker.obtain() + } + velocityTracker?.addMovement(ev) + + if (shouldHandleDraggingWithHelper() && action == MotionEvent.ACTION_MOVE && !ignoreEvents) { + if (abs(lastY - ev.y.toInt()) > (dragHelper?.touchSlop ?: 0)) { + dragHelper?.captureChildView(child, ev.getPointerId(ev.actionIndex)) + } + } + + return !ignoreEvents + } + + private fun resetTouchEventTracking() { + activePointerId = ViewDragHelper.INVALID_POINTER + velocityTracker?.recycle() + velocityTracker = null + } + // endregion + + override fun onAttachedToLayoutParams(params: CoordinatorLayout.LayoutParams) { + super.onAttachedToLayoutParams(params) + + viewRef = null + dragHelper = null + } + + override fun onDetachedFromLayoutParams() { + super.onDetachedFromLayoutParams() + + viewRef = null + dragHelper = null + } + + // region: Size measuring and utils + private fun calculateCollapsedOffset(child: View) { + val availableSpace = parentHeight - insetTop + child.measure( + MeasureSpec.makeMeasureSpec(parentWidth, MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(availableSpace, MeasureSpec.AT_MOST), + ) + collapsedOffset = parentHeight - child.measuredHeight + insetTop + } + + private fun calculateExpandedOffset(parent: CoordinatorLayout): Int { + expandedOffset = if (avoidAppBarLayout) { + findAppBarLayout(parent)?.measuredHeight ?: 0 + } else { + 0 + } + topOffset + insetTop + return expandedOffset + } + + private fun ensureViewDragHelper(parent: CoordinatorLayout) { + if (dragHelper == null) { + dragHelper = ViewDragHelper.create(parent, dragHelperCallback) + } + } + + private fun findAppBarLayout(view: View): AppBarLayout? { + return when (view) { + is AppBarLayout -> view + is ViewGroup -> view.children.firstNotNullOfOrNull { findAppBarLayout(it) } + else -> null + } + } + + private fun shouldHandleDraggingWithHelper(): Boolean { + return dragHelper != null && (isDraggable || state == State.Dragging) + } + + private fun log(contents: String, vararg args: Any) { + if (!enableDebugLogs) return + Timber.d(contents, args) + } + // endregion + + // region: State and delayed state settling + fun setState(state: State) { + if (state == this.state) { + return + } else if (viewRef?.get() == null) { + setInternalState(state) + } else { + viewRef?.get()?.let { child -> + runAfterLayout(child) { startSettling(child, state, false) } + } + } + } + + private fun setInternalState(state: State) { + if (!this.state.isDraggingOrSettling()) { + prevState = this.state + } + this.state = state + + viewRef?.get()?.requestLayout() + + callback?.onStateChanged(state) + } + + private fun startSettling(child: View, state: State, isReleasingView: Boolean) { + val top = getTopOffsetForState(state) + log("Settling to: $top") + val isSettling = dragHelper?.let { + if (isReleasingView) { + it.settleCapturedViewAt(child.left, top) + } else { + it.smoothSlideViewTo(child, child.left, top) + } + } ?: false + setInternalState(if (isSettling) State.Settling else state) + + if (isSettling) { + stateSettlingTracker.continueSettlingToState(state) + } + } + + private fun runAfterLayout(child: V, runnable: Runnable) { + if (isLayouting(child)) { + child.post(runnable) + } else { + runnable.run() + } + } + + private fun isLayouting(child: V): Boolean { + return child.parent != null && child.parent.isLayoutRequested && ViewCompat.isAttachedToWindow(child) + } + + private fun getTopOffsetForState(state: State): Int { + return when (state) { + State.Collapsed -> minCollapsedOffset ?: collapsedOffset + State.Expanded -> expandedOffset + else -> error("Cannot get offset for state $state") + } + } + // endregion + + // region: Nested scroll + override fun onStartNestedScroll( + coordinatorLayout: CoordinatorLayout, + child: V, + directTargetChild: View, + target: View, + axes: Int, + type: Int + ): Boolean { + lastNestedScrollDy = 0 + isNestedScrolled = false + return (axes and ViewCompat.SCROLL_AXIS_VERTICAL) != 0 + } + + override fun onNestedPreScroll( + coordinatorLayout: CoordinatorLayout, + child: V, + target: View, + dx: Int, + dy: Int, + consumed: IntArray, + type: Int + ) { + if (type == ViewCompat.TYPE_NON_TOUCH) return + val scrollingChild = nestedScrollingChildRef?.get() + if (target != scrollingChild) return + + val currentTop = child.top + val newTop = currentTop - dy + if (dy > 0) { + // Upward scroll + if (newTop < expandedOffset) { + consumed[1] = currentTop - expandedOffset + ViewCompat.offsetTopAndBottom(child, -consumed[1]) + setInternalState(State.Expanded) + } else { + if (!isDraggable) return + + consumed[1] = dy + ViewCompat.offsetTopAndBottom(child, -dy) + setInternalState(State.Dragging) + } + } else if (dy < 0) { + // Scroll downward + if (!target.canScrollVertically(-1)) { + if (newTop <= collapsedOffset) { + if (!isDraggable) return + + consumed[1] = dy + ViewCompat.offsetTopAndBottom(child, -dy) + setInternalState(State.Dragging) + } else { + consumed[1] = currentTop - collapsedOffset + ViewCompat.offsetTopAndBottom(child, -consumed[1]) + setInternalState(State.Collapsed) + } + } + } + lastNestedScrollDy = dy + isNestedScrolled = true + } + + override fun onNestedScroll( + coordinatorLayout: CoordinatorLayout, + child: V, + target: View, + dxConsumed: Int, + dyConsumed: Int, + dxUnconsumed: Int, + dyUnconsumed: Int, + type: Int, + consumed: IntArray + ) { + // Empty to avoid default behaviour + } + + override fun onNestedPreFling( + coordinatorLayout: CoordinatorLayout, + child: V, + target: View, + velocityX: Float, + velocityY: Float + ): Boolean { + return target == nestedScrollingChildRef?.get() && + (state != State.Expanded || super.onNestedPreFling(coordinatorLayout, child, target, velocityX, velocityY)) + } + + private fun findScrollingChild(view: View): View? { + return when { + !view.isVisible -> null + ViewCompat.isNestedScrollingEnabled(view) -> view + view is ViewGroup -> { + view.children.firstNotNullOfOrNull { findScrollingChild(it) } + } + else -> null + } + } + // endregion + + // region: Insets + private fun setWindowInsetsListener(view: View) { + // Create a snapshot of the view's padding state. + initialPaddingLeft = view.paddingLeft + initialPaddingTop = view.paddingTop + initialPaddingRight = view.paddingRight + initialPaddingBottom = view.paddingBottom + + // This should only be used to set initial insets and other edge cases where the insets can't be applied using an animation. + var isAnimating = false + + // This will animate inset changes, making them look a lot better. However, it won't update initial insets. + ViewCompat.setWindowInsetsAnimationCallback(view, object : WindowInsetsAnimationCompat.Callback(DISPATCH_MODE_STOP) { + override fun onPrepare(animation: WindowInsetsAnimationCompat) { + isAnimating = true + } + + override fun onProgress(insets: WindowInsetsCompat, runningAnimations: MutableList): WindowInsetsCompat { + return if (isAnimating) { + applyInsets(view, insets) + } else { + insets + } + } + + override fun onEnd(animation: WindowInsetsAnimationCompat) { + isAnimating = false + view.requestApplyInsets() + } + }) + + ViewCompat.setOnApplyWindowInsetsListener(view) { _: View, insets: WindowInsetsCompat -> + if (isAnimating) { + insets + } else { + applyInsets(view, insets) + } + } + + // Request to apply insets as soon as the view is attached to a window. + if (ViewCompat.isAttachedToWindow(view)) { + ViewCompat.requestApplyInsets(view) + } else { + view.addOnAttachStateChangeListener(object : View.OnAttachStateChangeListener { + override fun onViewAttachedToWindow(v: View) { + v.removeOnAttachStateChangeListener(this) + ViewCompat.requestApplyInsets(v) + } + + override fun onViewDetachedFromWindow(v: View) = Unit + }) + } + } + + private fun applyInsets(view: View, insets: WindowInsetsCompat): WindowInsetsCompat { + val insetsType = WindowInsetsCompat.Type.systemBars() or WindowInsetsCompat.Type.ime() + val imeInsets = insets.getInsets(insetsType) + insetTop = imeInsets.top + insetBottom = imeInsets.bottom + insetLeft = imeInsets.left + insetRight = imeInsets.right + + val bottomPadding = initialPaddingBottom + insetBottom + view.setPadding(initialPaddingLeft, initialPaddingTop, initialPaddingRight, bottomPadding) + if (state == State.Collapsed) { + val params = view.layoutParams + params.height = CoordinatorLayout.LayoutParams.WRAP_CONTENT + view.layoutParams = params + calculateCollapsedOffset(view) + } + return WindowInsetsCompat.CONSUMED + } + // endregion + + // Used to add dragging animations along with StateSettlingTracker, and set max and min dragging coordinates. + private val dragHelperCallback = object : ViewDragHelper.Callback() { + + override fun tryCaptureView(child: View, pointerId: Int): Boolean { + if (state == State.Dragging) { + return false + } + + if (touchingScrollingChild) { + return false + } + + if (state == State.Expanded && activePointerId == pointerId) { + val scroll = nestedScrollingChildRef?.get() + if (scroll?.canScrollVertically(-1) == true) { + return false + } + } + + return viewRef?.get() == child + } + + override fun onViewDragStateChanged(state: Int) { + if (state == ViewDragHelper.STATE_DRAGGING && isDraggable) { + setInternalState(State.Dragging) + } + } + + override fun onViewPositionChanged( + changedView: View, + left: Int, + top: Int, + dx: Int, + dy: Int + ) { + super.onViewPositionChanged(changedView, left, top, dx, dy) + + val params = changedView.layoutParams + params.height = parentHeight - top + insetBottom + insetTop + changedView.layoutParams = params + + val collapsedOffset = minCollapsedOffset ?: collapsedOffset + val percentage = 1f - (top - insetTop).toFloat() / collapsedOffset.toFloat() + + callback?.onSlidePositionChanged(changedView, percentage) + } + + override fun onViewReleased(releasedChild: View, xvel: Float, yvel: Float) { + val actualCollapsedOffset = minCollapsedOffset ?: collapsedOffset + val targetState = if (yvel < 0) { + // Moving up + val currentTop = releasedChild.top + + val yPositionPercentage = currentTop * 100f / actualCollapsedOffset + if (yPositionPercentage >= 0.5f) { + State.Expanded + } else { + State.Collapsed + } + } else if (yvel == 0f || abs(xvel) > abs(yvel)) { + // If the Y velocity is 0 or the swipe was mostly horizontal indicated by the X velocity + // being greater than the Y velocity, settle to the nearest correct height. + + val currentTop = releasedChild.top + if (currentTop < actualCollapsedOffset / 2) { + State.Expanded + } else { + State.Collapsed + } + } else { + // Moving down + val currentTop = releasedChild.top + + val yPositionPercentage = currentTop * 100f / actualCollapsedOffset + if (yPositionPercentage >= 0.5f) { + State.Collapsed + } else { + State.Expanded + } + } + startSettling(releasedChild, targetState, true) + } + + override fun clampViewPositionHorizontal(child: View, left: Int, dx: Int): Int { + return child.left + } + + override fun clampViewPositionVertical(child: View, top: Int, dy: Int): Int { + val collapsed = minCollapsedOffset ?: collapsedOffset + val maxTop = max(top, insetTop) + return min(max(maxTop, expandedOffset), collapsed) + } + + override fun getViewVerticalDragRange(child: View): Int { + return minCollapsedOffset ?: collapsedOffset + } + } + + // Used to set the current State in a delayed way. + private inner class StateSettlingTracker { + private lateinit var targetState: State + private var isContinueSettlingRunnablePosted = false + + private val continueSettlingRunnable: Runnable = Runnable { + isContinueSettlingRunnablePosted = false + if (dragHelper?.continueSettling(true) == true) { + continueSettlingToState(targetState) + } else { + setInternalState(targetState) + } + } + + fun continueSettlingToState(state: State) { + val view = viewRef?.get() ?: return + + this.targetState = state + if (!isContinueSettlingRunnablePosted) { + ViewCompat.postOnAnimation(view, continueSettlingRunnable) + isContinueSettlingRunnablePosted = true + } + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/MainActivity.kt b/vector/src/main/java/im/vector/app/features/MainActivity.kt index dfc0750b58..0c6108a383 100644 --- a/vector/src/main/java/im/vector/app/features/MainActivity.kt +++ b/vector/src/main/java/im/vector/app/features/MainActivity.kt @@ -177,12 +177,15 @@ class MainActivity : VectorBaseActivity(), UnlockedActivity private fun handleAppStarted() { if (intent.hasExtra(EXTRA_NEXT_INTENT)) { // Start the next Activity + startSyncing() val nextIntent = intent.getParcelableExtraCompat(EXTRA_NEXT_INTENT) startIntentAndFinish(nextIntent) } else if (intent.hasExtra(EXTRA_INIT_SESSION)) { + startSyncing() setResult(RESULT_OK) finish() } else if (intent.action == ACTION_ROOM_DETAILS_FROM_SHORTCUT) { + startSyncing() val roomId = intent.getStringExtra(EXTRA_ROOM_ID) if (roomId?.isNotEmpty() == true) { navigator.openRoom(this, roomId, trigger = ViewRoom.Trigger.Shortcut) @@ -198,11 +201,16 @@ class MainActivity : VectorBaseActivity(), UnlockedActivity if (args.clearCache || args.clearCredentials || args.isAccountExpired) { doCleanUp() } else { + startSyncing() startNextActivityAndFinish() } } } + private fun startSyncing() { + activeSessionHolder.getSafeActiveSession()?.startSyncing(this) + } + private fun clearNotifications() { // Dismiss all notifications notificationDrawerManager.clearAllEvents() diff --git a/vector/src/main/java/im/vector/app/features/VectorFeatures.kt b/vector/src/main/java/im/vector/app/features/VectorFeatures.kt index 8f34ec32f7..8dc93c3963 100644 --- a/vector/src/main/java/im/vector/app/features/VectorFeatures.kt +++ b/vector/src/main/java/im/vector/app/features/VectorFeatures.kt @@ -16,12 +16,18 @@ package im.vector.app.features +import im.vector.app.R import im.vector.app.config.Config import im.vector.app.config.OnboardingVariant +import im.vector.app.core.resources.BooleanProvider import im.vector.app.features.settings.VectorPreferences +import javax.inject.Inject interface VectorFeatures { + fun tchapIsVoipSupported(): Boolean + fun tchapIsCrossSigningEnabled(): Boolean + fun tchapIsKeyBackupEnabled(): Boolean fun onboardingVariant(): OnboardingVariant fun isOnboardingAlreadyHaveAccountSplashEnabled(): Boolean fun isOnboardingSplashCarouselEnabled(): Boolean @@ -44,9 +50,15 @@ interface VectorFeatures { fun isQrCodeLoginForAllServers(): Boolean fun isReciprocateQrCodeLogin(): Boolean fun isVoiceBroadcastEnabled(): Boolean + fun isUnverifiedSessionsAlertEnabled(): Boolean } -class DefaultVectorFeatures : VectorFeatures { +class DefaultVectorFeatures @Inject constructor( + private val booleanProvider: BooleanProvider +) : VectorFeatures { + override fun tchapIsVoipSupported() = booleanProvider.getBoolean(R.bool.tchap_is_voip_supported) + override fun tchapIsCrossSigningEnabled() = booleanProvider.getBoolean(R.bool.tchap_is_cross_signing_enabled) + override fun tchapIsKeyBackupEnabled() = booleanProvider.getBoolean(R.bool.tchap_is_key_backup_enabled) override fun onboardingVariant() = Config.ONBOARDING_VARIANT override fun isOnboardingAlreadyHaveAccountSplashEnabled() = true override fun isOnboardingSplashCarouselEnabled() = false // Tchap: no carousel @@ -63,4 +75,5 @@ class DefaultVectorFeatures : VectorFeatures { override fun isQrCodeLoginForAllServers(): Boolean = false override fun isReciprocateQrCodeLogin(): Boolean = false override fun isVoiceBroadcastEnabled(): Boolean = true + override fun isUnverifiedSessionsAlertEnabled(): Boolean = true } diff --git a/vector/src/main/java/im/vector/app/features/analytics/VectorAnalytics.kt b/vector/src/main/java/im/vector/app/features/analytics/VectorAnalytics.kt index 7d11f93883..802ba08092 100644 --- a/vector/src/main/java/im/vector/app/features/analytics/VectorAnalytics.kt +++ b/vector/src/main/java/im/vector/app/features/analytics/VectorAnalytics.kt @@ -16,9 +16,10 @@ package im.vector.app.features.analytics +import im.vector.app.features.analytics.errors.ErrorTracker import kotlinx.coroutines.flow.Flow -interface VectorAnalytics : AnalyticsTracker { +interface VectorAnalytics : AnalyticsTracker, ErrorTracker { /** * Return a Flow of Boolean, true if the user has given their consent. */ diff --git a/vector/src/main/java/im/vector/app/features/analytics/errors/ErrorTracker.kt b/vector/src/main/java/im/vector/app/features/analytics/errors/ErrorTracker.kt new file mode 100644 index 0000000000..8ad6bfffc0 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/analytics/errors/ErrorTracker.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2022 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 im.vector.app.features.analytics.errors + +interface ErrorTracker { + fun trackError(throwable: Throwable) +} diff --git a/vector/src/main/java/im/vector/app/features/analytics/impl/DefaultVectorAnalytics.kt b/vector/src/main/java/im/vector/app/features/analytics/impl/DefaultVectorAnalytics.kt index 553d699d86..ca7608166c 100644 --- a/vector/src/main/java/im/vector/app/features/analytics/impl/DefaultVectorAnalytics.kt +++ b/vector/src/main/java/im/vector/app/features/analytics/impl/DefaultVectorAnalytics.kt @@ -41,7 +41,7 @@ private val IGNORED_OPTIONS: Options? = null @Singleton class DefaultVectorAnalytics @Inject constructor( postHogFactory: PostHogFactory, - private val sentryFactory: SentryFactory, + private val sentryAnalytics: SentryAnalytics, analyticsConfig: AnalyticsConfig, private val analyticsStore: AnalyticsStore, private val lateInitUserPropertiesFactory: LateInitUserPropertiesFactory, @@ -97,7 +97,7 @@ class DefaultVectorAnalytics @Inject constructor( setAnalyticsId("") // Close Sentry SDK. - sentryFactory.stopSentry() + sentryAnalytics.stopSentry() } private fun observeAnalyticsId() { @@ -135,8 +135,8 @@ class DefaultVectorAnalytics @Inject constructor( private fun initOrStopSentry() { userConsent?.let { when (it) { - true -> sentryFactory.initSentry() - false -> sentryFactory.stopSentry() + true -> sentryAnalytics.initSentry() + false -> sentryAnalytics.stopSentry() } } } @@ -180,4 +180,10 @@ class DefaultVectorAnalytics @Inject constructor( putAll(this@toPostHogUserProperties.filter { it.value != null }) } } + + override fun trackError(throwable: Throwable) { + sentryAnalytics + .takeIf { userConsent == true } + ?.trackError(throwable) + } } diff --git a/vector/src/main/java/im/vector/app/features/analytics/impl/SentryFactory.kt b/vector/src/main/java/im/vector/app/features/analytics/impl/SentryAnalytics.kt similarity index 88% rename from vector/src/main/java/im/vector/app/features/analytics/impl/SentryFactory.kt rename to vector/src/main/java/im/vector/app/features/analytics/impl/SentryAnalytics.kt index a000f2a77a..21721a31ae 100644 --- a/vector/src/main/java/im/vector/app/features/analytics/impl/SentryFactory.kt +++ b/vector/src/main/java/im/vector/app/features/analytics/impl/SentryAnalytics.kt @@ -18,6 +18,7 @@ package im.vector.app.features.analytics.impl import android.content.Context import im.vector.app.features.analytics.AnalyticsConfig +import im.vector.app.features.analytics.errors.ErrorTracker import im.vector.app.features.analytics.log.analyticsTag import io.sentry.Sentry import io.sentry.SentryOptions @@ -25,10 +26,10 @@ import io.sentry.android.core.SentryAndroid import timber.log.Timber import javax.inject.Inject -class SentryFactory @Inject constructor( +class SentryAnalytics @Inject constructor( private val context: Context, private val analyticsConfig: AnalyticsConfig, -) { +) : ErrorTracker { fun initSentry() { Timber.tag(analyticsTag.value).d("Initializing Sentry") @@ -47,4 +48,8 @@ class SentryFactory @Inject constructor( Timber.tag(analyticsTag.value).d("Stopping Sentry") Sentry.close() } + + override fun trackError(throwable: Throwable) { + Sentry.captureException(throwable) + } } diff --git a/vector/src/main/java/im/vector/app/features/analytics/metrics/VectorPlugins.kt b/vector/src/main/java/im/vector/app/features/analytics/metrics/VectorPlugins.kt index 64f143a2fd..4278c1011b 100644 --- a/vector/src/main/java/im/vector/app/features/analytics/metrics/VectorPlugins.kt +++ b/vector/src/main/java/im/vector/app/features/analytics/metrics/VectorPlugins.kt @@ -17,6 +17,7 @@ package im.vector.app.features.analytics.metrics import im.vector.app.features.analytics.metrics.sentry.SentryDownloadDeviceKeysMetrics +import im.vector.app.features.analytics.metrics.sentry.SentrySyncDurationMetrics import org.matrix.android.sdk.api.metrics.MetricPlugin import javax.inject.Inject import javax.inject.Singleton @@ -27,9 +28,10 @@ import javax.inject.Singleton @Singleton data class VectorPlugins @Inject constructor( val sentryDownloadDeviceKeysMetrics: SentryDownloadDeviceKeysMetrics, + val sentrySyncDurationMetrics: SentrySyncDurationMetrics, ) { /** * Returns [List] of all [MetricPlugin] hold by this class. */ - fun plugins(): List = listOf(sentryDownloadDeviceKeysMetrics) + fun plugins(): List = listOf(sentryDownloadDeviceKeysMetrics, sentrySyncDurationMetrics) } diff --git a/vector/src/main/java/im/vector/app/features/analytics/metrics/sentry/SentryDownloadDeviceKeysMetrics.kt b/vector/src/main/java/im/vector/app/features/analytics/metrics/sentry/SentryDownloadDeviceKeysMetrics.kt index 92213d380c..488b72bfd9 100644 --- a/vector/src/main/java/im/vector/app/features/analytics/metrics/sentry/SentryDownloadDeviceKeysMetrics.kt +++ b/vector/src/main/java/im/vector/app/features/analytics/metrics/sentry/SentryDownloadDeviceKeysMetrics.kt @@ -26,8 +26,10 @@ class SentryDownloadDeviceKeysMetrics @Inject constructor() : DownloadDeviceKeys private var transaction: ITransaction? = null override fun startTransaction() { - transaction = Sentry.startTransaction("download_device_keys", "task") - logTransaction("Sentry transaction started") + if (Sentry.isEnabled()) { + transaction = Sentry.startTransaction("download_device_keys", "task") + logTransaction("Sentry transaction started") + } } override fun finishTransaction() { diff --git a/vector/src/main/java/im/vector/app/features/analytics/metrics/sentry/SentrySyncDurationMetrics.kt b/vector/src/main/java/im/vector/app/features/analytics/metrics/sentry/SentrySyncDurationMetrics.kt new file mode 100644 index 0000000000..d69ed01526 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/analytics/metrics/sentry/SentrySyncDurationMetrics.kt @@ -0,0 +1,89 @@ +/* + * Copyright (c) 2022 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 im.vector.app.features.analytics.metrics.sentry + +import io.sentry.ISpan +import io.sentry.ITransaction +import io.sentry.Sentry +import io.sentry.SpanStatus +import org.matrix.android.sdk.api.metrics.SyncDurationMetricPlugin +import java.util.EmptyStackException +import java.util.Stack +import javax.inject.Inject + +/** + * Sentry based implementation of SyncDurationMetricPlugin. + */ +class SentrySyncDurationMetrics @Inject constructor() : SyncDurationMetricPlugin { + private var transaction: ITransaction? = null + + // Stacks to keep spans in LIFO order. + private var spans: Stack = Stack() + + /** + * Starts the span for a sub-task. + * + * @param operation Name of the new span. + * @param description Description of the new span. + * + * @throws IllegalStateException if this is called without starting a transaction ie. `measureSpan` must be called within `measureMetric`. + */ + override fun startSpan(operation: String, description: String) { + if (Sentry.isEnabled()) { + val span = Sentry.getSpan() ?: throw IllegalStateException("measureSpan block must be called within measureMetric") + val innerSpan = span.startChild(operation, description) + spans.push(innerSpan) + logTransaction("Sentry span started: operation=[$operation], description=[$description]") + } + } + + override fun finishSpan() { + try { + spans.pop() + } catch (e: EmptyStackException) { + null + }?.finish() + logTransaction("Sentry span finished") + } + + override fun startTransaction() { + if (Sentry.isEnabled()) { + transaction = Sentry.startTransaction("sync_response_handler", "task", true) + logTransaction("Sentry transaction started") + } + } + + override fun finishTransaction() { + transaction?.finish() + logTransaction("Sentry transaction finished") + } + + override fun onError(throwable: Throwable) { + try { + spans.peek() + } catch (e: EmptyStackException) { + null + }?.apply { + this.throwable = throwable + this.status = SpanStatus.INTERNAL_ERROR + } ?: transaction?.apply { + this.throwable = throwable + this.status = SpanStatus.INTERNAL_ERROR + } + logTransaction("Sentry transaction encountered error ${throwable.message}") + } +} diff --git a/vector/src/main/java/im/vector/app/features/analytics/plan/UserProperties.kt b/vector/src/main/java/im/vector/app/features/analytics/plan/UserProperties.kt index 28732c9a42..01720453ce 100644 --- a/vector/src/main/java/im/vector/app/features/analytics/plan/UserProperties.kt +++ b/vector/src/main/java/im/vector/app/features/analytics/plan/UserProperties.kt @@ -24,26 +24,6 @@ package im.vector.app.features.analytics.plan * definition. These properties must all be device independent. */ data class UserProperties( - /** - * Whether the user has the favourites space enabled. - */ - val webMetaSpaceFavouritesEnabled: Boolean? = null, - /** - * Whether the user has the home space set to all rooms. - */ - val webMetaSpaceHomeAllRooms: Boolean? = null, - /** - * Whether the user has the home space enabled. - */ - val webMetaSpaceHomeEnabled: Boolean? = null, - /** - * Whether the user has the other rooms space enabled. - */ - val webMetaSpaceOrphansEnabled: Boolean? = null, - /** - * Whether the user has the people space enabled. - */ - val webMetaSpacePeopleEnabled: Boolean? = null, /** * The active filter in the All Chats screen. */ @@ -109,11 +89,6 @@ data class UserProperties( fun getProperties(): Map? { return mutableMapOf().apply { - webMetaSpaceFavouritesEnabled?.let { put("WebMetaSpaceFavouritesEnabled", it) } - webMetaSpaceHomeAllRooms?.let { put("WebMetaSpaceHomeAllRooms", it) } - webMetaSpaceHomeEnabled?.let { put("WebMetaSpaceHomeEnabled", it) } - webMetaSpaceOrphansEnabled?.let { put("WebMetaSpaceOrphansEnabled", it) } - webMetaSpacePeopleEnabled?.let { put("WebMetaSpacePeopleEnabled", it) } allChatsActiveFilter?.let { put("allChatsActiveFilter", it.name) } ftueUseCaseSelection?.let { put("ftueUseCaseSelection", it.name) } numFavouriteRooms?.let { put("numFavouriteRooms", it) } diff --git a/vector/src/main/java/im/vector/app/features/attachments/AttachmentTypeSelectorBottomSheet.kt b/vector/src/main/java/im/vector/app/features/attachments/AttachmentTypeSelectorBottomSheet.kt index af17800455..f8d5d768ef 100644 --- a/vector/src/main/java/im/vector/app/features/attachments/AttachmentTypeSelectorBottomSheet.kt +++ b/vector/src/main/java/im/vector/app/features/attachments/AttachmentTypeSelectorBottomSheet.kt @@ -23,10 +23,10 @@ import android.view.ViewGroup import androidx.core.view.isVisible import androidx.fragment.app.FragmentManager import androidx.fragment.app.viewModels -import com.airbnb.mvrx.fragmentViewModel import com.airbnb.mvrx.parentFragmentViewModel import com.airbnb.mvrx.withState import dagger.hilt.android.AndroidEntryPoint +import im.vector.app.R import im.vector.app.core.platform.VectorBaseBottomSheetDialogFragment import im.vector.app.databinding.BottomSheetAttachmentTypeSelectorBinding import im.vector.app.features.home.room.detail.TimelineViewModel @@ -34,7 +34,7 @@ import im.vector.app.features.home.room.detail.TimelineViewModel @AndroidEntryPoint class AttachmentTypeSelectorBottomSheet : VectorBaseBottomSheetDialogFragment() { - private val viewModel: AttachmentTypeSelectorViewModel by fragmentViewModel() + private val viewModel: AttachmentTypeSelectorViewModel by parentFragmentViewModel() private val timelineViewModel: TimelineViewModel by parentFragmentViewModel() private val sharedActionViewModel: AttachmentTypeSelectorSharedActionViewModel by viewModels( ownerProducer = { requireParentFragment() } @@ -51,6 +51,14 @@ class AttachmentTypeSelectorBottomSheet : VectorBaseBottomSheetDialogFragment onTextFormattingToggled(isChecked) } } private fun onAttachmentSelected(attachmentType: AttachmentType) { @@ -71,6 +80,9 @@ class AttachmentTypeSelectorBottomSheet : VectorBaseBottomSheetDialogFragment(initialState) { + private val vectorPreferences: VectorPreferences, +) : VectorViewModel(initialState) { @AssistedFactory interface Factory : MavericksAssistedViewModelFactory { override fun create(initialState: AttachmentTypeSelectorViewState): AttachmentTypeSelectorViewModel @@ -39,8 +41,8 @@ class AttachmentTypeSelectorViewModel @AssistedInject constructor( companion object : MavericksViewModelFactory by hiltMavericksViewModelFactory() - override fun handle(action: EmptyAction) { - // do nothing + override fun handle(action: AttachmentTypeSelectorAction) = when (action) { + is AttachmentTypeSelectorAction.ToggleTextFormatting -> setTextFormattingEnabled(action.isEnabled) } init { @@ -48,6 +50,16 @@ class AttachmentTypeSelectorViewModel @AssistedInject constructor( copy( isLocationVisible = vectorFeatures.isLocationSharingEnabled(), isVoiceBroadcastVisible = vectorFeatures.isVoiceBroadcastEnabled(), + isTextFormattingEnabled = vectorPreferences.isTextFormattingEnabled(), + ) + } + } + + private fun setTextFormattingEnabled(isEnabled: Boolean) { + vectorPreferences.setTextFormattingEnabled(isEnabled) + setState { + copy( + isTextFormattingEnabled = isEnabled ) } } @@ -56,4 +68,9 @@ class AttachmentTypeSelectorViewModel @AssistedInject constructor( data class AttachmentTypeSelectorViewState( val isLocationVisible: Boolean = false, val isVoiceBroadcastVisible: Boolean = false, + val isTextFormattingEnabled: Boolean = false, ) : MavericksState + +sealed interface AttachmentTypeSelectorAction : VectorViewModelAction { + data class ToggleTextFormatting(val isEnabled: Boolean) : AttachmentTypeSelectorAction +} diff --git a/vector/src/main/java/im/vector/app/features/configuration/VectorCustomEventTypesProvider.kt b/vector/src/main/java/im/vector/app/features/configuration/VectorCustomEventTypesProvider.kt new file mode 100644 index 0000000000..55244685d7 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/configuration/VectorCustomEventTypesProvider.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2022 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 im.vector.app.features.configuration + +import im.vector.app.features.voicebroadcast.VoiceBroadcastConstants +import org.matrix.android.sdk.api.provider.CustomEventTypesProvider +import javax.inject.Inject + +class VectorCustomEventTypesProvider @Inject constructor() : CustomEventTypesProvider { + + override val customPreviewableEventTypes = listOf( + VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO + ) +} diff --git a/vector/src/main/java/im/vector/app/features/crypto/keysbackup/setup/KeysBackupSetupStep1Fragment.kt b/vector/src/main/java/im/vector/app/features/crypto/keysbackup/setup/KeysBackupSetupStep1Fragment.kt index 9b8386c741..8c4445f5c2 100644 --- a/vector/src/main/java/im/vector/app/features/crypto/keysbackup/setup/KeysBackupSetupStep1Fragment.kt +++ b/vector/src/main/java/im/vector/app/features/crypto/keysbackup/setup/KeysBackupSetupStep1Fragment.kt @@ -52,7 +52,8 @@ class KeysBackupSetupStep1Fragment : } private fun onButtonClick() { - viewModel.navigateEvent.value = LiveEvent(KeysBackupSetupSharedViewModel.NAVIGATE_TO_STEP_2) + // viewModel.navigateEvent.value = LiveEvent(KeysBackupSetupSharedViewModel.NAVIGATE_TO_STEP_2) + viewModel.prepareRecoveryKey(requireContext(), null) // Tchap : only recovery key } private fun onManualExportClick() { diff --git a/vector/src/main/java/im/vector/app/features/crypto/keysrequest/KeyRequestHandler.kt b/vector/src/main/java/im/vector/app/features/crypto/keysrequest/KeyRequestHandler.kt index 4b2c9c2bd6..26ace7d959 100644 --- a/vector/src/main/java/im/vector/app/features/crypto/keysrequest/KeyRequestHandler.kt +++ b/vector/src/main/java/im/vector/app/features/crypto/keysrequest/KeyRequestHandler.kt @@ -17,10 +17,10 @@ package im.vector.app.features.crypto.keysrequest import android.content.Context -import im.vector.app.BuildConfig import im.vector.app.R import im.vector.app.core.date.DateFormatKind import im.vector.app.core.date.VectorDateFormatter +import im.vector.app.features.VectorFeatures import im.vector.app.features.popup.DefaultVectorAlert import im.vector.app.features.popup.PopupAlertManager import im.vector.app.features.session.coroutineScope @@ -52,6 +52,7 @@ import javax.inject.Singleton @Singleton class KeyRequestHandler @Inject constructor( + private val vectorFeatures: VectorFeatures, private val context: Context, private val popupAlertManager: PopupAlertManager, private val dateFormatter: VectorDateFormatter @@ -121,7 +122,7 @@ class KeyRequestHandler @Inject constructor( return } - if (BuildConfig.ENABLE_CROSS_SIGNING) { + if (vectorFeatures.tchapIsCrossSigningEnabled()) { if (deviceInfo.isUnknown) { session?.cryptoService()?.setDeviceVerification( DeviceTrustLevel(crossSigningVerified = false, locallyVerified = false), userId, deviceId) diff --git a/vector/src/main/java/im/vector/app/features/crypto/verification/IncomingVerificationRequestHandler.kt b/vector/src/main/java/im/vector/app/features/crypto/verification/IncomingVerificationRequestHandler.kt index 3a5c7e7eb8..0f8f5c633e 100644 --- a/vector/src/main/java/im/vector/app/features/crypto/verification/IncomingVerificationRequestHandler.kt +++ b/vector/src/main/java/im/vector/app/features/crypto/verification/IncomingVerificationRequestHandler.kt @@ -72,10 +72,11 @@ class IncomingVerificationRequestHandler @Inject constructor( val user = session.getUserOrDefault(tx.otherUserId).toMatrixItem() val name = user.getBestName() val alert = VerificationVectorAlert( - uid, - context.getString(R.string.sas_incoming_request_notif_title), - context.getString(R.string.sas_incoming_request_notif_content, name), - R.drawable.ic_shield_black, + uid = uid, + title = context.getString(R.string.sas_incoming_request_notif_title), + description = context.getString(R.string.sas_incoming_request_notif_content, name), + iconId = R.drawable.ic_shield_black, + priority = PopupAlertManager.INCOMING_VERIFICATION_REQUEST_PRIORITY, shouldBeDisplayedIn = { activity -> if (activity is VectorBaseActivity<*>) { // TODO a bit too ugly :/ @@ -85,7 +86,7 @@ class IncomingVerificationRequestHandler @Inject constructor( } } ?: true } else true - } + }, ) .apply { viewBinder = VerificationVectorAlert.ViewBinder(user, avatarRenderer.get()) @@ -127,12 +128,9 @@ class IncomingVerificationRequestHandler @Inject constructor( // For incoming request we should prompt (if not in activity where this request apply) if (pr.isIncoming) { // if it's a self verification for my devices, we can discard the review login alert - // if not this request will be underneath and not visible by the user... + // if not, this request will be underneath and not visible by the user... // it will re-appear later - if (pr.otherUserId == session?.myUserId) { - // XXX this is a bit hard coded :/ - popupAlertManager.cancelAlert("review_login") - } + cancelAnyVerifySessionAlerts(pr) val user = session.getUserOrDefault(pr.otherUserId).toMatrixItem() val name = user.getBestName() val description = if (name == pr.otherUserId) { @@ -142,21 +140,23 @@ class IncomingVerificationRequestHandler @Inject constructor( } val alert = VerificationVectorAlert( - uniqueIdForVerificationRequest(pr), - context.getString(R.string.sas_incoming_request_notif_title), - description, - R.drawable.ic_shield_black, + uid = uniqueIdForVerificationRequest(pr), + title = context.getString(R.string.sas_incoming_request_notif_title), + description = description, + iconId = R.drawable.ic_shield_black, + priority = PopupAlertManager.INCOMING_VERIFICATION_REQUEST_PRIORITY, shouldBeDisplayedIn = { activity -> if (activity is RoomDetailActivity) { activity.intent?.extras?.getParcelableCompat(RoomDetailActivity.EXTRA_ROOM_DETAIL_ARGS)?.let { it.roomId != pr.roomId } ?: true } else true - } + }, ) .apply { viewBinder = VerificationVectorAlert.ViewBinder(user, avatarRenderer.get()) contentAction = Runnable { + cancelAnyVerifySessionAlerts(pr) (weakCurrentActivity?.get() as? VectorBaseActivity<*>)?.let { val roomId = pr.roomId if (roomId.isNullOrBlank()) { @@ -186,6 +186,13 @@ class IncomingVerificationRequestHandler @Inject constructor( } } + private fun cancelAnyVerifySessionAlerts(pr: PendingVerificationRequest) { + if (pr.otherUserId == session?.myUserId) { + popupAlertManager.cancelAlert(PopupAlertManager.REVIEW_LOGIN_UID) + popupAlertManager.cancelAlert(PopupAlertManager.VERIFY_SESSION_UID) + } + } + override fun verificationRequestUpdated(pr: PendingVerificationRequest) { // If an incoming request is readied (by another device?) we should discard the alert if (pr.isIncoming && (pr.isReady || pr.handledByOtherSession || pr.cancelConclusion != null)) { diff --git a/vector/src/main/java/im/vector/app/features/crypto/verification/request/VerificationRequestController.kt b/vector/src/main/java/im/vector/app/features/crypto/verification/request/VerificationRequestController.kt index 0a2c085f7e..a640c717f2 100644 --- a/vector/src/main/java/im/vector/app/features/crypto/verification/request/VerificationRequestController.kt +++ b/vector/src/main/java/im/vector/app/features/crypto/verification/request/VerificationRequestController.kt @@ -22,12 +22,12 @@ import com.airbnb.mvrx.Fail import com.airbnb.mvrx.Loading import com.airbnb.mvrx.Success import com.airbnb.mvrx.Uninitialized -import im.vector.app.BuildConfig import im.vector.app.R import im.vector.app.core.epoxy.bottomSheetDividerItem import im.vector.app.core.resources.ColorProvider import im.vector.app.core.resources.StringProvider import im.vector.app.core.utils.colorizeMatchingText +import im.vector.app.features.VectorFeatures import im.vector.app.features.crypto.verification.VerificationBottomSheetViewState import im.vector.app.features.crypto.verification.epoxy.bottomSheetSelfWaitItem import im.vector.app.features.crypto.verification.epoxy.bottomSheetVerificationActionItem @@ -38,6 +38,7 @@ import im.vector.lib.core.utils.epoxy.charsequence.toEpoxyCharSequence import javax.inject.Inject class VerificationRequestController @Inject constructor( + private val vectorFeatures: VectorFeatures, private val stringProvider: StringProvider, private val colorProvider: ColorProvider ) : EpoxyController() { @@ -71,7 +72,7 @@ class VerificationRequestController @Inject constructor( } } - if (BuildConfig.IS_KEY_BACKUP_SUPPORTED && state.quadSContainsSecrets) { + if (vectorFeatures.tchapIsKeyBackupEnabled() && state.quadSContainsSecrets) { val subtitle = if (state.hasAnyOtherSession) { stringProvider.getString(R.string.verification_use_passphrase) } else { diff --git a/vector/src/main/java/im/vector/app/features/displayname/VectorMatrixItemDisplayNameFallbackProvider.kt b/vector/src/main/java/im/vector/app/features/displayname/VectorMatrixItemDisplayNameFallbackProvider.kt index b856d8e599..528523c244 100644 --- a/vector/src/main/java/im/vector/app/features/displayname/VectorMatrixItemDisplayNameFallbackProvider.kt +++ b/vector/src/main/java/im/vector/app/features/displayname/VectorMatrixItemDisplayNameFallbackProvider.kt @@ -17,7 +17,7 @@ package im.vector.app.features.displayname import fr.gouv.tchap.core.utils.TchapUtils -import org.matrix.android.sdk.api.MatrixItemDisplayNameFallbackProvider +import org.matrix.android.sdk.api.provider.MatrixItemDisplayNameFallbackProvider import org.matrix.android.sdk.api.util.MatrixItem // Used to provide the fallback to the MatrixSDK, in the MatrixConfiguration diff --git a/vector/src/main/java/im/vector/app/features/home/HomeActivity.kt b/vector/src/main/java/im/vector/app/features/home/HomeActivity.kt index 011e61ab66..4d47320cf0 100644 --- a/vector/src/main/java/im/vector/app/features/home/HomeActivity.kt +++ b/vector/src/main/java/im/vector/app/features/home/HomeActivity.kt @@ -45,8 +45,6 @@ import im.vector.app.core.extensions.restart import im.vector.app.core.extensions.validateBackPressed import im.vector.app.core.platform.VectorBaseActivity import im.vector.app.core.platform.VectorMenuProvider -import im.vector.app.core.pushers.FcmHelper -import im.vector.app.core.pushers.PushersManager import im.vector.app.core.pushers.UnifiedPushHelper import im.vector.app.core.utils.registerForPermissionsResult import im.vector.app.core.utils.startSharePlainTextIntent @@ -132,7 +130,6 @@ class HomeActivity : private val serverBackupStatusViewModel: ServerBackupStatusViewModel by viewModel() @Inject lateinit var vectorUncaughtExceptionHandler: VectorUncaughtExceptionHandler - @Inject lateinit var pushersManager: PushersManager @Inject lateinit var notificationDrawerManager: NotificationDrawerManager @Inject lateinit var popupAlertManager: PopupAlertManager @Inject lateinit var shortcutsHandler: ShortcutsHandler @@ -141,7 +138,6 @@ class HomeActivity : @Inject lateinit var initSyncStepFormatter: InitSyncStepFormatter @Inject lateinit var spaceStateHandler: SpaceStateHandler @Inject lateinit var unifiedPushHelper: UnifiedPushHelper - @Inject lateinit var fcmHelper: FcmHelper @Inject lateinit var nightlyProxy: NightlyProxy @Inject lateinit var disclaimerDialog: DisclaimerDialog @Inject lateinit var notificationPermissionManager: NotificationPermissionManager @@ -213,16 +209,6 @@ class HomeActivity : isNewAppLayoutEnabled = vectorPreferences.isNewAppLayoutEnabled() analyticsScreenName = MobileScreen.ScreenName.Home supportFragmentManager.registerFragmentLifecycleCallbacks(fragmentLifecycleCallbacks, false) - unifiedPushHelper.register(this) { - if (unifiedPushHelper.isEmbeddedDistributor()) { - fcmHelper.ensureFcmTokenIsRetrieved( - this, - pushersManager, - homeActivityViewModel.shouldAddHttpPusher() - ) - } - } - sharedActionViewModel = viewModelProvider[HomeSharedActionViewModel::class.java] roomListSharedActionViewModel = viewModelProvider[RoomListSharedActionViewModel::class.java] views.drawerLayout.addDrawerListener(drawerListener) @@ -296,6 +282,7 @@ class HomeActivity : HomeActivityViewEvents.ShowReleaseNotes -> handleShowReleaseNotes() HomeActivityViewEvents.NotifyUserForThreadsMigration -> handleNotifyUserForThreadsMigration() is HomeActivityViewEvents.MigrateThreads -> migrateThreadsIfNeeded(it.checkSession) + is HomeActivityViewEvents.AskUserForPushDistributor -> askUserToSelectPushDistributor() } } homeActivityViewModel.onEach { renderState(it) } @@ -308,6 +295,12 @@ class HomeActivity : homeActivityViewModel.handle(HomeActivityViewActions.ViewStarted) } + private fun askUserToSelectPushDistributor() { + unifiedPushHelper.showSelectDistributorDialog(this) { selection -> + homeActivityViewModel.handle(HomeActivityViewActions.RegisterPushDistributor(selection)) + } + } + private fun handleShowNotificationDialog() { notificationPermissionManager.eventuallyRequestPermission(this, postPermissionLauncher) } @@ -431,14 +424,6 @@ class HomeActivity : } private fun renderState(state: HomeActivityViewState) { - lifecycleScope.launch { - if (state.areNotificationsSilenced) { - unifiedPushHelper.unregister(pushersManager) - } else { - unifiedPushHelper.register(this@HomeActivity) - } - } - when (val status = state.syncRequestState) { is SyncRequestState.InitialSyncProgressing -> { val initSyncStepStr = initSyncStepFormatter.format(status.initialSyncStep) @@ -468,9 +453,10 @@ class HomeActivity : private fun handleAskPasswordToInitCrossSigning(events: HomeActivityViewEvents.AskPasswordToInitCrossSigning) { // We need to ask promptSecurityEvent( - events.userItem, - R.string.upgrade_security, - R.string.security_prompt_text + uid = PopupAlertManager.UPGRADE_SECURITY_UID, + userItem = events.userItem, + titleRes = R.string.upgrade_security, + descRes = R.string.security_prompt_text, ) { it.navigator.upgradeSessionSecurity(it, true) } @@ -479,9 +465,10 @@ class HomeActivity : private fun handleCrossSigningInvalidated(event: HomeActivityViewEvents.OnCrossSignedInvalidated) { // We need to ask promptSecurityEvent( - event.userItem, - R.string.crosssigning_verify_this_session, - R.string.confirm_your_identity + uid = PopupAlertManager.VERIFY_SESSION_UID, + userItem = event.userItem, + titleRes = R.string.crosssigning_verify_this_session, + descRes = R.string.confirm_your_identity, ) { it.navigator.waitSessionVerification(it) } @@ -490,9 +477,10 @@ class HomeActivity : private fun handleOnNewSession(event: HomeActivityViewEvents.CurrentSessionNotVerified) { // We need to ask promptSecurityEvent( - event.userItem, - R.string.crosssigning_verify_this_session, - R.string.confirm_your_identity + uid = PopupAlertManager.VERIFY_SESSION_UID, + userItem = event.userItem, + titleRes = R.string.crosssigning_verify_this_session, + descRes = R.string.confirm_your_identity, ) { if (event.waitForIncomingRequest) { it.navigator.waitSessionVerification(it) @@ -505,9 +493,10 @@ class HomeActivity : private fun handleCantVerify(event: HomeActivityViewEvents.CurrentSessionCannotBeVerified) { // We need to ask promptSecurityEvent( - event.userItem, - R.string.crosssigning_cannot_verify_this_session, - R.string.crosssigning_cannot_verify_this_session_desc + uid = PopupAlertManager.UPGRADE_SECURITY_UID, + userItem = event.userItem, + titleRes = R.string.crosssigning_cannot_verify_this_session, + descRes = R.string.crosssigning_cannot_verify_this_session_desc, ) { it.navigator.open4SSetup(it, SetupMode.PASSPHRASE_AND_NEEDED_SECRETS_RESET) } @@ -516,7 +505,7 @@ class HomeActivity : private fun handlePromptToEnablePush() { popupAlertManager.postVectorAlert( DefaultVectorAlert( - uid = "enablePush", + uid = PopupAlertManager.ENABLE_PUSH_UID, title = getString(R.string.alert_push_are_disabled_title), description = getString(R.string.alert_push_are_disabled_description), iconId = R.drawable.ic_room_actions_notifications_mutes, @@ -549,10 +538,16 @@ class HomeActivity : ) } - private fun promptSecurityEvent(userItem: MatrixItem.UserItem, titleRes: Int, descRes: Int, action: ((VectorBaseActivity<*>) -> Unit)) { + private fun promptSecurityEvent( + uid: String, + userItem: MatrixItem.UserItem, + titleRes: Int, + descRes: Int, + action: ((VectorBaseActivity<*>) -> Unit), + ) { popupAlertManager.postVectorAlert( VerificationVectorAlert( - uid = "upgradeSecurity", + uid = uid, title = getString(titleRes), description = getString(descRes), iconId = R.drawable.ic_shield_warning @@ -612,7 +607,9 @@ class HomeActivity : serverBackupStatusViewModel.refreshRemoteStateIfNeeded() // Check nightly - nightlyProxy.onHomeResumed() + if (nightlyProxy.canDisplayPopup()) { + nightlyProxy.updateApplication() + } checkNewAppLayoutFlagChange() } diff --git a/vector/src/main/java/im/vector/app/features/home/HomeActivityViewActions.kt b/vector/src/main/java/im/vector/app/features/home/HomeActivityViewActions.kt index c0441ab99a..5a0ccbd5c1 100644 --- a/vector/src/main/java/im/vector/app/features/home/HomeActivityViewActions.kt +++ b/vector/src/main/java/im/vector/app/features/home/HomeActivityViewActions.kt @@ -19,9 +19,9 @@ package im.vector.app.features.home import im.vector.app.core.platform.VectorViewModelAction sealed interface HomeActivityViewActions : VectorViewModelAction { - object ViewStarted : HomeActivityViewActions - object PushPromptHasBeenReviewed : HomeActivityViewActions - // Tchap: Use only in Tchap object DisclaimerDialogShown : HomeActivityViewActions + object ViewStarted : HomeActivityViewActions + object PushPromptHasBeenReviewed : HomeActivityViewActions + data class RegisterPushDistributor(val distributor: String) : HomeActivityViewActions } diff --git a/vector/src/main/java/im/vector/app/features/home/HomeActivityViewEvents.kt b/vector/src/main/java/im/vector/app/features/home/HomeActivityViewEvents.kt index e548fdb2f3..be5aa7def0 100644 --- a/vector/src/main/java/im/vector/app/features/home/HomeActivityViewEvents.kt +++ b/vector/src/main/java/im/vector/app/features/home/HomeActivityViewEvents.kt @@ -25,9 +25,11 @@ sealed interface HomeActivityViewEvents : VectorViewEvents { val userItem: MatrixItem.UserItem, val waitForIncomingRequest: Boolean = true, ) : HomeActivityViewEvents + data class CurrentSessionCannotBeVerified( val userItem: MatrixItem.UserItem, ) : HomeActivityViewEvents + data class OnCrossSignedInvalidated(val userItem: MatrixItem.UserItem) : HomeActivityViewEvents object PromptToEnableSessionPush : HomeActivityViewEvents object ShowAnalyticsOptIn : HomeActivityViewEvents @@ -37,4 +39,5 @@ sealed interface HomeActivityViewEvents : VectorViewEvents { data class MigrateThreads(val checkSession: Boolean) : HomeActivityViewEvents object StartRecoverySetupFlow : HomeActivityViewEvents data class ForceVerification(val sendRequest: Boolean) : HomeActivityViewEvents + object AskUserForPushDistributor : HomeActivityViewEvents } diff --git a/vector/src/main/java/im/vector/app/features/home/HomeActivityViewModel.kt b/vector/src/main/java/im/vector/app/features/home/HomeActivityViewModel.kt index 1076ecbe97..007c25352a 100644 --- a/vector/src/main/java/im/vector/app/features/home/HomeActivityViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/home/HomeActivityViewModel.kt @@ -16,18 +16,20 @@ package im.vector.app.features.home -import androidx.lifecycle.asFlow import com.airbnb.mvrx.Mavericks import com.airbnb.mvrx.MavericksViewModelFactory import com.airbnb.mvrx.ViewModelContext import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject -import im.vector.app.BuildConfig import im.vector.app.core.di.ActiveSessionHolder import im.vector.app.core.di.MavericksAssistedViewModelFactory import im.vector.app.core.di.hiltMavericksViewModelFactory import im.vector.app.core.platform.VectorViewModel +import im.vector.app.core.pushers.EnsureFcmTokenIsRetrievedUseCase +import im.vector.app.core.pushers.PushersManager +import im.vector.app.core.pushers.RegisterUnifiedPushUseCase +import im.vector.app.core.pushers.UnregisterUnifiedPushUseCase import im.vector.app.features.VectorFeatures import im.vector.app.features.analytics.AnalyticsConfig import im.vector.app.features.analytics.AnalyticsTracker @@ -43,17 +45,16 @@ import im.vector.app.features.raw.wellknown.isSecureBackupRequired import im.vector.app.features.raw.wellknown.withElementWellKnown import im.vector.app.features.session.coroutineScope import im.vector.app.features.settings.VectorPreferences +import im.vector.app.features.voicebroadcast.recording.usecase.StopOngoingVoiceBroadcastUseCase import im.vector.lib.core.utils.compat.getParcelableExtraCompat import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.takeWhile import kotlinx.coroutines.launch -import org.matrix.android.sdk.api.account.LocalNotificationSettingsContent import org.matrix.android.sdk.api.auth.UIABaseAuth import org.matrix.android.sdk.api.auth.UserInteractiveAuthInterceptor import org.matrix.android.sdk.api.auth.UserPasswordAuth @@ -62,11 +63,9 @@ import org.matrix.android.sdk.api.auth.registration.RegistrationFlowResponse import org.matrix.android.sdk.api.auth.registration.nextUncompletedStage import org.matrix.android.sdk.api.extensions.tryOrNull import org.matrix.android.sdk.api.raw.RawService -import org.matrix.android.sdk.api.session.accountdata.UserAccountDataTypes import org.matrix.android.sdk.api.session.crypto.crosssigning.CrossSigningService import org.matrix.android.sdk.api.session.crypto.model.CryptoDeviceInfo import org.matrix.android.sdk.api.session.crypto.model.MXUsersDevicesMap -import org.matrix.android.sdk.api.session.events.model.toModel import org.matrix.android.sdk.api.session.getUserOrDefault import org.matrix.android.sdk.api.session.pushrules.RuleIds import org.matrix.android.sdk.api.session.room.model.Membership @@ -83,6 +82,7 @@ import kotlin.coroutines.resumeWithException class HomeActivityViewModel @AssistedInject constructor( @Assisted private val initialState: HomeActivityViewState, + private val vectorFeatures: VectorFeatures, private val activeSessionHolder: ActiveSessionHolder, private val rawService: RawService, private val reAuthHelper: ReAuthHelper, @@ -92,7 +92,11 @@ class HomeActivityViewModel @AssistedInject constructor( private val analyticsTracker: AnalyticsTracker, private val analyticsConfig: AnalyticsConfig, private val releaseNotesPreferencesStore: ReleaseNotesPreferencesStore, - private val vectorFeatures: VectorFeatures, + private val stopOngoingVoiceBroadcastUseCase: StopOngoingVoiceBroadcastUseCase, + private val pushersManager: PushersManager, + private val registerUnifiedPushUseCase: RegisterUnifiedPushUseCase, + private val unregisterUnifiedPushUseCase: UnregisterUnifiedPushUseCase, + private val ensureFcmTokenIsRetrievedUseCase: EnsureFcmTokenIsRetrievedUseCase, ) : VectorViewModel(initialState) { @AssistedFactory @@ -113,20 +117,48 @@ class HomeActivityViewModel @AssistedInject constructor( private var checkBootstrap = false // Tchap: Disable cross-signing - private var hasCheckedBootstrap = !BuildConfig.ENABLE_CROSS_SIGNING + private var hasCheckedBootstrap = !vectorFeatures.tchapIsCrossSigningEnabled() private var onceTrusted = false private fun initialize() { if (isInitialized) return isInitialized = true + registerUnifiedPushIfNeeded() cleanupFiles() observeInitialSync() checkSessionPushIsOn() observeCrossSigningReset() observeAnalytics() observeReleaseNotes() - observeLocalNotificationsSilenced() initThreadsMigration() + viewModelScope.launch { stopOngoingVoiceBroadcastUseCase.execute() } + } + + private fun registerUnifiedPushIfNeeded() { + if (vectorPreferences.areNotificationEnabledForDevice()) { + registerUnifiedPush(distributor = "") + } else { + unregisterUnifiedPush() + } + } + + private fun registerUnifiedPush(distributor: String) { + viewModelScope.launch { + when (registerUnifiedPushUseCase.execute(distributor = distributor)) { + is RegisterUnifiedPushUseCase.RegisterUnifiedPushResult.NeedToAskUserForDistributor -> { + _viewEvents.post(HomeActivityViewEvents.AskUserForPushDistributor) + } + RegisterUnifiedPushUseCase.RegisterUnifiedPushResult.Success -> { + ensureFcmTokenIsRetrievedUseCase.execute(pushersManager, registerPusher = vectorPreferences.areNotificationEnabledForDevice()) + } + } + } + } + + private fun unregisterUnifiedPush() { + viewModelScope.launch { + unregisterUnifiedPushUseCase.execute(pushersManager) + } } private fun observeReleaseNotes() = withState { state -> @@ -147,26 +179,6 @@ class HomeActivityViewModel @AssistedInject constructor( } } - fun shouldAddHttpPusher() = if (vectorPreferences.areNotificationEnabledForDevice()) { - val currentSession = activeSessionHolder.getActiveSession() - val currentPushers = currentSession.pushersService().getPushers() - currentPushers.none { it.deviceId == currentSession.sessionParams.deviceId } - } else { - false - } - - fun observeLocalNotificationsSilenced() { - val currentSession = activeSessionHolder.getActiveSession() - val deviceId = currentSession.cryptoService().getMyDevice().deviceId - viewModelScope.launch { - currentSession.accountDataService() - .getLiveUserAccountDataEvent(UserAccountDataTypes.TYPE_LOCAL_NOTIFICATION_SETTINGS + deviceId) - .asFlow() - .map { it.getOrNull()?.content?.toModel()?.isSilenced ?: false } - .onEach { setState { copy(areNotificationsSilenced = it) } } - } - } - private fun observeAnalytics() { if (analyticsConfig.isEnabled) { analyticsStore.didAskUserConsentFlow @@ -225,7 +237,7 @@ class HomeActivityViewModel @AssistedInject constructor( // Tchap: Disable cross-signing val mxCrossSigningInfo = info.getOrNull() - if (!BuildConfig.ENABLE_CROSS_SIGNING && mxCrossSigningInfo != null) { + if (!vectorFeatures.tchapIsCrossSigningEnabled() && mxCrossSigningInfo != null) { Timber.i("Cross signing feature is disabled. This account should not have cross signing keys") } @@ -255,6 +267,12 @@ class HomeActivityViewModel @AssistedInject constructor( // } when { + !vectorPreferences.areThreadMessagesEnabled() && !vectorPreferences.wasThreadFlagChangedManually() -> { + vectorPreferences.setThreadMessagesEnabled() + lightweightSettingsStorage.setThreadMessagesEnabled(vectorPreferences.areThreadMessagesEnabled()) + // Clear Cache + _viewEvents.post(HomeActivityViewEvents.MigrateThreads(checkSession = false)) + } // Notify users vectorPreferences.shouldNotifyUserAboutThreads() && vectorPreferences.areThreadMessagesEnabled() -> { Timber.i("----> Notify users about threads") @@ -525,15 +543,18 @@ class HomeActivityViewModel @AssistedInject constructor( override fun handle(action: HomeActivityViewActions) { when (action) { + HomeActivityViewActions.DisclaimerDialogShown -> { + // Tchap: in case of migration, there is no initial sync, so force the update of the identity server url + updateIdentityServer() + } HomeActivityViewActions.PushPromptHasBeenReviewed -> { vectorPreferences.setDidAskUserToEnableSessionPush() } HomeActivityViewActions.ViewStarted -> { initialize() } - HomeActivityViewActions.DisclaimerDialogShown -> { - // Tchap: in case of migration, there is no initial sync, so force the update of the identity server url - updateIdentityServer() + is HomeActivityViewActions.RegisterPushDistributor -> { + registerUnifiedPush(distributor = action.distributor) } } } diff --git a/vector/src/main/java/im/vector/app/features/home/HomeActivityViewState.kt b/vector/src/main/java/im/vector/app/features/home/HomeActivityViewState.kt index 4df2957cbc..f9c1b37ed5 100644 --- a/vector/src/main/java/im/vector/app/features/home/HomeActivityViewState.kt +++ b/vector/src/main/java/im/vector/app/features/home/HomeActivityViewState.kt @@ -23,5 +23,4 @@ import org.matrix.android.sdk.api.session.sync.SyncRequestState data class HomeActivityViewState( val syncRequestState: SyncRequestState = SyncRequestState.Idle, val authenticationDescription: AuthenticationDescription? = null, - val areNotificationsSilenced: Boolean = false, ) : MavericksState diff --git a/vector/src/main/java/im/vector/app/features/home/HomeDetailFragment.kt b/vector/src/main/java/im/vector/app/features/home/HomeDetailFragment.kt index cece3205e4..53147f0190 100644 --- a/vector/src/main/java/im/vector/app/features/home/HomeDetailFragment.kt +++ b/vector/src/main/java/im/vector/app/features/home/HomeDetailFragment.kt @@ -32,7 +32,6 @@ import com.airbnb.mvrx.withState import com.google.android.material.badge.BadgeDrawable import com.google.android.material.dialog.MaterialAlertDialogBuilder import dagger.hilt.android.AndroidEntryPoint -import im.vector.app.BuildConfig import im.vector.app.R import im.vector.app.SpaceStateHandler import im.vector.app.core.extensions.commitTransaction @@ -46,6 +45,7 @@ import im.vector.app.core.ui.views.CurrentCallsView import im.vector.app.core.ui.views.CurrentCallsViewPresenter import im.vector.app.core.ui.views.KeysBackupBanner import im.vector.app.databinding.FragmentHomeDetailBinding +import im.vector.app.features.VectorFeatures import im.vector.app.features.call.SharedKnownCallsViewModel import im.vector.app.features.call.VectorCallActivity import im.vector.app.features.call.dialpad.DialPadFragment @@ -81,6 +81,7 @@ class HomeDetailFragment : OnBackPressed, VectorMenuProvider { + @Inject lateinit var vectorFeatures: VectorFeatures @Inject lateinit var avatarRenderer: AvatarRenderer @Inject lateinit var colorProvider: ColorProvider @Inject lateinit var alertManager: PopupAlertManager @@ -169,13 +170,13 @@ class HomeDetailFragment : unknownDeviceDetectorSharedViewModel.onEach { state -> state.unknownSessions.invoke()?.let { unknownDevices -> if (unknownDevices.firstOrNull()?.currentSessionTrust == true) { - val uid = "review_login" + val uid = PopupAlertManager.REVIEW_LOGIN_UID alertManager.cancelAlert(uid) val olderUnverified = unknownDevices.filter { !it.isNew } val newest = unknownDevices.firstOrNull { it.isNew }?.deviceInfo if (newest != null) { promptForNewUnknownDevices(uid, state, newest) - } else if (BuildConfig.ENABLE_CROSS_SIGNING && olderUnverified.isNotEmpty()) { + } else if (vectorFeatures.tchapIsCrossSigningEnabled() && olderUnverified.isNotEmpty()) { // In this case we prompt to go to settings to review logins promptToReviewChanges(uid, state, olderUnverified.map { it.deviceInfo }) } @@ -296,12 +297,12 @@ class HomeDetailFragment : .requestSessionVerification(vectorBaseActivity, newest.deviceId ?: "") } unknownDeviceDetectorSharedViewModel.handle( - UnknownDeviceDetectorSharedViewModel.Action.IgnoreDevice(newest.deviceId?.let { listOf(it) }.orEmpty()) + UnknownDeviceDetectorSharedViewModel.Action.IgnoreNewLogin(newest.deviceId?.let { listOf(it) }.orEmpty()) ) } dismissedAction = Runnable { unknownDeviceDetectorSharedViewModel.handle( - UnknownDeviceDetectorSharedViewModel.Action.IgnoreDevice(newest.deviceId?.let { listOf(it) }.orEmpty()) + UnknownDeviceDetectorSharedViewModel.Action.IgnoreNewLogin(newest.deviceId?.let { listOf(it) }.orEmpty()) ) } } @@ -313,8 +314,8 @@ class HomeDetailFragment : alertManager.postVectorAlert( VerificationVectorAlert( uid = uid, - title = getString(R.string.review_logins), - description = getString(R.string.verify_other_sessions), + title = getString(R.string.review_unverified_sessions_title), + description = getString(R.string.review_unverified_sessions_description), iconId = R.drawable.ic_shield_warning ).apply { viewBinder = VerificationVectorAlert.ViewBinder(user, avatarRenderer) diff --git a/vector/src/main/java/im/vector/app/features/home/IsNewLoginAlertShownUseCase.kt b/vector/src/main/java/im/vector/app/features/home/IsNewLoginAlertShownUseCase.kt new file mode 100644 index 0000000000..5a0d4743dc --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/IsNewLoginAlertShownUseCase.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2022 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 im.vector.app.features.home + +import im.vector.app.features.settings.VectorPreferences +import javax.inject.Inject + +class IsNewLoginAlertShownUseCase @Inject constructor( + private val vectorPreferences: VectorPreferences, +) { + + fun execute(deviceId: String?): Boolean { + deviceId ?: return false + + return vectorPreferences.isNewLoginAlertShownForDevice(deviceId) + } +} diff --git a/vector/src/main/java/im/vector/app/features/home/NewHomeDetailFragment.kt b/vector/src/main/java/im/vector/app/features/home/NewHomeDetailFragment.kt index 7520c9fb3d..944c033b26 100644 --- a/vector/src/main/java/im/vector/app/features/home/NewHomeDetailFragment.kt +++ b/vector/src/main/java/im/vector/app/features/home/NewHomeDetailFragment.kt @@ -164,7 +164,7 @@ class NewHomeDetailFragment : unknownDeviceDetectorSharedViewModel.onEach { state -> state.unknownSessions.invoke()?.let { unknownDevices -> if (unknownDevices.firstOrNull()?.currentSessionTrust == true) { - val uid = "review_login" + val uid = PopupAlertManager.REVIEW_LOGIN_UID alertManager.cancelAlert(uid) val olderUnverified = unknownDevices.filter { !it.isNew } val newest = unknownDevices.firstOrNull { it.isNew }?.deviceInfo @@ -272,12 +272,12 @@ class NewHomeDetailFragment : .requestSessionVerification(vectorBaseActivity, newest.deviceId ?: "") } unknownDeviceDetectorSharedViewModel.handle( - UnknownDeviceDetectorSharedViewModel.Action.IgnoreDevice(newest.deviceId?.let { listOf(it) }.orEmpty()) + UnknownDeviceDetectorSharedViewModel.Action.IgnoreNewLogin(newest.deviceId?.let { listOf(it) }.orEmpty()) ) } dismissedAction = Runnable { unknownDeviceDetectorSharedViewModel.handle( - UnknownDeviceDetectorSharedViewModel.Action.IgnoreDevice(newest.deviceId?.let { listOf(it) }.orEmpty()) + UnknownDeviceDetectorSharedViewModel.Action.IgnoreNewLogin(newest.deviceId?.let { listOf(it) }.orEmpty()) ) } } @@ -289,8 +289,8 @@ class NewHomeDetailFragment : alertManager.postVectorAlert( VerificationVectorAlert( uid = uid, - title = getString(R.string.review_logins), - description = getString(R.string.verify_other_sessions), + title = getString(R.string.review_unverified_sessions_title), + description = getString(R.string.review_unverified_sessions_description), iconId = R.drawable.ic_shield_warning ).apply { viewBinder = VerificationVectorAlert.ViewBinder(user, avatarRenderer) diff --git a/vector/src/main/java/im/vector/app/features/home/NightlyProxy.kt b/vector/src/main/java/im/vector/app/features/home/NightlyProxy.kt index b25add2ac9..42b93bf1a5 100644 --- a/vector/src/main/java/im/vector/app/features/home/NightlyProxy.kt +++ b/vector/src/main/java/im/vector/app/features/home/NightlyProxy.kt @@ -17,5 +17,18 @@ package im.vector.app.features.home interface NightlyProxy { - fun onHomeResumed() + /** + * Return true if this is a nightly build (checking the package of the app), and only once a day. + */ + fun canDisplayPopup(): Boolean + + /** + * Return true if this is a nightly build (checking the package of the app). + */ + fun isNightlyBuild(): Boolean + + /** + * Try to update the application, if update is available. Will also take care of the user sign in. + */ + fun updateApplication() } diff --git a/vector/src/main/java/im/vector/app/features/home/SetNewLoginAlertShownUseCase.kt b/vector/src/main/java/im/vector/app/features/home/SetNewLoginAlertShownUseCase.kt new file mode 100644 index 0000000000..d313f93043 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/SetNewLoginAlertShownUseCase.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2022 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 im.vector.app.features.home + +import im.vector.app.features.settings.VectorPreferences +import javax.inject.Inject + +class SetNewLoginAlertShownUseCase @Inject constructor( + private val vectorPreferences: VectorPreferences, +) { + + fun execute(deviceIds: List) { + deviceIds.forEach { + vectorPreferences.setNewLoginAlertShownForDevice(it) + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/home/SetUnverifiedSessionsAlertShownUseCase.kt b/vector/src/main/java/im/vector/app/features/home/SetUnverifiedSessionsAlertShownUseCase.kt new file mode 100644 index 0000000000..4580ac0f31 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/SetUnverifiedSessionsAlertShownUseCase.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2022 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 im.vector.app.features.home + +import im.vector.app.core.time.Clock +import im.vector.app.features.settings.VectorPreferences +import javax.inject.Inject + +class SetUnverifiedSessionsAlertShownUseCase @Inject constructor( + private val vectorPreferences: VectorPreferences, + private val clock: Clock, +) { + + fun execute(deviceIds: List) { + val epochMillis = clock.epochMillis() + deviceIds.forEach { + vectorPreferences.setUnverifiedSessionsAlertLastShownMillis(it, epochMillis) + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/home/ShortcutCreator.kt b/vector/src/main/java/im/vector/app/features/home/ShortcutCreator.kt index e0565debf2..a0bcea217f 100644 --- a/vector/src/main/java/im/vector/app/features/home/ShortcutCreator.kt +++ b/vector/src/main/java/im/vector/app/features/home/ShortcutCreator.kt @@ -29,6 +29,7 @@ import im.vector.app.core.glide.GlideApp import im.vector.app.core.resources.BuildMeta import im.vector.app.core.utils.DimensionConverter import im.vector.app.features.MainActivity +import im.vector.app.features.settings.VectorPreferences import org.matrix.android.sdk.api.session.room.model.RoomSummary import org.matrix.android.sdk.api.util.toMatrixItem import javax.inject.Inject @@ -55,6 +56,7 @@ class ShortcutCreator @Inject constructor( dimensionConverter.dpToPx(72) } } + @Inject lateinit var vectorPreferences: VectorPreferences fun canCreateShortcut(): Boolean { return ShortcutManagerCompat.isRequestPinShortcutSupported(context) @@ -73,10 +75,12 @@ class ShortcutCreator @Inject constructor( } catch (failure: Throwable) { null } - val categories = if (Build.VERSION.SDK_INT >= 25) { - setOf(directShareCategory, ShortcutInfo.SHORTCUT_CATEGORY_CONVERSATION) - } else { - setOf(directShareCategory) + val categories = mutableSetOf() + if (vectorPreferences.directShareEnabled()) { + categories.add(directShareCategory) + } + if (Build.VERSION.SDK_INT >= 25) { + categories.add(ShortcutInfo.SHORTCUT_CATEGORY_CONVERSATION) } return ShortcutInfoCompat.Builder(context, roomSummary.roomId) diff --git a/vector/src/main/java/im/vector/app/features/home/ShouldShowUnverifiedSessionsAlertUseCase.kt b/vector/src/main/java/im/vector/app/features/home/ShouldShowUnverifiedSessionsAlertUseCase.kt new file mode 100644 index 0000000000..18c7ed9689 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/ShouldShowUnverifiedSessionsAlertUseCase.kt @@ -0,0 +1,39 @@ +/* + * Copyright (c) 2022 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 im.vector.app.features.home + +import im.vector.app.config.Config +import im.vector.app.core.time.Clock +import im.vector.app.features.VectorFeatures +import im.vector.app.features.settings.VectorPreferences +import javax.inject.Inject + +class ShouldShowUnverifiedSessionsAlertUseCase @Inject constructor( + private val vectorFeatures: VectorFeatures, + private val vectorPreferences: VectorPreferences, + private val clock: Clock, +) { + + fun execute(deviceId: String?): Boolean { + deviceId ?: return false + + val isUnverifiedSessionsAlertEnabled = vectorFeatures.isUnverifiedSessionsAlertEnabled() + val unverifiedSessionsAlertLastShownMillis = vectorPreferences.getUnverifiedSessionsAlertLastShownMillis(deviceId) + return isUnverifiedSessionsAlertEnabled && + clock.epochMillis() - unverifiedSessionsAlertLastShownMillis >= Config.SHOW_UNVERIFIED_SESSIONS_ALERT_AFTER_MILLIS + } +} diff --git a/vector/src/main/java/im/vector/app/features/home/UnknownDeviceDetectorSharedViewModel.kt b/vector/src/main/java/im/vector/app/features/home/UnknownDeviceDetectorSharedViewModel.kt index 21c7b48dcb..f4fbf272b9 100644 --- a/vector/src/main/java/im/vector/app/features/home/UnknownDeviceDetectorSharedViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/home/UnknownDeviceDetectorSharedViewModel.kt @@ -19,7 +19,6 @@ package im.vector.app.features.home import com.airbnb.mvrx.Async import com.airbnb.mvrx.MavericksState import com.airbnb.mvrx.MavericksViewModelFactory -import com.airbnb.mvrx.Success import com.airbnb.mvrx.Uninitialized import com.airbnb.mvrx.ViewModelContext import dagger.assisted.Assisted @@ -32,13 +31,14 @@ import im.vector.app.core.di.hiltMavericksViewModelFactory import im.vector.app.core.platform.EmptyViewEvents import im.vector.app.core.platform.VectorViewModel import im.vector.app.core.platform.VectorViewModelAction +import im.vector.app.core.session.clientinfo.DeleteUnusedClientInformationUseCase import im.vector.app.core.time.Clock -import im.vector.app.features.settings.VectorPreferences import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.sample +import kotlinx.coroutines.launch import org.matrix.android.sdk.api.NoOpMatrixCallback import org.matrix.android.sdk.api.extensions.orFalse import org.matrix.android.sdk.api.session.Session @@ -63,12 +63,17 @@ data class DeviceDetectionInfo( class UnknownDeviceDetectorSharedViewModel @AssistedInject constructor( @Assisted initialState: UnknownDevicesState, session: Session, - private val vectorPreferences: VectorPreferences, clock: Clock, + private val shouldShowUnverifiedSessionsAlertUseCase: ShouldShowUnverifiedSessionsAlertUseCase, + private val setUnverifiedSessionsAlertShownUseCase: SetUnverifiedSessionsAlertShownUseCase, + private val isNewLoginAlertShownUseCase: IsNewLoginAlertShownUseCase, + private val setNewLoginAlertShownUseCase: SetNewLoginAlertShownUseCase, + private val deleteUnusedClientInformationUseCase: DeleteUnusedClientInformationUseCase, ) : VectorViewModel(initialState) { sealed class Action : VectorViewModelAction { data class IgnoreDevice(val deviceIds: List) : Action() + data class IgnoreNewLogin(val deviceIds: List) : Action() } @AssistedFactory @@ -86,8 +91,6 @@ class UnknownDeviceDetectorSharedViewModel @AssistedInject constructor( } } - private val ignoredDeviceList = ArrayList() - init { val currentSessionTs = session.cryptoService().getCryptoDeviceInfo(session.myUserId) .firstOrNull { it.deviceId == session.sessionParams.deviceId } @@ -95,12 +98,6 @@ class UnknownDeviceDetectorSharedViewModel @AssistedInject constructor( ?: clock.epochMillis() Timber.v("## Detector - Current Session first time seen $currentSessionTs") - ignoredDeviceList.addAll( - vectorPreferences.getUnknownDeviceDismissedList().also { - Timber.v("## Detector - Remembered ignored list $it") - } - ) - combine( session.flow().liveUserCryptoDevices(session.myUserId), session.flow().liveMyDevicesInfo(), @@ -109,19 +106,24 @@ class UnknownDeviceDetectorSharedViewModel @AssistedInject constructor( ) { cryptoList, infoList, pInfo, xInfo -> // Timber.v("## Detector trigger ${cryptoList.map { "${it.deviceId} ${it.trustLevel}" }}") // Timber.v("## Detector trigger canCrossSign ${pInfo.get().selfSigned != null}") + + deleteUnusedClientInformation(infoList) + infoList .filter { info -> - // filter verified session, by checking the crypto device info + // filter out verified sessions or those which do not support encryption (i.e. without crypto info) cryptoList.firstOrNull { info.deviceId == it.deviceId }?.isVerified?.not().orFalse() } // filter out ignored devices - .filter { !ignoredDeviceList.contains(it.deviceId) } + .filter { shouldShowUnverifiedSessionsAlertUseCase.execute(it.deviceId) } .sortedByDescending { it.lastSeenTs } .map { deviceInfo -> val deviceKnownSince = cryptoList.firstOrNull { it.deviceId == deviceInfo.deviceId }?.firstTimeSeenLocalTs ?: 0 + val isNew = isNewLoginAlertShownUseCase.execute(deviceInfo.deviceId).not() && deviceKnownSince > currentSessionTs + DeviceDetectionInfo( deviceInfo, - deviceKnownSince > currentSessionTs + 60_000, // short window to avoid false positive, + isNew, xInfo.getOrNull() == null || pInfo.getOrNull()?.selfSigned != null // adding this to pass distinct when cross sign change ) } @@ -148,25 +150,20 @@ class UnknownDeviceDetectorSharedViewModel @AssistedInject constructor( session.cryptoService().fetchDevicesList(NoOpMatrixCallback()) } + private fun deleteUnusedClientInformation(deviceFullInfoList: List) { + viewModelScope.launch { + deleteUnusedClientInformationUseCase.execute(deviceFullInfoList) + } + } + override fun handle(action: Action) { when (action) { is Action.IgnoreDevice -> { - ignoredDeviceList.addAll(action.deviceIds) - // local echo - withState { state -> - state.unknownSessions.invoke()?.let { detectedSessions -> - val updated = detectedSessions.filter { !action.deviceIds.contains(it.deviceInfo.deviceId) } - setState { - copy(unknownSessions = Success(updated)) - } - } - } + setUnverifiedSessionsAlertShownUseCase.execute(action.deviceIds) + } + is Action.IgnoreNewLogin -> { + setNewLoginAlertShownUseCase.execute(action.deviceIds) } } } - - override fun onCleared() { - vectorPreferences.storeUnknownDeviceDismissedList(ignoredDeviceList) - super.onCleared() - } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailAction.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailAction.kt index f773671694..9fe296e1f9 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailAction.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailAction.kt @@ -20,6 +20,7 @@ import android.net.Uri import android.view.View import im.vector.app.core.platform.VectorViewModelAction import im.vector.app.features.call.conference.ConferenceEvent +import im.vector.app.features.voicebroadcast.model.VoiceBroadcast import org.matrix.android.sdk.api.session.content.ContentAttachmentData import org.matrix.android.sdk.api.session.room.model.message.MessageStickerContent import org.matrix.android.sdk.api.session.room.model.message.MessageWithAttachmentContent @@ -126,12 +127,14 @@ sealed class RoomDetailAction : VectorViewModelAction { object Pause : Recording() object Resume : Recording() object Stop : Recording() + object StopConfirmed : Recording() } sealed class Listening : VoiceBroadcastAction() { - data class PlayOrResume(val eventId: String) : Listening() + data class PlayOrResume(val voiceBroadcast: VoiceBroadcast) : Listening() object Pause : Listening() object Stop : Listening() + data class SeekTo(val voiceBroadcast: VoiceBroadcast, val positionMillis: Int, val duration: Int) : Listening() } } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailActivity.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailActivity.kt index ecbea133df..2ed3bf8614 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailActivity.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailActivity.kt @@ -18,10 +18,12 @@ package im.vector.app.features.home.room.detail import android.content.Context import android.content.Intent +import android.graphics.Color import android.os.Bundle import android.view.View import android.widget.Toast import androidx.core.view.GravityCompat +import androidx.core.view.WindowCompat import androidx.drawerlayout.widget.DrawerLayout import androidx.fragment.app.Fragment import androidx.fragment.app.FragmentManager @@ -98,6 +100,11 @@ class RoomDetailActivity : override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) + + // For dealing with insets and status bar background color + WindowCompat.setDecorFitsSystemWindows(window, false) + window.statusBarColor = Color.TRANSPARENT + supportFragmentManager.registerFragmentLifecycleCallbacks(fragmentLifecycleCallbacks, false) waitingView = views.waitingView.waitingView val timelineArgs: TimelineArgs = intent?.extras?.getParcelableCompat(EXTRA_ROOM_DETAIL_ARGS) ?: return diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewEvents.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewEvents.kt index 399d5e0abe..77dd826cfe 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewEvents.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/RoomDetailViewEvents.kt @@ -71,6 +71,8 @@ sealed class RoomDetailViewEvents : VectorViewEvents { object DisplayEnableIntegrationsWarning : RoomDetailViewEvents() + object DisplayPromptToStopVoiceBroadcast : RoomDetailViewEvents() + data class OpenStickerPicker(val widget: Widget) : RoomDetailViewEvents() object OpenIntegrationManager : RoomDetailViewEvents() diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt index 6d43fd05f1..929ad0b36f 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt @@ -32,6 +32,8 @@ import android.view.ViewGroup import android.widget.FrameLayout import android.widget.ImageView import android.widget.TextView +import androidx.activity.addCallback +import androidx.annotation.StringRes import androidx.appcompat.view.menu.MenuBuilder import androidx.core.content.ContextCompat import androidx.core.graphics.drawable.DrawableCompat @@ -39,9 +41,11 @@ import androidx.core.net.toUri import androidx.core.text.toSpannable import androidx.core.util.Pair import androidx.core.view.ViewCompat +import androidx.core.view.WindowInsetsCompat import androidx.core.view.forEach import androidx.core.view.isInvisible import androidx.core.view.isVisible +import androidx.core.view.updatePadding import androidx.fragment.app.setFragmentResultListener import androidx.lifecycle.lifecycleScope import androidx.recyclerview.widget.ItemTouchHelper @@ -337,6 +341,7 @@ class TimelineFragment : setupJumpToBottomView() setupRemoveJitsiWidgetView() setupLiveLocationIndicator() + setupBackPressHandling() views.includeRoomToolbar.roomToolbarContentView.debouncedClicks { navigator.openRoomProfile(requireActivity(), timelineArgs.roomId) @@ -408,11 +413,29 @@ class TimelineFragment : is RoomDetailViewEvents.DisplayAndAcceptCall -> acceptIncomingCall(it) RoomDetailViewEvents.RoomReplacementStarted -> handleRoomReplacement() RoomDetailViewEvents.OpenElementCallWidget -> handleOpenElementCallWidget() + RoomDetailViewEvents.DisplayPromptToStopVoiceBroadcast -> displayPromptToStopVoiceBroadcast() } } - if (savedInstanceState == null) { - handleSpaceShare() + ViewCompat.setOnApplyWindowInsetsListener(views.coordinatorLayout) { _, insets -> + val imeInsets = insets.getInsets(WindowInsetsCompat.Type.ime() or WindowInsetsCompat.Type.systemBars()) + views.appBarLayout.updatePadding(top = imeInsets.top) + views.voiceMessageRecorderContainer.updatePadding(bottom = imeInsets.bottom) + insets + } + } + + private fun setupBackPressHandling() { + requireActivity().onBackPressedDispatcher.addCallback(viewLifecycleOwner) { + withState(messageComposerViewModel) { state -> + if (state.isFullScreen) { + messageComposerViewModel.handle(MessageComposerAction.SetFullScreen(false)) + } else { + remove() // Remove callback to avoid infinite loop + @Suppress("DEPRECATION") + requireActivity().onBackPressed() + } + } } } @@ -950,8 +973,10 @@ class TimelineFragment : override fun onResume() { super.onResume() notificationDrawerManager.setCurrentRoom(timelineArgs.roomId) + notificationDrawerManager.setCurrentThread(timelineArgs.threadTimelineArgs?.rootThreadEventId) roomDetailPendingActionStore.data?.let { handlePendingAction(it) } roomDetailPendingActionStore.data = null + views.timelineRecyclerView.adapter = timelineEventController.adapter } private fun handlePendingAction(roomDetailPendingAction: RoomDetailPendingAction) { @@ -969,6 +994,8 @@ class TimelineFragment : override fun onPause() { super.onPause() notificationDrawerManager.setCurrentRoom(null) + notificationDrawerManager.setCurrentThread(null) + views.timelineRecyclerView.adapter = null } private val emojiActivityResultLauncher = registerStartForActivityResult { activityResult -> @@ -1034,7 +1061,6 @@ class TimelineFragment : it.dispatchTo(scrollOnHighlightedEventCallback) } timelineEventController.addModelBuildListener(modelBuildListener) - views.timelineRecyclerView.adapter = timelineEventController.adapter if (vectorPreferences.swipeToReplyIsEnabled()) { val quickReplyHandler = object : RoomMessageTouchHelperCallback.QuickReplayHandler { @@ -1116,7 +1142,6 @@ class TimelineFragment : } val summary = mainState.asyncRoomSummary() renderToolbar(summary) - renderTypingMessageNotification(summary, mainState) views.removeJitsiWidgetView.render(mainState) if (mainState.hasFailedSending) { lazyLoadedViews.failedMessagesWarningView(inflateIfNeeded = true, createFailedMessagesWarningCallback())?.isVisible = true @@ -1131,6 +1156,8 @@ class TimelineFragment : lazyLoadedViews.inviteView(false)?.isVisible = false if (mainState.tombstoneEvent == null) { + views.composerContainer.isInvisible = !messageComposerState.isComposerVisible + views.voiceMessageRecorderContainer.isVisible = messageComposerState.isVoiceMessageRecorderVisible when (messageComposerState.canSendMessage) { CanSendStatus.Allowed -> { NotificationAreaView.State.Hidden @@ -1189,17 +1216,7 @@ class TimelineFragment : private fun FragmentTimelineBinding.hideComposerViews() { composerContainer.isVisible = false - } - - private fun renderTypingMessageNotification(roomSummary: RoomSummary?, state: RoomDetailViewState) { - if (!isThreadTimeLine() && roomSummary != null) { - views.typingMessageView.isInvisible = state.typingUsers.isNullOrEmpty() - state.typingUsers - ?.take(MAX_TYPING_MESSAGE_USERS_COUNT) - ?.let { senders -> views.typingMessageView.render(senders, avatarRenderer) } - } else { - views.typingMessageView.isInvisible = true - } + voiceMessageRecorderContainer.isVisible = false } private fun renderToolbar(roomSummary: RoomSummary?) { @@ -1286,8 +1303,12 @@ class TimelineFragment : } private fun displayRoomDetailActionFailure(result: RoomDetailViewEvents.ActionFailure) { + @StringRes val titleResId = when (result.action) { + RoomDetailAction.VoiceBroadcastAction.Recording.Start -> R.string.error_voice_broadcast_unauthorized_title + else -> R.string.dialog_title_error + } MaterialAlertDialogBuilder(requireActivity()) - .setTitle(R.string.dialog_title_error) + .setTitle(titleResId) .setMessage(errorFormatter.toHumanReadable(result.throwable)) .setPositiveButton(R.string.ok, null) .show() @@ -1763,7 +1784,7 @@ class TimelineFragment : timelineViewModel.handle(RoomDetailAction.UpdateQuickReactAction(action.eventId, action.clickedOn, action.add)) } is EventSharedAction.Edit -> { - if (action.eventType in EventType.POLL_START) { + if (action.eventType in EventType.POLL_START.values) { navigator.openCreatePoll(requireContext(), timelineArgs.roomId, action.eventId, PollMode.EDIT) } else if (withState(messageComposerViewModel) { it.isVoiceMessageIdle }) { messageComposerViewModel.handle(MessageComposerAction.EnterEditMode(action.eventId)) @@ -1992,6 +2013,20 @@ class TimelineFragment : } } + private fun displayPromptToStopVoiceBroadcast() { + ConfirmationDialogBuilder + .show( + activity = requireActivity(), + askForReason = false, + confirmationRes = R.string.stop_voice_broadcast_content, + positiveRes = R.string.action_stop, + reasonHintRes = 0, + titleRes = R.string.stop_voice_broadcast_dialog_title + ) { + timelineViewModel.handle(RoomDetailAction.VoiceBroadcastAction.Recording.StopConfirmed) + } + } + override fun onTapToReturnToCall() { callManager.getCurrentCall()?.let { call -> VectorCallActivity.newIntent( diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineViewModel.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineViewModel.kt index 84e325bb77..c6fed56baa 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineViewModel.kt @@ -28,7 +28,6 @@ import com.airbnb.mvrx.Uninitialized import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject -import im.vector.app.BuildConfig import im.vector.app.R import im.vector.app.SpaceStateHandler import im.vector.app.core.di.MavericksAssistedViewModelFactory @@ -38,6 +37,7 @@ import im.vector.app.core.platform.VectorViewModel import im.vector.app.core.resources.BuildMeta import im.vector.app.core.resources.StringProvider import im.vector.app.core.utils.BehaviorDataSource +import im.vector.app.features.VectorFeatures import im.vector.app.features.analytics.AnalyticsTracker import im.vector.app.features.analytics.DecryptionFailureTracker import im.vector.app.features.analytics.extensions.toAnalyticsJoinedRoom @@ -51,6 +51,7 @@ import im.vector.app.features.call.webrtc.WebRtcCallManager import im.vector.app.features.createdirect.DirectRoomHelper import im.vector.app.features.crypto.keysrequest.OutboundSessionKeySharingStrategy import im.vector.app.features.crypto.verification.SupportedVerificationMethodsProvider +import im.vector.app.features.home.room.detail.RoomDetailAction.VoiceBroadcastAction import im.vector.app.features.home.room.detail.error.RoomNotFound import im.vector.app.features.home.room.detail.location.RedactLiveLocationShareEventUseCase import im.vector.app.features.home.room.detail.sticker.StickerPickerActionHandler @@ -129,6 +130,7 @@ import java.util.concurrent.atomic.AtomicBoolean class TimelineViewModel @AssistedInject constructor( @Assisted private val initialState: RoomDetailViewState, + private val vectorFeatures: VectorFeatures, private val vectorPreferences: VectorPreferences, private val vectorDataStore: VectorDataStore, private val stringProvider: StringProvider, @@ -217,7 +219,7 @@ class TimelineViewModel @AssistedInject constructor( observePowerLevel() setupPreviewUrlObservers() viewModelScope.launch(Dispatchers.IO) { - tryOrNull { room.readService().markAsRead(ReadService.MarkAsReadParams.READ_RECEIPT) } + tryOrNull { room.readService().markAsRead(ReadService.MarkAsReadParams.READ_RECEIPT, mainTimeLineOnly = true) } } // Inform the SDK that the room is displayed viewModelScope.launch(Dispatchers.IO) { @@ -479,7 +481,7 @@ class TimelineViewModel @AssistedInject constructor( is RoomDetailAction.ReRequestKeys -> handleReRequestKeys(action) is RoomDetailAction.TapOnFailedToDecrypt -> handleTapOnFailedToDecrypt(action) is RoomDetailAction.SelectStickerAttachment -> handleSelectStickerAttachment() - is RoomDetailAction.VoiceBroadcastAction -> handleVoiceBroadcastAction(action) + is VoiceBroadcastAction -> handleVoiceBroadcastAction(action) is RoomDetailAction.OpenIntegrationManager -> handleOpenIntegrationManager() is RoomDetailAction.StartCall -> handleStartCall(action) is RoomDetailAction.AcceptCall -> handleAcceptCall(action) @@ -621,17 +623,24 @@ class TimelineViewModel @AssistedInject constructor( } } - private fun handleVoiceBroadcastAction(action: RoomDetailAction.VoiceBroadcastAction) { + private fun handleVoiceBroadcastAction(action: VoiceBroadcastAction) { if (room == null) return viewModelScope.launch { when (action) { - RoomDetailAction.VoiceBroadcastAction.Recording.Start -> voiceBroadcastHelper.startVoiceBroadcast(room.roomId) - RoomDetailAction.VoiceBroadcastAction.Recording.Pause -> voiceBroadcastHelper.pauseVoiceBroadcast(room.roomId) - RoomDetailAction.VoiceBroadcastAction.Recording.Resume -> voiceBroadcastHelper.resumeVoiceBroadcast(room.roomId) - RoomDetailAction.VoiceBroadcastAction.Recording.Stop -> voiceBroadcastHelper.stopVoiceBroadcast(room.roomId) - is RoomDetailAction.VoiceBroadcastAction.Listening.PlayOrResume -> voiceBroadcastHelper.playOrResumePlayback(room.roomId, action.eventId) - RoomDetailAction.VoiceBroadcastAction.Listening.Pause -> voiceBroadcastHelper.pausePlayback() - RoomDetailAction.VoiceBroadcastAction.Listening.Stop -> voiceBroadcastHelper.stopPlayback() + VoiceBroadcastAction.Recording.Start -> { + voiceBroadcastHelper.startVoiceBroadcast(room.roomId).fold( + { _viewEvents.post(RoomDetailViewEvents.ActionSuccess(action)) }, + { _viewEvents.post(RoomDetailViewEvents.ActionFailure(action, it)) }, + ) + } + VoiceBroadcastAction.Recording.Pause -> voiceBroadcastHelper.pauseVoiceBroadcast(room.roomId) + VoiceBroadcastAction.Recording.Resume -> voiceBroadcastHelper.resumeVoiceBroadcast(room.roomId) + VoiceBroadcastAction.Recording.Stop -> _viewEvents.post(RoomDetailViewEvents.DisplayPromptToStopVoiceBroadcast) + VoiceBroadcastAction.Recording.StopConfirmed -> voiceBroadcastHelper.stopVoiceBroadcast(room.roomId) + is VoiceBroadcastAction.Listening.PlayOrResume -> voiceBroadcastHelper.playOrResumePlayback(action.voiceBroadcast) + VoiceBroadcastAction.Listening.Pause -> voiceBroadcastHelper.pausePlayback() + VoiceBroadcastAction.Listening.Stop -> voiceBroadcastHelper.stopPlayback() + is VoiceBroadcastAction.Listening.SeekTo -> voiceBroadcastHelper.seekTo(action.voiceBroadcast, action.positionMillis, action.duration) } } } @@ -827,9 +836,9 @@ class TimelineViewModel @AssistedInject constructor( R.id.invite -> state.canInvite R.id.open_matrix_apps -> false // Tchap: there are no matrix apps // Tchap: check if voip is enabled - R.id.voice_call -> BuildConfig.IS_VOIP_SUPPORTED && (state.isCallOptionAvailable() || state.hasActiveElementCallWidget()) + R.id.voice_call -> vectorFeatures.tchapIsVoipSupported() && (state.isCallOptionAvailable() || state.hasActiveElementCallWidget()) // Tchap: check if voip is enabled - R.id.video_call -> BuildConfig.IS_VOIP_SUPPORTED && + R.id.video_call -> vectorFeatures.tchapIsVoipSupported() && (state.isCallOptionAvailable() || state.jitsiState.confId == null || state.jitsiState.hasJoined) // Show Join conference button only if there is an active conf id not joined. Otherwise fallback to default video disabled. ^ R.id.join_conference -> !state.isCallOptionAvailable() && state.jitsiState.confId != null && !state.jitsiState.hasJoined @@ -1104,7 +1113,8 @@ class TimelineViewModel @AssistedInject constructor( } bufferedMostRecentDisplayedEvent.root.eventId?.let { eventId -> session.coroutineScope.launch { - tryOrNull { room.readService().setReadReceipt(eventId) } + val threadId = initialState.rootThreadEventId ?: ReadService.THREAD_ID_MAIN + tryOrNull { room.readService().setReadReceipt(eventId, threadId = threadId) } } } } @@ -1122,7 +1132,7 @@ class TimelineViewModel @AssistedInject constructor( if (room == null) return setState { copy(unreadState = UnreadState.HasNoUnread) } viewModelScope.launch { - tryOrNull { room.readService().markAsRead(ReadService.MarkAsReadParams.BOTH) } + tryOrNull { room.readService().markAsRead(ReadService.MarkAsReadParams.BOTH, mainTimeLineOnly = true) } } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/AudioMessageHelper.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/AudioMessageHelper.kt index bede02c17f..900de041d0 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/AudioMessageHelper.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/AudioMessageHelper.kt @@ -66,7 +66,7 @@ class AudioMessageHelper @Inject constructor( fun startRecording(roomId: String) { stopPlayback() - playbackTracker.makeAllPlaybacksIdle() + playbackTracker.pauseAllPlaybacks() amplitudeList.clear() try { @@ -149,7 +149,7 @@ class AudioMessageHelper @Inject constructor( } private fun startPlayback(id: String, file: File) { - val currentPlaybackTime = playbackTracker.getPlaybackTime(id) + val currentPlaybackTime = playbackTracker.getPlaybackTime(id) ?: 0 try { FileInputStream(file).use { fis -> @@ -199,11 +199,7 @@ class AudioMessageHelper @Inject constructor( private fun startRecordingAmplitudes() { amplitudeTicker?.stop() amplitudeTicker = CountUpTimer(50).apply { - tickListener = object : CountUpTimer.TickListener { - override fun onTick(milliseconds: Long) { - onAmplitudeTick() - } - } + tickListener = CountUpTimer.TickListener { onAmplitudeTick() } resume() } } @@ -234,11 +230,7 @@ class AudioMessageHelper @Inject constructor( private fun startPlaybackTicker(id: String) { playbackTicker?.stop() playbackTicker = CountUpTimer().apply { - tickListener = object : CountUpTimer.TickListener { - override fun onTick(milliseconds: Long) { - onPlaybackTick(id) - } - } + tickListener = CountUpTimer.TickListener { onPlaybackTick(id) } resume() } onPlaybackTick(id) @@ -261,8 +253,8 @@ class AudioMessageHelper @Inject constructor( playbackTicker = null } - fun clearTracker() { - playbackTracker.clear() + fun stopTracking() { + playbackTracker.unregisterListeners() } fun stopAllVoiceActions(deleteRecord: Boolean = true): MultiPickerAudioType? { diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerAction.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerAction.kt index 82adcd014a..ffaaa235cf 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerAction.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerAction.kt @@ -33,6 +33,7 @@ sealed class MessageComposerAction : VectorViewModelAction { data class OnEntersBackground(val composerText: String) : MessageComposerAction() data class SlashCommandConfirmed(val parsedCommand: ParsedCommand) : MessageComposerAction() data class InsertUserDisplayName(val userId: String) : MessageComposerAction() + data class SetFullScreen(val isFullScreen: Boolean) : MessageComposerAction() // Voice Message data class InitializeVoiceRecorder(val attachmentData: ContentAttachmentData) : MessageComposerAction() diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerFragment.kt index d1b1e7c3fd..d7fb112d8d 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerFragment.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerFragment.kt @@ -24,7 +24,6 @@ import android.net.Uri import android.os.Build import android.os.Bundle import android.text.Spannable -import android.text.format.DateUtils import android.view.KeyEvent import android.view.LayoutInflater import android.view.View @@ -32,10 +31,7 @@ import android.view.ViewGroup import android.view.inputmethod.EditorInfo import android.widget.EditText import android.widget.Toast -import androidx.annotation.DrawableRes import androidx.annotation.RequiresApi -import androidx.annotation.StringRes -import androidx.core.content.ContextCompat import androidx.core.text.buildSpannedString import androidx.core.view.isGone import androidx.core.view.isInvisible @@ -43,6 +39,7 @@ import androidx.core.view.isVisible import androidx.fragment.app.viewModels import androidx.lifecycle.Lifecycle import androidx.lifecycle.lifecycleScope +import com.airbnb.mvrx.fragmentViewModel import com.airbnb.mvrx.parentFragmentViewModel import com.airbnb.mvrx.withState import com.google.android.material.dialog.MaterialAlertDialogBuilder @@ -51,7 +48,6 @@ import dagger.hilt.android.AndroidEntryPoint import im.vector.app.R import im.vector.app.config.Config import im.vector.app.core.error.fatalError -import im.vector.app.core.extensions.getVectorLastMessageContent import im.vector.app.core.extensions.registerStartForActivityResult import im.vector.app.core.extensions.showKeyboard import im.vector.app.core.glide.GlideApp @@ -59,17 +55,19 @@ import im.vector.app.core.platform.VectorBaseFragment import im.vector.app.core.platform.lifecycleAwareLazy import im.vector.app.core.platform.showOptimizedSnackbar import im.vector.app.core.resources.BuildMeta -import im.vector.app.core.utils.DimensionConverter +import im.vector.app.core.utils.ExpandingBottomSheetBehavior import im.vector.app.core.utils.checkPermissions import im.vector.app.core.utils.onPermissionDeniedDialog import im.vector.app.core.utils.registerForPermissionsResult import im.vector.app.databinding.FragmentComposerBinding import im.vector.app.features.VectorFeatures +import im.vector.app.features.analytics.errors.ErrorTracker import im.vector.app.features.attachments.AttachmentType import im.vector.app.features.attachments.AttachmentTypeSelectorBottomSheet import im.vector.app.features.attachments.AttachmentTypeSelectorSharedAction import im.vector.app.features.attachments.AttachmentTypeSelectorSharedActionViewModel import im.vector.app.features.attachments.AttachmentTypeSelectorView +import im.vector.app.features.attachments.AttachmentTypeSelectorViewModel import im.vector.app.features.attachments.AttachmentsHelper import im.vector.app.features.attachments.ContactAttachment import im.vector.app.features.attachments.ShareIntentHandler @@ -83,37 +81,27 @@ import im.vector.app.features.home.room.detail.AutoCompleter import im.vector.app.features.home.room.detail.RoomDetailAction import im.vector.app.features.home.room.detail.RoomDetailAction.VoiceBroadcastAction import im.vector.app.features.home.room.detail.TimelineViewModel +import im.vector.app.features.home.room.detail.composer.link.SetLinkFragment +import im.vector.app.features.home.room.detail.composer.link.SetLinkSharedAction +import im.vector.app.features.home.room.detail.composer.link.SetLinkSharedActionViewModel import im.vector.app.features.home.room.detail.composer.voice.VoiceMessageRecorderView import im.vector.app.features.home.room.detail.timeline.action.MessageSharedActionViewModel -import im.vector.app.features.home.room.detail.timeline.helper.MatrixItemColorProvider -import im.vector.app.features.home.room.detail.timeline.image.buildImageContentRendererData import im.vector.app.features.home.room.detail.upgrade.MigrateRoomBottomSheet -import im.vector.app.features.html.EventHtmlRenderer import im.vector.app.features.html.PillImageSpan -import im.vector.app.features.html.PillsPostProcessor import im.vector.app.features.location.LocationSharingMode -import im.vector.app.features.media.ImageContentRenderer import im.vector.app.features.poll.PollMode import im.vector.app.features.settings.VectorPreferences import im.vector.app.features.share.SharedData import im.vector.app.features.voice.VoiceFailure import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach -import org.commonmark.parser.Parser import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.content.ContentAttachmentData -import org.matrix.android.sdk.api.session.room.model.message.MessageAudioContent -import org.matrix.android.sdk.api.session.room.model.message.MessageBeaconInfoContent -import org.matrix.android.sdk.api.session.room.model.message.MessageContent -import org.matrix.android.sdk.api.session.room.model.message.MessageFormat -import org.matrix.android.sdk.api.session.room.model.message.MessagePollContent -import org.matrix.android.sdk.api.session.room.model.message.MessageTextContent -import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent import org.matrix.android.sdk.api.util.MatrixItem -import org.matrix.android.sdk.api.util.toMatrixItem import reactivecircus.flowbinding.android.view.focusChanges import reactivecircus.flowbinding.android.widget.textChanges import timber.log.Timber @@ -128,16 +116,12 @@ class MessageComposerFragment : VectorBaseFragment(), A @Inject lateinit var autoCompleterFactory: AutoCompleter.Factory @Inject lateinit var avatarRenderer: AvatarRenderer - @Inject lateinit var matrixItemColorProvider: MatrixItemColorProvider - @Inject lateinit var eventHtmlRenderer: EventHtmlRenderer - @Inject lateinit var dimensionConverter: DimensionConverter - @Inject lateinit var imageContentRenderer: ImageContentRenderer @Inject lateinit var shareIntentHandler: ShareIntentHandler - @Inject lateinit var pillsPostProcessorFactory: PillsPostProcessor.Factory @Inject lateinit var vectorPreferences: VectorPreferences @Inject lateinit var vectorFeatures: VectorFeatures @Inject lateinit var buildMeta: BuildMeta @Inject lateinit var session: Session + @Inject lateinit var errorTracker: ErrorTracker private val roomId: String get() = withState(timelineViewModel) { it.roomId } @@ -145,10 +129,6 @@ class MessageComposerFragment : VectorBaseFragment(), A autoCompleterFactory.create(roomId, isThreadTimeLine()) } - private val pillsPostProcessor by lazy { - pillsPostProcessorFactory.create(roomId) - } - private val emojiPopup: EmojiPopup by lifecycleAwareLazy { createEmojiPopup() } @@ -164,11 +144,14 @@ class MessageComposerFragment : VectorBaseFragment(), A private lateinit var attachmentsHelper: AttachmentsHelper private lateinit var attachmentTypeSelector: AttachmentTypeSelectorView + private var bottomSheetBehavior: ExpandingBottomSheetBehavior? = null private val timelineViewModel: TimelineViewModel by parentFragmentViewModel() private val messageComposerViewModel: MessageComposerViewModel by parentFragmentViewModel() private lateinit var sharedActionViewModel: MessageSharedActionViewModel - private val attachmentViewModel: AttachmentTypeSelectorSharedActionViewModel by viewModels() + private val attachmentViewModel: AttachmentTypeSelectorViewModel by fragmentViewModel() + private val attachmentActionsViewModel: AttachmentTypeSelectorSharedActionViewModel by viewModels() + private val setLinkActionsViewModel: SetLinkSharedActionViewModel by viewModels() private val composer: MessageComposerView get() { @@ -190,11 +173,13 @@ class MessageComposerFragment : VectorBaseFragment(), A attachmentsHelper = AttachmentsHelper(requireContext(), this, buildMeta).register() + setupBottomSheet() setupComposer() setupEmojiButton() views.composerLayout.isGone = vectorPreferences.isRichTextEditorEnabled() views.richTextComposerLayout.isVisible = vectorPreferences.isRichTextEditorEnabled() + views.richTextComposerLayout.setOnErrorListener(errorTracker::trackError) messageComposerViewModel.observeViewEvents { when (it) { @@ -221,18 +206,34 @@ class MessageComposerFragment : VectorBaseFragment(), A } when (mode) { is SendMode.Regular -> renderRegularMode(mode.text.toString()) - is SendMode.Edit -> renderSpecialMode(mode.timelineEvent, R.drawable.ic_edit, R.string.edit, mode.text.toString()) - is SendMode.Quote -> renderSpecialMode(mode.timelineEvent, R.drawable.ic_quote, R.string.action_quote, mode.text.toString()) - is SendMode.Reply -> renderSpecialMode(mode.timelineEvent, R.drawable.ic_reply, R.string.reply, mode.text.toString()) + is SendMode.Edit -> renderSpecialMode(MessageComposerMode.Edit(mode.timelineEvent, mode.text.toString())) + is SendMode.Quote -> renderSpecialMode(MessageComposerMode.Quote(mode.timelineEvent, mode.text.toString())) + is SendMode.Reply -> renderSpecialMode(MessageComposerMode.Reply(mode.timelineEvent, mode.text.toString())) is SendMode.Voice -> renderVoiceMessageMode(mode.text) } } - attachmentViewModel.stream() + attachmentActionsViewModel.stream() .filterIsInstance() .onEach { onTypeSelected(it.attachmentType) } .launchIn(lifecycleScope) + setLinkActionsViewModel.stream() + .onEach { when (it) { + is SetLinkSharedAction.Insert -> views.richTextComposerLayout.insertLink(it.link, it.text) + is SetLinkSharedAction.Set -> views.richTextComposerLayout.setLink(it.link) + SetLinkSharedAction.Remove -> views.richTextComposerLayout.removeLink() + } } + .launchIn(lifecycleScope) + + messageComposerViewModel.stateFlow.map { it.isFullScreen } + .distinctUntilChanged() + .onEach { isFullScreen -> + val state = if (isFullScreen) ExpandingBottomSheetBehavior.State.Expanded else ExpandingBottomSheetBehavior.State.Collapsed + bottomSheetBehavior?.setState(state) + } + .launchIn(viewLifecycleOwner.lifecycleScope) + if (savedInstanceState == null) { handleShareData() } @@ -248,7 +249,7 @@ class MessageComposerFragment : VectorBaseFragment(), A } // TODO remove this when there will be a recording indicator outside of the timeline // Pause voice broadcast if the timeline is not shown anymore - it.isVoiceBroadcasting && !requireActivity().isChangingConfigurations -> timelineViewModel.handle(VoiceBroadcastAction.Recording.Pause) + it.isRecordingVoiceBroadcast && !requireActivity().isChangingConfigurations -> timelineViewModel.handle(VoiceBroadcastAction.Recording.Pause) else -> { timelineViewModel.handle(VoiceBroadcastAction.Listening.Pause) messageComposerViewModel.handle(MessageComposerAction.OnEntersBackground(composer.text.toString())) @@ -266,15 +267,52 @@ class MessageComposerFragment : VectorBaseFragment(), A messageComposerViewModel.endAllVoiceActions() } - override fun invalidate() = withState(timelineViewModel, messageComposerViewModel) { mainState, messageComposerState -> + override fun invalidate() = withState( + timelineViewModel, messageComposerViewModel, attachmentViewModel + ) { mainState, messageComposerState, attachmentState -> if (mainState.tombstoneEvent != null) return@withState - composer.setInvisible(!messageComposerState.isComposerVisible) + (composer as? View)?.isVisible = messageComposerState.isComposerVisible if (Config.SHOW_VOICE_RECORDER) { composer.sendButton.isInvisible = !messageComposerState.isSendButtonVisible } else { // Tchap: set visibility to gone if there is no voice recorder button composer.sendButton.isGone = !messageComposerState.isSendButtonVisible + (composer as? RichTextComposerLayout)?.isTextFormattingEnabled = attachmentState.isTextFormattingEnabled + } + } + + private fun setupBottomSheet() { + val parentView = view?.parent as? View ?: return + bottomSheetBehavior = ExpandingBottomSheetBehavior.from(parentView)?.apply { + applyInsetsToContentViewWhenCollapsed = true + topOffset = 22 + useScrimView = true + scrimViewTranslationZ = 8 + minCollapsedHeight = { + (composer as? RichTextComposerLayout)?.estimateCollapsedHeight() ?: -1 + } + isDraggable = false + callback = object : ExpandingBottomSheetBehavior.Callback { + override fun onStateChanged(state: ExpandingBottomSheetBehavior.State) { + // Dragging is disabled while the composer is collapsed + bottomSheetBehavior?.isDraggable = state != ExpandingBottomSheetBehavior.State.Collapsed + + val setFullScreen = when (state) { + ExpandingBottomSheetBehavior.State.Collapsed -> false + ExpandingBottomSheetBehavior.State.Expanded -> true + else -> return + } + + (composer as? RichTextComposerLayout)?.setFullScreen(setFullScreen, true) + + messageComposerViewModel.handle(MessageComposerAction.SetFullScreen(setFullScreen)) + } + + override fun onSlidePositionChanged(view: View, yPosition: Float) { + (composer as? RichTextComposerLayout)?.notifyIsBeingDragged(yPosition) + } + } } } @@ -316,7 +354,7 @@ class MessageComposerFragment : VectorBaseFragment(), A // Show keyboard when the user started a thread composerEditText.showKeyboard(andRequestFocus = true) } - composer.callback = object : PlainTextComposerLayout.Callback { + composer.callback = object : Callback { override fun onAddAttachment() { if (vectorPreferences.isRichTextEditorEnabled()) { AttachmentTypeSelectorBottomSheet.show(childFragmentManager) @@ -350,8 +388,12 @@ class MessageComposerFragment : VectorBaseFragment(), A composer.emojiButton?.isVisible = isEmojiKeyboardVisible } - override fun onSendMessage(text: CharSequence) { + override fun onSendMessage(text: CharSequence) = withState(messageComposerViewModel) { state -> sendTextMessage(text, composer.formattedText) + + if (state.isFullScreen) { + messageComposerViewModel.handle(MessageComposerAction.SetFullScreen(false)) + } } override fun onCloseRelatedMessage() { @@ -365,6 +407,14 @@ class MessageComposerFragment : VectorBaseFragment(), A override fun onTextChanged(text: CharSequence) { messageComposerViewModel.handle(MessageComposerAction.OnTextChanged(text)) } + + override fun onFullScreenModeChanged() = withState(messageComposerViewModel) { state -> + messageComposerViewModel.handle(MessageComposerAction.SetFullScreen(!state.isFullScreen)) + } + + override fun onSetLink(isTextSupported: Boolean, initialLink: String?) { + SetLinkFragment.show(isTextSupported, initialLink, childFragmentManager) + } } } @@ -374,8 +424,7 @@ class MessageComposerFragment : VectorBaseFragment(), A return } if (text.isNotBlank()) { - // We collapse ASAP, if not there will be a slight annoying delay - composer.collapse(true) + composer.renderComposerMode(MessageComposerMode.Normal("")) lockSendButton = true if (formattedText != null) { messageComposerViewModel.handle(MessageComposerAction.SendMessage(text, formattedText, false)) @@ -399,66 +448,12 @@ class MessageComposerFragment : VectorBaseFragment(), A private fun renderRegularMode(content: CharSequence) { autoCompleter.exitSpecialMode() - composer.collapse() - composer.setTextIfDifferent(content) - composer.sendButton.contentDescription = getString(R.string.action_send) + composer.renderComposerMode(MessageComposerMode.Normal(content)) } - private fun renderSpecialMode( - event: TimelineEvent, - @DrawableRes iconRes: Int, - @StringRes descriptionRes: Int, - defaultContent: CharSequence, - ) { + private fun renderSpecialMode(mode: MessageComposerMode.Special) { autoCompleter.enterSpecialMode() - // switch to expanded bar - composer.composerRelatedMessageTitle.apply { - text = event.senderInfo.disambiguatedDisplayName - setTextColor(matrixItemColorProvider.getColor(MatrixItem.UserItem(event.root.senderId ?: "@"))) - } - - val messageContent: MessageContent? = event.getVectorLastMessageContent() - val nonFormattedBody = when (messageContent) { - is MessageAudioContent -> getAudioContentBodyText(messageContent) - is MessagePollContent -> messageContent.getBestPollCreationInfo()?.question?.getBestQuestion() - is MessageBeaconInfoContent -> getString(R.string.live_location_description) - else -> messageContent?.body.orEmpty() - } - var formattedBody: CharSequence? = null - if (messageContent is MessageTextContent && messageContent.format == MessageFormat.FORMAT_MATRIX_HTML) { - val parser = Parser.builder().build() - val document = parser.parse(messageContent.formattedBody ?: messageContent.body) - formattedBody = eventHtmlRenderer.render(document, pillsPostProcessor) - } - composer.composerRelatedMessageContent.text = (formattedBody ?: nonFormattedBody) - - // Image Event - val data = event.buildImageContentRendererData(dimensionConverter.dpToPx(66)) - val isImageVisible = if (data != null) { - imageContentRenderer.render(data, ImageContentRenderer.Mode.THUMBNAIL, composer.composerRelatedMessageImage) - true - } else { - imageContentRenderer.clear(composer.composerRelatedMessageImage) - false - } - - composer.composerRelatedMessageImage.isVisible = isImageVisible - - composer.replaceFormattedContent(defaultContent) - - composer.composerRelatedMessageActionIcon.setImageDrawable(ContextCompat.getDrawable(requireContext(), iconRes)) - composer.sendButton.contentDescription = getString(descriptionRes) - - avatarRenderer.render(event.senderInfo.toMatrixItem(), composer.composerRelatedMessageAvatar) - - composer.expand { - if (isAdded) { - // need to do it here also when not using quick reply - focusComposerAndShowKeyboard() - composer.composerRelatedMessageImage.isVisible = isImageVisible - } - } - focusComposerAndShowKeyboard() + composer.renderComposerMode(mode) } private fun observerUserTyping() { @@ -481,7 +476,7 @@ class MessageComposerFragment : VectorBaseFragment(), A } private fun focusComposerAndShowKeyboard() { - if (composer.isVisible) { + if ((composer as? View)?.isVisible == true) { composer.editText.showKeyboard(andRequestFocus = true) } } @@ -502,15 +497,6 @@ class MessageComposerFragment : VectorBaseFragment(), A } } - private fun getAudioContentBodyText(messageContent: MessageAudioContent): String { - val formattedDuration = DateUtils.formatElapsedTime(((messageContent.audioInfo?.duration ?: 0) / 1000).toLong()) - return if (messageContent.voiceMessageIndicator != null) { - getString(R.string.voice_message_reply_content, formattedDuration) - } else { - getString(R.string.audio_message_reply_content, messageContent.body, formattedDuration) - } - } - private fun createEmojiPopup(): EmojiPopup { return EmojiPopup( rootView = views.root, @@ -832,11 +818,6 @@ class MessageComposerFragment : VectorBaseFragment(), A return displayName } - /** - * Returns the root thread event if we are in a thread room, otherwise returns null. - */ - fun getRootThreadEventId(): String? = withState(timelineViewModel) { it.rootThreadEventId } - /** * Returns true if the current room is a Thread room, false otherwise. */ diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerMode.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerMode.kt new file mode 100644 index 0000000000..89cb148639 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerMode.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2022 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 im.vector.app.features.home.room.detail.composer + +import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent + +sealed interface MessageComposerMode { + data class Normal(val content: CharSequence?) : MessageComposerMode + + sealed class Special(open val event: TimelineEvent, open val defaultContent: CharSequence) : MessageComposerMode + data class Edit(override val event: TimelineEvent, override val defaultContent: CharSequence) : Special(event, defaultContent) + data class Quote(override val event: TimelineEvent, override val defaultContent: CharSequence) : Special(event, defaultContent) + data class Reply(override val event: TimelineEvent, override val defaultContent: CharSequence) : Special(event, defaultContent) +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerView.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerView.kt index 09357191b4..b68f4046c8 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerView.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerView.kt @@ -19,31 +19,31 @@ package im.vector.app.features.home.room.detail.composer import android.text.Editable import android.widget.EditText import android.widget.ImageButton -import android.widget.ImageView -import android.widget.TextView interface MessageComposerView { + companion object { + const val MAX_LINES_WHEN_COLLAPSED = 10 + } + val text: Editable? val formattedText: String? val editText: EditText val emojiButton: ImageButton? val sendButton: ImageButton val attachmentButton: ImageButton - val composerRelatedMessageTitle: TextView - val composerRelatedMessageContent: TextView - val composerRelatedMessageImage: ImageView - val composerRelatedMessageActionIcon: ImageView - val composerRelatedMessageAvatar: ImageView - - var callback: PlainTextComposerLayout.Callback? - var isVisible: Boolean + var callback: Callback? - fun collapse(animate: Boolean = true, transitionComplete: (() -> Unit)? = null) - fun expand(animate: Boolean = true, transitionComplete: (() -> Unit)? = null) fun setTextIfDifferent(text: CharSequence?): Boolean - fun replaceFormattedContent(text: CharSequence) + fun renderComposerMode(mode: MessageComposerMode) +} - fun setInvisible(isInvisible: Boolean) +interface Callback : ComposerEditText.Callback { + fun onCloseRelatedMessage() + fun onSendMessage(text: CharSequence) + fun onAddAttachment() + fun onExpandOrCompactChange() + fun onFullScreenModeChanged() + fun onSetLink(isTextSupported: Boolean, initialLink: String?) } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewModel.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewModel.kt index 7ffdfbf803..a117001ec7 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewModel.kt @@ -16,6 +16,7 @@ package im.vector.app.features.home.room.detail.composer +import android.text.SpannableString import androidx.lifecycle.asFlow import com.airbnb.mvrx.MavericksViewModelFactory import dagger.assisted.Assisted @@ -60,6 +61,7 @@ import org.matrix.android.sdk.api.session.events.model.toContent import org.matrix.android.sdk.api.session.events.model.toModel import org.matrix.android.sdk.api.session.getRoom import org.matrix.android.sdk.api.session.getRoomSummary +import org.matrix.android.sdk.api.session.room.Room import org.matrix.android.sdk.api.session.room.getStateEvent import org.matrix.android.sdk.api.session.room.getTimelineEvent import org.matrix.android.sdk.api.session.room.members.roomMemberQueryParams @@ -92,40 +94,46 @@ class MessageComposerViewModel @AssistedInject constructor( private val voiceBroadcastHelper: VoiceBroadcastHelper, ) : VectorViewModel(initialState) { - private val room = session.getRoom(initialState.roomId)!! + private val room = session.getRoom(initialState.roomId) // Keep it out of state to avoid invalidate being called private var currentComposerText: CharSequence = "" init { - loadDraftIfAny() - observeCanSendMessageAndEncryption() - observeVoiceBroadcast() - subscribeToStateInternal() + if (room != null) { + loadDraftIfAny(room) + observeCanSendMessageAndEncryption(room) + observeVoiceBroadcast(room) + subscribeToStateInternal() + } else { + onRoomError() + } } override fun handle(action: MessageComposerAction) { + val room = this.room ?: return when (action) { - is MessageComposerAction.EnterEditMode -> handleEnterEditMode(action) - is MessageComposerAction.EnterQuoteMode -> handleEnterQuoteMode(action) + is MessageComposerAction.EnterEditMode -> handleEnterEditMode(room, action) + is MessageComposerAction.EnterQuoteMode -> handleEnterQuoteMode(room, action) is MessageComposerAction.EnterRegularMode -> handleEnterRegularMode(action) - is MessageComposerAction.EnterReplyMode -> handleEnterReplyMode(action) - is MessageComposerAction.SendMessage -> handleSendMessage(action) - is MessageComposerAction.UserIsTyping -> handleUserIsTyping(action) + is MessageComposerAction.EnterReplyMode -> handleEnterReplyMode(room, action) + is MessageComposerAction.SendMessage -> handleSendMessage(room, action) + is MessageComposerAction.UserIsTyping -> handleUserIsTyping(room, action) is MessageComposerAction.OnTextChanged -> handleOnTextChanged(action) is MessageComposerAction.OnVoiceRecordingUiStateChanged -> handleOnVoiceRecordingUiStateChanged(action) - is MessageComposerAction.StartRecordingVoiceMessage -> handleStartRecordingVoiceMessage() - is MessageComposerAction.EndRecordingVoiceMessage -> handleEndRecordingVoiceMessage(action.isCancelled, action.rootThreadEventId) + is MessageComposerAction.StartRecordingVoiceMessage -> handleStartRecordingVoiceMessage(room) + is MessageComposerAction.EndRecordingVoiceMessage -> handleEndRecordingVoiceMessage(room, action.isCancelled, action.rootThreadEventId) is MessageComposerAction.PlayOrPauseVoicePlayback -> handlePlayOrPauseVoicePlayback(action) MessageComposerAction.PauseRecordingVoiceMessage -> handlePauseRecordingVoiceMessage() MessageComposerAction.PlayOrPauseRecordingPlayback -> handlePlayOrPauseRecordingPlayback() - is MessageComposerAction.InitializeVoiceRecorder -> handleInitializeVoiceRecorder(action.attachmentData) - is MessageComposerAction.OnEntersBackground -> handleEntersBackground(action.composerText) + is MessageComposerAction.InitializeVoiceRecorder -> handleInitializeVoiceRecorder(room, action.attachmentData) + is MessageComposerAction.OnEntersBackground -> handleEntersBackground(room, action.composerText) is MessageComposerAction.VoiceWaveformTouchedUp -> handleVoiceWaveformTouchedUp(action) is MessageComposerAction.VoiceWaveformMovedTo -> handleVoiceWaveformMovedTo(action) is MessageComposerAction.AudioSeekBarMovedTo -> handleAudioSeekBarMovedTo(action) - is MessageComposerAction.SlashCommandConfirmed -> handleSlashCommandConfirmed(action) + is MessageComposerAction.SlashCommandConfirmed -> handleSlashCommandConfirmed(room, action) is MessageComposerAction.InsertUserDisplayName -> handleInsertUserDisplayName(action) + is MessageComposerAction.SetFullScreen -> handleSetFullScreen(action) } } @@ -134,12 +142,11 @@ class MessageComposerViewModel @AssistedInject constructor( } private fun handleOnTextChanged(action: MessageComposerAction.OnTextChanged) { - setState { - // Makes sure currentComposerText is upToDate when accessing further setState - currentComposerText = action.text - this + val needsSendButtonVisibilityUpdate = currentComposerText.isEmpty() != action.text.isEmpty() + currentComposerText = SpannableString(action.text) + if (needsSendButtonVisibilityUpdate) { + updateIsSendButtonVisibility(true) } - updateIsSendButtonVisibility(true) } private fun subscribeToStateInternal() { @@ -160,14 +167,18 @@ class MessageComposerViewModel @AssistedInject constructor( copy(sendMode = SendMode.Regular(currentComposerText, action.fromSharing)) } - private fun handleEnterEditMode(action: MessageComposerAction.EnterEditMode) { + private fun handleEnterEditMode(room: Room, action: MessageComposerAction.EnterEditMode) { room.getTimelineEvent(action.eventId)?.let { timelineEvent -> val formatted = vectorPreferences.isRichTextEditorEnabled() setState { copy(sendMode = SendMode.Edit(timelineEvent, timelineEvent.getTextEditableContent(formatted))) } } } - private fun observeCanSendMessageAndEncryption() { + private fun handleSetFullScreen(action: MessageComposerAction.SetFullScreen) { + setState { copy(isFullScreen = action.isFullScreen) } + } + + private fun observeCanSendMessageAndEncryption(room: Room) { val roomMemberQueryParams = roomMemberQueryParams { displayName = QueryStringValue.IsNotEmpty memberships = Membership.activeMemberships() @@ -184,7 +195,7 @@ class MessageComposerViewModel @AssistedInject constructor( val isLastMemberInDm = isLastMember && roomType == TchapRoomType.DIRECT val canSendMessage = PowerLevelsHelper(pl).isUserAllowedToSend(session.myUserId, false, EventType.MESSAGE) && !isLastMemberInDm when { - canSendMessage -> { + canSendMessage -> { val isE2E = sum.isEncrypted if (isE2E) { val roomEncryptionAlgorithm = sum.roomEncryptionAlgorithm @@ -198,14 +209,14 @@ class MessageComposerViewModel @AssistedInject constructor( } } isLastMemberInDm -> CanSendStatus.EmptyDM - else -> CanSendStatus.NoPermission + else -> CanSendStatus.NoPermission } }.setOnEach { copy(canSendMessage = it) } } - private fun observeVoiceBroadcast() { + private fun observeVoiceBroadcast(room: Room) { room.stateService().getStateEventLive(VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO, QueryStringValue.Equals(session.myUserId)) .asFlow() .unwrap() @@ -215,19 +226,19 @@ class MessageComposerViewModel @AssistedInject constructor( } } - private fun handleEnterQuoteMode(action: MessageComposerAction.EnterQuoteMode) { + private fun handleEnterQuoteMode(room: Room, action: MessageComposerAction.EnterQuoteMode) { room.getTimelineEvent(action.eventId)?.let { timelineEvent -> setState { copy(sendMode = SendMode.Quote(timelineEvent, currentComposerText)) } } } - private fun handleEnterReplyMode(action: MessageComposerAction.EnterReplyMode) { + private fun handleEnterReplyMode(room: Room, action: MessageComposerAction.EnterReplyMode) { room.getTimelineEvent(action.eventId)?.let { timelineEvent -> setState { copy(sendMode = SendMode.Reply(timelineEvent, currentComposerText)) } } } - private fun handleSendMessage(action: MessageComposerAction.SendMessage) { + private fun handleSendMessage(room: Room, action: MessageComposerAction.SendMessage) { withState { state -> analyticsTracker.capture(state.toAnalyticsComposer()).also { setState { copy(startsThread = false) } @@ -240,7 +251,7 @@ class MessageComposerViewModel @AssistedInject constructor( isInThreadTimeline = state.isInThreadTimeline() )) { is ParsedCommand.ErrorNotACommand, - ParsedCommand.ErrorNotATchapCommand -> { + ParsedCommand.ErrorNotATchapCommand -> { // Send the text message to the room if (state.rootThreadEventId != null) { room.relationService().replyInThread( @@ -258,7 +269,7 @@ class MessageComposerViewModel @AssistedInject constructor( } _viewEvents.post(MessageComposerViewEvents.MessageSent) - popDraft() + popDraft(room) } is ParsedCommand.ErrorSyntax -> { _viewEvents.post(MessageComposerViewEvents.SlashCommandError(parsedCommand.command)) @@ -284,7 +295,7 @@ class MessageComposerViewModel @AssistedInject constructor( room.sendService().sendTextMessage(parsedCommand.message, autoMarkdown = false) } _viewEvents.post(MessageComposerViewEvents.MessageSent) - popDraft() + popDraft(room) } is ParsedCommand.SendFormattedText -> { // Send the text message to the room, without markdown @@ -302,23 +313,23 @@ class MessageComposerViewModel @AssistedInject constructor( ) } _viewEvents.post(MessageComposerViewEvents.MessageSent) - popDraft() + popDraft(room) } is ParsedCommand.ChangeRoomName -> { - handleChangeRoomNameSlashCommand(parsedCommand) + handleChangeRoomNameSlashCommand(room, parsedCommand) } is ParsedCommand.Invite -> { - handleInviteSlashCommand(parsedCommand) + handleInviteSlashCommand(room, parsedCommand) } is ParsedCommand.Invite3Pid -> { - handleInvite3pidSlashCommand(parsedCommand) + handleInvite3pidSlashCommand(room, parsedCommand) } is ParsedCommand.SetUserPowerLevel -> { - handleSetUserPowerLevel(parsedCommand) + handleSetUserPowerLevel(room, parsedCommand) } is ParsedCommand.DevTools -> { _viewEvents.post(MessageComposerViewEvents.SlashCommandResultOk(parsedCommand)) - popDraft() + popDraft(room) } is ParsedCommand.ClearScalarToken -> { // TODO @@ -327,29 +338,29 @@ class MessageComposerViewModel @AssistedInject constructor( is ParsedCommand.SetMarkdown -> { vectorPreferences.setMarkdownEnabled(parsedCommand.enable) _viewEvents.post(MessageComposerViewEvents.SlashCommandResultOk(parsedCommand)) - popDraft() + popDraft(room) } is ParsedCommand.BanUser -> { - handleBanSlashCommand(parsedCommand) + handleBanSlashCommand(room, parsedCommand) } is ParsedCommand.UnbanUser -> { - handleUnbanSlashCommand(parsedCommand) + handleUnbanSlashCommand(room, parsedCommand) } is ParsedCommand.IgnoreUser -> { - handleIgnoreSlashCommand(parsedCommand) + handleIgnoreSlashCommand(room, parsedCommand) } is ParsedCommand.UnignoreUser -> { handleUnignoreSlashCommand(parsedCommand) } is ParsedCommand.RemoveUser -> { - handleRemoveSlashCommand(parsedCommand) + handleRemoveSlashCommand(room, parsedCommand) } is ParsedCommand.JoinRoom -> { handleJoinToAnotherRoomSlashCommand(parsedCommand) - popDraft() + popDraft(room) } is ParsedCommand.PartRoom -> { - handlePartSlashCommand(parsedCommand) + handlePartSlashCommand(room, parsedCommand) } is ParsedCommand.SendEmote -> { if (state.rootThreadEventId != null) { @@ -367,7 +378,7 @@ class MessageComposerViewModel @AssistedInject constructor( ) } _viewEvents.post(MessageComposerViewEvents.SlashCommandResultOk(parsedCommand)) - popDraft() + popDraft(room) } is ParsedCommand.SendRainbow -> { val message = parsedCommand.message.toString() @@ -381,7 +392,7 @@ class MessageComposerViewModel @AssistedInject constructor( room.sendService().sendFormattedTextMessage(message, rainbowGenerator.generate(message)) } _viewEvents.post(MessageComposerViewEvents.SlashCommandResultOk(parsedCommand)) - popDraft() + popDraft(room) } is ParsedCommand.SendRainbowEmote -> { val message = parsedCommand.message.toString() @@ -397,7 +408,7 @@ class MessageComposerViewModel @AssistedInject constructor( } _viewEvents.post(MessageComposerViewEvents.SlashCommandResultOk(parsedCommand)) - popDraft() + popDraft(room) } is ParsedCommand.SendSpoiler -> { val text = "[${stringProvider.getString(R.string.spoiler)}](${parsedCommand.message})" @@ -415,53 +426,53 @@ class MessageComposerViewModel @AssistedInject constructor( ) } _viewEvents.post(MessageComposerViewEvents.SlashCommandResultOk(parsedCommand)) - popDraft() + popDraft(room) } is ParsedCommand.SendShrug -> { - sendPrefixedMessage("¯\\_(ツ)_/¯", parsedCommand.message, state.rootThreadEventId) + sendPrefixedMessage(room, "¯\\_(ツ)_/¯", parsedCommand.message, state.rootThreadEventId) _viewEvents.post(MessageComposerViewEvents.SlashCommandResultOk(parsedCommand)) - popDraft() + popDraft(room) } is ParsedCommand.SendLenny -> { - sendPrefixedMessage("( ͡° ͜ʖ ͡°)", parsedCommand.message, state.rootThreadEventId) + sendPrefixedMessage(room, "( ͡° ͜ʖ ͡°)", parsedCommand.message, state.rootThreadEventId) _viewEvents.post(MessageComposerViewEvents.SlashCommandResultOk(parsedCommand)) - popDraft() + popDraft(room) } is ParsedCommand.SendTableFlip -> { - sendPrefixedMessage("(╯°□°)╯︵ ┻━┻", parsedCommand.message, state.rootThreadEventId) + sendPrefixedMessage(room, "(╯°□°)╯︵ ┻━┻", parsedCommand.message, state.rootThreadEventId) _viewEvents.post(MessageComposerViewEvents.SlashCommandResultOk(parsedCommand)) - popDraft() + popDraft(room) } is ParsedCommand.SendChatEffect -> { - sendChatEffect(parsedCommand) + sendChatEffect(room, parsedCommand) _viewEvents.post(MessageComposerViewEvents.SlashCommandResultOk(parsedCommand)) - popDraft() + popDraft(room) } is ParsedCommand.ChangeTopic -> { - handleChangeTopicSlashCommand(parsedCommand) + handleChangeTopicSlashCommand(room, parsedCommand) } is ParsedCommand.ChangeDisplayName -> { - handleChangeDisplayNameSlashCommand(parsedCommand) + handleChangeDisplayNameSlashCommand(room, parsedCommand) } is ParsedCommand.ChangeDisplayNameForRoom -> { - handleChangeDisplayNameForRoomSlashCommand(parsedCommand) + handleChangeDisplayNameForRoomSlashCommand(room, parsedCommand) } is ParsedCommand.ChangeRoomAvatar -> { - handleChangeRoomAvatarSlashCommand(parsedCommand) + handleChangeRoomAvatarSlashCommand(room, parsedCommand) } is ParsedCommand.ChangeAvatarForRoom -> { - handleChangeAvatarForRoomSlashCommand(parsedCommand) + handleChangeAvatarForRoomSlashCommand(room, parsedCommand) } is ParsedCommand.ShowUser -> { _viewEvents.post(MessageComposerViewEvents.SlashCommandResultOk(parsedCommand)) handleWhoisSlashCommand(parsedCommand) - popDraft() + popDraft(room) } is ParsedCommand.DiscardSession -> { if (room.roomCryptoService().isEncrypted()) { session.cryptoService().discardOutboundSession(room.roomId) _viewEvents.post(MessageComposerViewEvents.SlashCommandResultOk(parsedCommand)) - popDraft() + popDraft(room) } else { _viewEvents.post(MessageComposerViewEvents.SlashCommandResultOk(parsedCommand)) _viewEvents.post( @@ -486,7 +497,7 @@ class MessageComposerViewModel @AssistedInject constructor( null, true ) - popDraft() + popDraft(room) _viewEvents.post(MessageComposerViewEvents.SlashCommandResultOk(parsedCommand)) } catch (failure: Throwable) { _viewEvents.post(MessageComposerViewEvents.SlashCommandResultError(failure)) @@ -505,7 +516,7 @@ class MessageComposerViewModel @AssistedInject constructor( null, false ) - popDraft() + popDraft(room) _viewEvents.post(MessageComposerViewEvents.SlashCommandResultOk(parsedCommand)) } catch (failure: Throwable) { _viewEvents.post(MessageComposerViewEvents.SlashCommandResultError(failure)) @@ -518,7 +529,7 @@ class MessageComposerViewModel @AssistedInject constructor( viewModelScope.launch(Dispatchers.IO) { try { session.spaceService().joinSpace(parsedCommand.spaceIdOrAlias) - popDraft() + popDraft(room) _viewEvents.post(MessageComposerViewEvents.SlashCommandResultOk(parsedCommand)) } catch (failure: Throwable) { _viewEvents.post(MessageComposerViewEvents.SlashCommandResultError(failure)) @@ -530,7 +541,7 @@ class MessageComposerViewModel @AssistedInject constructor( viewModelScope.launch(Dispatchers.IO) { try { session.roomService().leaveRoom(parsedCommand.roomId) - popDraft() + popDraft(room) _viewEvents.post(MessageComposerViewEvents.SlashCommandResultOk(parsedCommand)) } catch (failure: Throwable) { _viewEvents.post(MessageComposerViewEvents.SlashCommandResultError(failure)) @@ -546,7 +557,7 @@ class MessageComposerViewModel @AssistedInject constructor( ) ) _viewEvents.post(MessageComposerViewEvents.SlashCommandResultOk(parsedCommand)) - popDraft() + popDraft(room) } } } @@ -595,7 +606,7 @@ class MessageComposerViewModel @AssistedInject constructor( } } _viewEvents.post(MessageComposerViewEvents.MessageSent) - popDraft() + popDraft(room) } is SendMode.Quote -> { room.sendService().sendQuotedTextMessage( @@ -606,7 +617,7 @@ class MessageComposerViewModel @AssistedInject constructor( rootThreadEventId = state.rootThreadEventId ) _viewEvents.post(MessageComposerViewEvents.MessageSent) - popDraft() + popDraft(room) } is SendMode.Reply -> { val timelineEvent = state.sendMode.timelineEvent @@ -631,7 +642,7 @@ class MessageComposerViewModel @AssistedInject constructor( ) _viewEvents.post(MessageComposerViewEvents.MessageSent) - popDraft() + popDraft(room) } is SendMode.Voice -> { // do nothing @@ -640,10 +651,10 @@ class MessageComposerViewModel @AssistedInject constructor( } } - private fun popDraft() = withState { + private fun popDraft(room: Room) = withState { if (it.sendMode is SendMode.Regular && it.sendMode.fromSharing) { // If we were sharing, we want to get back our last value from draft - loadDraftIfAny() + loadDraftIfAny(room) } else { // Otherwise we clear the composer and remove the draft from db setState { copy(sendMode = SendMode.Regular("", false)) } @@ -653,7 +664,7 @@ class MessageComposerViewModel @AssistedInject constructor( } } - private fun loadDraftIfAny() { + private fun loadDraftIfAny(room: Room) { val currentDraft = room.draftService().getDraft() setState { copy( @@ -682,7 +693,7 @@ class MessageComposerViewModel @AssistedInject constructor( } } - private fun handleUserIsTyping(action: MessageComposerAction.UserIsTyping) { + private fun handleUserIsTyping(room: Room, action: MessageComposerAction.UserIsTyping) { if (vectorPreferences.sendTypingNotifs()) { if (action.isTyping) { room.typingService().userIsTyping() @@ -692,7 +703,7 @@ class MessageComposerViewModel @AssistedInject constructor( } } - private fun sendChatEffect(sendChatEffect: ParsedCommand.SendChatEffect) { + private fun sendChatEffect(room: Room, sendChatEffect: ParsedCommand.SendChatEffect) { // If message is blank, convert to an emote, with default message if (sendChatEffect.message.isBlank()) { val defaultMessage = stringProvider.getString( @@ -744,25 +755,25 @@ class MessageComposerViewModel @AssistedInject constructor( } } - private fun handleChangeTopicSlashCommand(changeTopic: ParsedCommand.ChangeTopic) { - launchSlashCommandFlowSuspendable(changeTopic) { + private fun handleChangeTopicSlashCommand(room: Room, changeTopic: ParsedCommand.ChangeTopic) { + launchSlashCommandFlowSuspendable(room, changeTopic) { room.stateService().updateTopic(changeTopic.topic) } } - private fun handleInviteSlashCommand(invite: ParsedCommand.Invite) { - launchSlashCommandFlowSuspendable(invite) { + private fun handleInviteSlashCommand(room: Room, invite: ParsedCommand.Invite) { + launchSlashCommandFlowSuspendable(room, invite) { room.membershipService().invite(invite.userId, invite.reason) } } - private fun handleInvite3pidSlashCommand(invite: ParsedCommand.Invite3Pid) { - launchSlashCommandFlowSuspendable(invite) { + private fun handleInvite3pidSlashCommand(room: Room, invite: ParsedCommand.Invite3Pid) { + launchSlashCommandFlowSuspendable(room, invite) { room.membershipService().invite3pid(invite.threePid) } } - private fun handleSetUserPowerLevel(setUserPowerLevel: ParsedCommand.SetUserPowerLevel) { + private fun handleSetUserPowerLevel(room: Room, setUserPowerLevel: ParsedCommand.SetUserPowerLevel) { val newPowerLevelsContent = room.getStateEvent(EventType.STATE_ROOM_POWER_LEVELS, QueryStringValue.IsEmpty) ?.content ?.toModel() @@ -770,19 +781,19 @@ class MessageComposerViewModel @AssistedInject constructor( ?.toContent() ?: return - launchSlashCommandFlowSuspendable(setUserPowerLevel) { + launchSlashCommandFlowSuspendable(room, setUserPowerLevel) { room.stateService().sendStateEvent(EventType.STATE_ROOM_POWER_LEVELS, stateKey = "", newPowerLevelsContent) } } - private fun handleChangeDisplayNameSlashCommand(changeDisplayName: ParsedCommand.ChangeDisplayName) { - launchSlashCommandFlowSuspendable(changeDisplayName) { + private fun handleChangeDisplayNameSlashCommand(room: Room, changeDisplayName: ParsedCommand.ChangeDisplayName) { + launchSlashCommandFlowSuspendable(room, changeDisplayName) { session.profileService().setDisplayName(session.myUserId, changeDisplayName.displayName) } } - private fun handlePartSlashCommand(command: ParsedCommand.PartRoom) { - launchSlashCommandFlowSuspendable(command) { + private fun handlePartSlashCommand(room: Room, command: ParsedCommand.PartRoom) { + launchSlashCommandFlowSuspendable(room, command) { if (command.roomAlias == null) { // Leave the current room room @@ -797,39 +808,39 @@ class MessageComposerViewModel @AssistedInject constructor( } } - private fun handleRemoveSlashCommand(removeUser: ParsedCommand.RemoveUser) { - launchSlashCommandFlowSuspendable(removeUser) { + private fun handleRemoveSlashCommand(room: Room, removeUser: ParsedCommand.RemoveUser) { + launchSlashCommandFlowSuspendable(room, removeUser) { room.membershipService().remove(removeUser.userId, removeUser.reason) } } - private fun handleBanSlashCommand(ban: ParsedCommand.BanUser) { - launchSlashCommandFlowSuspendable(ban) { + private fun handleBanSlashCommand(room: Room, ban: ParsedCommand.BanUser) { + launchSlashCommandFlowSuspendable(room, ban) { room.membershipService().ban(ban.userId, ban.reason) } } - private fun handleUnbanSlashCommand(unban: ParsedCommand.UnbanUser) { - launchSlashCommandFlowSuspendable(unban) { + private fun handleUnbanSlashCommand(room: Room, unban: ParsedCommand.UnbanUser) { + launchSlashCommandFlowSuspendable(room, unban) { room.membershipService().unban(unban.userId, unban.reason) } } - private fun handleChangeRoomNameSlashCommand(changeRoomName: ParsedCommand.ChangeRoomName) { - launchSlashCommandFlowSuspendable(changeRoomName) { + private fun handleChangeRoomNameSlashCommand(room: Room, changeRoomName: ParsedCommand.ChangeRoomName) { + launchSlashCommandFlowSuspendable(room, changeRoomName) { room.stateService().updateName(changeRoomName.name) } } - private fun getMyRoomMemberContent(): RoomMemberContent? { + private fun getMyRoomMemberContent(room: Room): RoomMemberContent? { return room.getStateEvent(EventType.STATE_ROOM_MEMBER, QueryStringValue.Equals(session.myUserId)) ?.content ?.toModel() } - private fun handleChangeDisplayNameForRoomSlashCommand(changeDisplayName: ParsedCommand.ChangeDisplayNameForRoom) { - launchSlashCommandFlowSuspendable(changeDisplayName) { - getMyRoomMemberContent() + private fun handleChangeDisplayNameForRoomSlashCommand(room: Room, changeDisplayName: ParsedCommand.ChangeDisplayNameForRoom) { + launchSlashCommandFlowSuspendable(room, changeDisplayName) { + getMyRoomMemberContent(room) ?.copy(displayName = changeDisplayName.displayName) ?.toContent() ?.let { @@ -838,15 +849,15 @@ class MessageComposerViewModel @AssistedInject constructor( } } - private fun handleChangeRoomAvatarSlashCommand(changeAvatar: ParsedCommand.ChangeRoomAvatar) { - launchSlashCommandFlowSuspendable(changeAvatar) { + private fun handleChangeRoomAvatarSlashCommand(room: Room, changeAvatar: ParsedCommand.ChangeRoomAvatar) { + launchSlashCommandFlowSuspendable(room, changeAvatar) { room.stateService().sendStateEvent(EventType.STATE_ROOM_AVATAR, stateKey = "", RoomAvatarContent(changeAvatar.url).toContent()) } } - private fun handleChangeAvatarForRoomSlashCommand(changeAvatar: ParsedCommand.ChangeAvatarForRoom) { - launchSlashCommandFlowSuspendable(changeAvatar) { - getMyRoomMemberContent() + private fun handleChangeAvatarForRoomSlashCommand(room: Room, changeAvatar: ParsedCommand.ChangeAvatarForRoom) { + launchSlashCommandFlowSuspendable(room, changeAvatar) { + getMyRoomMemberContent(room) ?.copy(avatarUrl = changeAvatar.url) ?.toContent() ?.let { @@ -855,8 +866,8 @@ class MessageComposerViewModel @AssistedInject constructor( } } - private fun handleIgnoreSlashCommand(ignore: ParsedCommand.IgnoreUser) { - launchSlashCommandFlowSuspendable(ignore) { + private fun handleIgnoreSlashCommand(room: Room, ignore: ParsedCommand.IgnoreUser) { + launchSlashCommandFlowSuspendable(room, ignore) { session.userService().ignoreUserIds(listOf(ignore.userId)) } } @@ -865,15 +876,15 @@ class MessageComposerViewModel @AssistedInject constructor( _viewEvents.post(MessageComposerViewEvents.SlashCommandConfirmationRequest(unignore)) } - private fun handleSlashCommandConfirmed(action: MessageComposerAction.SlashCommandConfirmed) { + private fun handleSlashCommandConfirmed(room: Room, action: MessageComposerAction.SlashCommandConfirmed) { when (action.parsedCommand) { - is ParsedCommand.UnignoreUser -> handleUnignoreSlashCommandConfirmed(action.parsedCommand) + is ParsedCommand.UnignoreUser -> handleUnignoreSlashCommandConfirmed(room, action.parsedCommand) else -> TODO("Not handled yet") } } - private fun handleUnignoreSlashCommandConfirmed(unignore: ParsedCommand.UnignoreUser) { - launchSlashCommandFlowSuspendable(unignore) { + private fun handleUnignoreSlashCommandConfirmed(room: Room, unignore: ParsedCommand.UnignoreUser) { + launchSlashCommandFlowSuspendable(room, unignore) { session.userService().unIgnoreUserIds(listOf(unignore.userId)) } } @@ -882,7 +893,7 @@ class MessageComposerViewModel @AssistedInject constructor( _viewEvents.post(MessageComposerViewEvents.OpenRoomMemberProfile(whois.userId)) } - private fun sendPrefixedMessage(prefix: String, message: CharSequence, rootThreadEventId: String?) { + private fun sendPrefixedMessage(room: Room, prefix: String, message: CharSequence, rootThreadEventId: String?) { val sequence = buildString { append(prefix) if (message.isNotEmpty()) { @@ -898,7 +909,7 @@ class MessageComposerViewModel @AssistedInject constructor( /** * Convert a send mode to a draft and save the draft. */ - private fun handleSaveTextDraft(draft: String) = withState { + private fun handleSaveTextDraft(room: Room, draft: String) = withState { session.coroutineScope.launch { when { it.sendMode is SendMode.Regular && !it.sendMode.fromSharing -> { @@ -921,7 +932,7 @@ class MessageComposerViewModel @AssistedInject constructor( } } - private fun handleStartRecordingVoiceMessage() { + private fun handleStartRecordingVoiceMessage(room: Room) { try { audioMessageHelper.startRecording(room.roomId) } catch (failure: Throwable) { @@ -929,7 +940,7 @@ class MessageComposerViewModel @AssistedInject constructor( } } - private fun handleEndRecordingVoiceMessage(isCancelled: Boolean, rootThreadEventId: String? = null) { + private fun handleEndRecordingVoiceMessage(room: Room, isCancelled: Boolean, rootThreadEventId: String? = null) { audioMessageHelper.stopPlayback() if (isCancelled) { audioMessageHelper.deleteRecording() @@ -972,11 +983,11 @@ class MessageComposerViewModel @AssistedInject constructor( } fun endAllVoiceActions(deleteRecord: Boolean = true) { - audioMessageHelper.clearTracker() + audioMessageHelper.stopTracking() audioMessageHelper.stopAllVoiceActions(deleteRecord) } - private fun handleInitializeVoiceRecorder(attachmentData: ContentAttachmentData) { + private fun handleInitializeVoiceRecorder(room: Room, attachmentData: ContentAttachmentData) { audioMessageHelper.initializeRecorder(room.roomId, attachmentData) setState { copy(voiceRecordingUiState = VoiceMessageRecorderView.RecordingUiState.Draft) } } @@ -997,7 +1008,7 @@ class MessageComposerViewModel @AssistedInject constructor( audioMessageHelper.movePlaybackTo(action.eventId, action.percentage, action.duration) } - private fun handleEntersBackground(composerText: String) { + private fun handleEntersBackground(room: Room, composerText: String) { // Always stop all voice actions. It may be playing in timeline or active recording val playingAudioContent = audioMessageHelper.stopAllVoiceActions(deleteRecord = false) // TODO remove this when there will be a listening indicator outside of the timeline @@ -1013,7 +1024,7 @@ class MessageComposerViewModel @AssistedInject constructor( } } } else { - handleSaveTextDraft(draft = composerText) + handleSaveTextDraft(room = room, draft = composerText) } } @@ -1021,12 +1032,12 @@ class MessageComposerViewModel @AssistedInject constructor( _viewEvents.post(MessageComposerViewEvents.InsertUserDisplayName(action.userId)) } - private fun launchSlashCommandFlowSuspendable(parsedCommand: ParsedCommand, block: suspend () -> Unit) { + private fun launchSlashCommandFlowSuspendable(room: Room, parsedCommand: ParsedCommand, block: suspend () -> Unit) { _viewEvents.post(MessageComposerViewEvents.SlashCommandLoading) viewModelScope.launch { val event = try { block() - popDraft() + popDraft(room) MessageComposerViewEvents.SlashCommandResultOk(parsedCommand) } catch (failure: Throwable) { MessageComposerViewEvents.SlashCommandResultError(failure) @@ -1035,6 +1046,10 @@ class MessageComposerViewModel @AssistedInject constructor( } } + private fun onRoomError() = setState { + copy(isRoomError = true) + } + @AssistedFactory interface Factory : MavericksAssistedViewModelFactory { override fun create(initialState: MessageComposerViewState): MessageComposerViewModel diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewState.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewState.kt index 1731c80252..303adfa6c8 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewState.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/MessageComposerViewState.kt @@ -67,6 +67,7 @@ fun CanSendStatus.boolean(): Boolean { data class MessageComposerViewState( val roomId: String, + val isRoomError: Boolean = false, val canSendMessage: CanSendStatus = CanSendStatus.Allowed, val isSendButtonVisible: Boolean = false, val rootThreadEventId: String? = null, @@ -75,6 +76,7 @@ data class MessageComposerViewState( val voiceRecordingUiState: VoiceMessageRecorderView.RecordingUiState = VoiceMessageRecorderView.RecordingUiState.Idle, val voiceBroadcastState: VoiceBroadcastState? = null, val text: CharSequence? = null, + val isFullScreen: Boolean = false, ) : MavericksState { val isVoiceRecording = when (voiceRecordingUiState) { @@ -84,17 +86,16 @@ data class MessageComposerViewState( is VoiceMessageRecorderView.RecordingUiState.Recording -> true } - val isVoiceBroadcasting = when (voiceBroadcastState) { + val isRecordingVoiceBroadcast = when (voiceBroadcastState) { VoiceBroadcastState.STARTED, - VoiceBroadcastState.PAUSED, VoiceBroadcastState.RESUMED -> true else -> false } val isVoiceMessageIdle = !isVoiceRecording - val isComposerVisible = canSendMessage.boolean() && !isVoiceRecording - val isVoiceMessageRecorderVisible = canSendMessage.boolean() && !isSendButtonVisible && Config.SHOW_VOICE_RECORDER + val isComposerVisible = canSendMessage.boolean() && !isVoiceRecording && !isRoomError + val isVoiceMessageRecorderVisible = canSendMessage.boolean() && !isSendButtonVisible && !isRoomError && Config.SHOW_VOICE_RECORDER constructor(args: TimelineArgs) : this( roomId = args.roomId, diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/PlainTextComposerLayout.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/PlainTextComposerLayout.kt index acb5a1b42a..8f4dd9b71d 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/PlainTextComposerLayout.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/PlainTextComposerLayout.kt @@ -19,51 +19,59 @@ package im.vector.app.features.home.room.detail.composer import android.content.Context import android.net.Uri import android.text.Editable +import android.text.format.DateUtils import android.util.AttributeSet -import android.view.ViewGroup import android.widget.EditText import android.widget.ImageButton -import android.widget.ImageView -import android.widget.TextView -import androidx.constraintlayout.widget.ConstraintLayout -import androidx.constraintlayout.widget.ConstraintSet +import android.widget.LinearLayout +import androidx.core.content.ContextCompat import androidx.core.text.toSpannable -import androidx.core.view.isInvisible import androidx.core.view.isVisible -import androidx.transition.ChangeBounds -import androidx.transition.Fade -import androidx.transition.Transition -import androidx.transition.TransitionManager -import androidx.transition.TransitionSet +import dagger.hilt.android.AndroidEntryPoint import im.vector.app.R -import im.vector.app.core.animations.SimpleTransitionListener +import im.vector.app.core.extensions.getVectorLastMessageContent import im.vector.app.core.extensions.setTextIfDifferent +import im.vector.app.core.extensions.showKeyboard +import im.vector.app.core.utils.DimensionConverter import im.vector.app.databinding.ComposerLayoutBinding +import im.vector.app.features.home.AvatarRenderer +import im.vector.app.features.home.room.detail.timeline.helper.MatrixItemColorProvider +import im.vector.app.features.home.room.detail.timeline.image.buildImageContentRendererData +import im.vector.app.features.html.EventHtmlRenderer +import im.vector.app.features.html.PillsPostProcessor +import im.vector.app.features.media.ImageContentRenderer +import org.commonmark.parser.Parser +import org.matrix.android.sdk.api.session.room.model.message.MessageAudioContent +import org.matrix.android.sdk.api.session.room.model.message.MessageBeaconInfoContent +import org.matrix.android.sdk.api.session.room.model.message.MessageContent +import org.matrix.android.sdk.api.session.room.model.message.MessageFormat +import org.matrix.android.sdk.api.session.room.model.message.MessagePollContent +import org.matrix.android.sdk.api.session.room.model.message.MessageTextContent +import org.matrix.android.sdk.api.util.MatrixItem +import org.matrix.android.sdk.api.util.toMatrixItem +import javax.inject.Inject /** * Encapsulate the timeline composer UX. */ +@AndroidEntryPoint class PlainTextComposerLayout @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 -) : ConstraintLayout(context, attrs, defStyleAttr), MessageComposerView { +) : LinearLayout(context, attrs, defStyleAttr), MessageComposerView { - interface Callback : ComposerEditText.Callback { - fun onCloseRelatedMessage() - fun onSendMessage(text: CharSequence) - fun onAddAttachment() - fun onExpandOrCompactChange() - } + @Inject lateinit var avatarRenderer: AvatarRenderer + @Inject lateinit var matrixItemColorProvider: MatrixItemColorProvider + @Inject lateinit var eventHtmlRenderer: EventHtmlRenderer + @Inject lateinit var dimensionConverter: DimensionConverter + @Inject lateinit var imageContentRenderer: ImageContentRenderer + @Inject lateinit var pillsPostProcessorFactory: PillsPostProcessor.Factory private val views: ComposerLayoutBinding override var callback: Callback? = null - private var currentConstraintSetId: Int = -1 - - private val animationDuration = 100L - override val text: Editable? get() = views.composerEditText.text @@ -72,36 +80,23 @@ class PlainTextComposerLayout @JvmOverloads constructor( override val editText: EditText get() = views.composerEditText + @Suppress("RedundantNullableReturnType") override val emojiButton: ImageButton? get() = views.composerEmojiButton override val sendButton: ImageButton get() = views.sendButton - override fun setInvisible(isInvisible: Boolean) { - this.isInvisible = isInvisible - } override val attachmentButton: ImageButton get() = views.attachmentButton - override val composerRelatedMessageActionIcon: ImageView - get() = views.composerRelatedMessageActionIcon - override val composerRelatedMessageAvatar: ImageView - get() = views.composerRelatedMessageAvatar - override val composerRelatedMessageContent: TextView - get() = views.composerRelatedMessageContent - override val composerRelatedMessageImage: ImageView - get() = views.composerRelatedMessageImage - override val composerRelatedMessageTitle: TextView - get() = views.composerRelatedMessageTitle - override var isVisible: Boolean - get() = views.root.isVisible - set(value) { views.root.isVisible = value } init { inflate(context, R.layout.composer_layout, this) views = ComposerLayoutBinding.bind(this) - collapse(false) + views.composerEditText.maxLines = MessageComposerView.MAX_LINES_WHEN_COLLAPSED + + collapse() views.composerEditText.callback = object : ComposerEditText.Callback { override fun onRichContentSelected(contentUri: Uri): Boolean { @@ -127,27 +122,15 @@ class PlainTextComposerLayout @JvmOverloads constructor( } } - override fun replaceFormattedContent(text: CharSequence) { - setTextIfDifferent(text) - } - - override fun collapse(animate: Boolean, transitionComplete: (() -> Unit)?) { - if (currentConstraintSetId == R.layout.composer_layout_constraint_set_compact) { - // ignore we good - return - } - currentConstraintSetId = R.layout.composer_layout_constraint_set_compact - applyNewConstraintSet(animate, transitionComplete) + private fun collapse(transitionComplete: (() -> Unit)? = null) { + views.relatedMessageGroup.isVisible = false + transitionComplete?.invoke() callback?.onExpandOrCompactChange() } - override fun expand(animate: Boolean, transitionComplete: (() -> Unit)?) { - if (currentConstraintSetId == R.layout.composer_layout_constraint_set_expanded) { - // ignore we good - return - } - currentConstraintSetId = R.layout.composer_layout_constraint_set_expanded - applyNewConstraintSet(animate, transitionComplete) + private fun expand(transitionComplete: (() -> Unit)? = null) { + views.relatedMessageGroup.isVisible = true + transitionComplete?.invoke() callback?.onExpandOrCompactChange() } @@ -155,31 +138,92 @@ class PlainTextComposerLayout @JvmOverloads constructor( return views.composerEditText.setTextIfDifferent(text) } - private fun applyNewConstraintSet(animate: Boolean, transitionComplete: (() -> Unit)?) { - // val wasSendButtonInvisible = views.sendButton.isInvisible - if (animate) { - configureAndBeginTransition(transitionComplete) + override fun renderComposerMode(mode: MessageComposerMode) { + val specialMode = mode as? MessageComposerMode.Special + if (specialMode != null) { + renderSpecialMode(specialMode) + } else if (mode is MessageComposerMode.Normal) { + collapse() + editText.setTextIfDifferent(mode.content) + } + + views.sendButton.apply { + if (mode is MessageComposerMode.Edit) { + contentDescription = resources.getString(R.string.action_save) + setImageResource(R.drawable.ic_composer_rich_text_save) + } else { + contentDescription = resources.getString(R.string.action_send) + setImageResource(R.drawable.ic_rich_composer_send) + } + } + } + + private fun renderSpecialMode(specialMode: MessageComposerMode.Special) { + val event = specialMode.event + val defaultContent = specialMode.defaultContent + + val iconRes: Int = when (specialMode) { + is MessageComposerMode.Reply -> R.drawable.ic_reply + is MessageComposerMode.Edit -> R.drawable.ic_edit + is MessageComposerMode.Quote -> R.drawable.ic_quote + } + + val pillsPostProcessor = pillsPostProcessorFactory.create(event.roomId) + + // switch to expanded bar + views.composerRelatedMessageTitle.apply { + text = event.senderInfo.disambiguatedDisplayName + setTextColor(matrixItemColorProvider.getColor(MatrixItem.UserItem(event.root.senderId ?: "@"))) + } + + val messageContent: MessageContent? = event.getVectorLastMessageContent() + val nonFormattedBody = when (messageContent) { + is MessageAudioContent -> getAudioContentBodyText(messageContent) + is MessagePollContent -> messageContent.getBestPollCreationInfo()?.question?.getBestQuestion() + is MessageBeaconInfoContent -> resources.getString(R.string.live_location_description) + else -> messageContent?.body.orEmpty() + } + var formattedBody: CharSequence? = null + if (messageContent is MessageTextContent && messageContent.format == MessageFormat.FORMAT_MATRIX_HTML) { + val parser = Parser.builder().build() + val document = parser.parse(messageContent.formattedBody ?: messageContent.body) + formattedBody = eventHtmlRenderer.render(document, pillsPostProcessor) } - ConstraintSet().also { - it.clone(context, currentConstraintSetId) - it.applyTo(this) + views.composerRelatedMessageContent.text = (formattedBody ?: nonFormattedBody) + + // Image Event + val data = event.buildImageContentRendererData(dimensionConverter.dpToPx(66)) + val isImageVisible = if (data != null) { + imageContentRenderer.render(data, ImageContentRenderer.Mode.THUMBNAIL, views.composerRelatedMessageImage) + true + } else { + imageContentRenderer.clear(views.composerRelatedMessageImage) + false + } + + views.composerRelatedMessageImage.isVisible = isImageVisible + + views.composerRelatedMessageActionIcon.setImageDrawable(ContextCompat.getDrawable(context, iconRes)) + + avatarRenderer.render(event.senderInfo.toMatrixItem(), views.composerRelatedMessageAvatar) + + views.composerEditText.setText(defaultContent) + + expand { + // need to do it here also when not using quick reply + if (isVisible) { + showKeyboard(andRequestFocus = true) + } + views.composerRelatedMessageImage.isVisible = isImageVisible } - // Might be updated by view state just after, but avoid blinks - // views.sendButton.isInvisible = wasSendButtonInvisible } - private fun configureAndBeginTransition(transitionComplete: (() -> Unit)? = null) { - val transition = TransitionSet().apply { - ordering = TransitionSet.ORDERING_SEQUENTIAL - addTransition(ChangeBounds()) - addTransition(Fade(Fade.IN)) - duration = animationDuration - addListener(object : SimpleTransitionListener() { - override fun onTransitionEnd(transition: Transition) { - transitionComplete?.invoke() - } - }) + private fun getAudioContentBodyText(messageContent: MessageAudioContent): String { + val formattedDuration = DateUtils.formatElapsedTime(((messageContent.audioInfo?.duration ?: 0) / 1000).toLong()) + return if (messageContent.voiceMessageIndicator != null) { + resources.getString(R.string.voice_message_reply_content, formattedDuration) + } else { + resources.getString(R.string.audio_message_reply_content, messageContent.body, formattedDuration) } - TransitionManager.beginDelayedTransition((parent as? ViewGroup ?: this), transition) } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/RichTextComposerLayout.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/RichTextComposerLayout.kt index 07b7d151ad..543210e006 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/RichTextComposerLayout.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/RichTextComposerLayout.kt @@ -16,102 +16,183 @@ package im.vector.app.features.home.room.detail.composer +import android.annotation.SuppressLint import android.content.Context +import android.content.res.ColorStateList +import android.content.res.Configuration +import android.graphics.Color import android.text.Editable import android.text.TextWatcher import android.util.AttributeSet +import android.util.TypedValue import android.view.LayoutInflater +import android.view.MotionEvent +import android.view.View import android.view.ViewGroup import android.widget.EditText import android.widget.ImageButton -import android.widget.ImageView -import android.widget.TextView +import android.widget.LinearLayout import androidx.annotation.DrawableRes import androidx.annotation.StringRes -import androidx.constraintlayout.widget.ConstraintLayout import androidx.constraintlayout.widget.ConstraintSet import androidx.core.text.toSpannable +import androidx.core.view.isGone import androidx.core.view.isInvisible import androidx.core.view.isVisible -import androidx.transition.ChangeBounds -import androidx.transition.Fade -import androidx.transition.Transition -import androidx.transition.TransitionManager -import androidx.transition.TransitionSet +import androidx.core.view.updateLayoutParams +import com.google.android.material.shape.MaterialShapeDrawable import im.vector.app.R -import im.vector.app.core.animations.SimpleTransitionListener import im.vector.app.core.extensions.setTextIfDifferent +import im.vector.app.core.extensions.showKeyboard +import im.vector.app.core.utils.DimensionConverter import im.vector.app.databinding.ComposerRichTextLayoutBinding import im.vector.app.databinding.ViewRichTextMenuButtonBinding import io.element.android.wysiwyg.EditorEditText -import io.element.android.wysiwyg.InlineFormat +import io.element.android.wysiwyg.inputhandlers.models.InlineFormat +import io.element.android.wysiwyg.inputhandlers.models.LinkAction +import io.element.android.wysiwyg.utils.RustErrorCollector +import uniffi.wysiwyg_composer.ActionState import uniffi.wysiwyg_composer.ComposerAction -import uniffi.wysiwyg_composer.MenuState -class RichTextComposerLayout @JvmOverloads constructor( +internal class RichTextComposerLayout @JvmOverloads constructor( context: Context, attrs: AttributeSet? = null, defStyleAttr: Int = 0 -) : ConstraintLayout(context, attrs, defStyleAttr), MessageComposerView { +) : LinearLayout(context, attrs, defStyleAttr), MessageComposerView { private val views: ComposerRichTextLayoutBinding - override var callback: PlainTextComposerLayout.Callback? = null + override var callback: Callback? = null - private var currentConstraintSetId: Int = -1 + // There is no need to persist these values since they're always updated by the parent fragment + private var isFullScreen = false + private var hasRelatedMessage = false + private var composerMode: MessageComposerMode? = null - private val animationDuration = 100L + var isTextFormattingEnabled = true + set(value) { + if (field == value) return + syncEditTexts() + field = value + updateTextFieldBorder(isFullScreen) + updateEditTextVisibility() + updateFullScreenButtonVisibility() + // If formatting is no longer enabled and it's in full screen, minimise the editor + if (!value && isFullScreen) { + callback?.onFullScreenModeChanged() + } + } override val text: Editable? - get() = views.composerEditText.text + get() = editText.text override val formattedText: String? - get() = views.composerEditText.getHtmlOutput() + get() = (editText as? EditorEditText)?.getHtmlOutput() override val editText: EditText - get() = views.composerEditText + get() = if (isTextFormattingEnabled) { + views.richTextComposerEditText + } else { + views.plainTextComposerEditText + } override val emojiButton: ImageButton? get() = null override val sendButton: ImageButton get() = views.sendButton override val attachmentButton: ImageButton get() = views.attachmentButton - override val composerRelatedMessageActionIcon: ImageView - get() = views.composerRelatedMessageActionIcon - override val composerRelatedMessageAvatar: ImageView - get() = views.composerRelatedMessageAvatar - override val composerRelatedMessageContent: TextView - get() = views.composerRelatedMessageContent - override val composerRelatedMessageImage: ImageView - get() = views.composerRelatedMessageImage - override val composerRelatedMessageTitle: TextView - get() = views.composerRelatedMessageTitle - override var isVisible: Boolean - get() = views.root.isVisible - set(value) { views.root.isVisible = value } + + // Border of the EditText + private val borderShapeDrawable: MaterialShapeDrawable by lazy { + MaterialShapeDrawable().apply { + val typedData = TypedValue() + val lineColor = context.theme.obtainStyledAttributes(typedData.data, intArrayOf(R.attr.vctr_content_quaternary)) + .getColor(0, 0) + strokeColor = ColorStateList.valueOf(lineColor) + strokeWidth = 1 * resources.displayMetrics.scaledDensity + fillColor = ColorStateList.valueOf(Color.TRANSPARENT) + val cornerSize = resources.getDimensionPixelSize(R.dimen.rich_text_composer_corner_radius_single_line) + setCornerSize(cornerSize.toFloat()) + } + } + + private val dimensionConverter = DimensionConverter(resources) + + fun setFullScreen(isFullScreen: Boolean, animated: Boolean) { + if (!animated && views.composerLayout.layoutParams != null) { + views.composerLayout.updateLayoutParams { + height = + if (isFullScreen) ViewGroup.LayoutParams.MATCH_PARENT else ViewGroup.LayoutParams.WRAP_CONTENT + } + } + editText.updateLayoutParams { + height = if (isFullScreen) ViewGroup.LayoutParams.MATCH_PARENT else ViewGroup.LayoutParams.WRAP_CONTENT + } + + updateTextFieldBorder(isFullScreen) + updateEditTextVisibility() + + updateEditTextFullScreenState(views.richTextComposerEditText, isFullScreen) + updateEditTextFullScreenState(views.plainTextComposerEditText, isFullScreen) + + views.composerFullScreenButton.setImageResource( + if (isFullScreen) R.drawable.ic_composer_collapse else R.drawable.ic_composer_full_screen + ) + + views.bottomSheetHandle.isVisible = isFullScreen + if (isFullScreen) { + editText.showKeyboard(true) + } + this.isFullScreen = isFullScreen + } + + fun notifyIsBeingDragged(percentage: Float) { + // Calculate a new shape for the border according to the position in screen + val isSingleLine = editText.lineCount == 1 + val cornerSize = if (!isSingleLine || hasRelatedMessage) { + resources.getDimensionPixelSize(R.dimen.rich_text_composer_corner_radius_expanded).toFloat() + } else { + val multilineCornerSize = resources.getDimensionPixelSize(R.dimen.rich_text_composer_corner_radius_expanded) + val singleLineCornerSize = resources.getDimensionPixelSize(R.dimen.rich_text_composer_corner_radius_single_line) + val diff = singleLineCornerSize - multilineCornerSize + multilineCornerSize + diff * (1 - percentage) + } + if (cornerSize != borderShapeDrawable.bottomLeftCornerResolvedSize) { + borderShapeDrawable.setCornerSize(cornerSize) + } + + // Change maxLines while dragging, this should improve the smoothness of animations + val maxLines = if (percentage > 0.25f) { + Int.MAX_VALUE + } else { + MessageComposerView.MAX_LINES_WHEN_COLLAPSED + } + views.richTextComposerEditText.maxLines = maxLines + views.plainTextComposerEditText.maxLines = maxLines + + views.bottomSheetHandle.isVisible = true + } init { inflate(context, R.layout.composer_rich_text_layout, this) views = ComposerRichTextLayoutBinding.bind(this) - collapse(false) + // Workaround to avoid cut-off text caused by padding in scrolled TextView (there is no clipToPadding). + // In TextView, clipTop = padding, but also clipTop -= shadowRadius. So if we set the shadowRadius to padding, they cancel each other + views.richTextComposerEditText.setShadowLayer(views.richTextComposerEditText.paddingBottom.toFloat(), 0f, 0f, 0) + views.plainTextComposerEditText.setShadowLayer(views.richTextComposerEditText.paddingBottom.toFloat(), 0f, 0f, 0) - views.composerEditText.addTextChangedListener(object : TextWatcher { - private var previousTextWasExpanded = false + renderComposerMode(MessageComposerMode.Normal(null)) - override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} - override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {} - override fun afterTextChanged(s: Editable) { - callback?.onTextChanged(s) + views.richTextComposerEditText.addTextChangedListener( + TextChangeListener({ callback?.onTextChanged(it) }, { updateTextFieldBorder(isFullScreen) }) + ) + views.plainTextComposerEditText.addTextChangedListener( + TextChangeListener({ callback?.onTextChanged(it) }, { updateTextFieldBorder(isFullScreen) }) + ) - val isExpanded = s.lines().count() > 1 - if (previousTextWasExpanded != isExpanded) { - updateTextFieldBorder(isExpanded) - } - previousTextWasExpanded = isExpanded - } - }) + disallowParentInterceptTouchEvent(views.richTextComposerEditText) + disallowParentInterceptTouchEvent(views.plainTextComposerEditText) - views.composerRelatedMessageCloseButton.setOnClickListener { - collapse() + views.composerModeCloseView.setOnClickListener { callback?.onCloseRelatedMessage() } @@ -124,33 +205,129 @@ class RichTextComposerLayout @JvmOverloads constructor( callback?.onAddAttachment() } + views.composerFullScreenButton.apply { + updateFullScreenButtonVisibility() + setOnClickListener { + callback?.onFullScreenModeChanged() + } + } + + views.composerEditTextOuterBorder.background = borderShapeDrawable + setupRichTextMenu() + + updateTextFieldBorder(isFullScreen) } private fun setupRichTextMenu() { - addRichTextMenuItem(R.drawable.ic_composer_bold, R.string.rich_text_editor_format_bold, ComposerAction.Bold) { - views.composerEditText.toggleInlineFormat(InlineFormat.Bold) + addRichTextMenuItem(R.drawable.ic_composer_bold, R.string.rich_text_editor_format_bold, ComposerAction.BOLD) { + views.richTextComposerEditText.toggleInlineFormat(InlineFormat.Bold) + } + addRichTextMenuItem(R.drawable.ic_composer_italic, R.string.rich_text_editor_format_italic, ComposerAction.ITALIC) { + views.richTextComposerEditText.toggleInlineFormat(InlineFormat.Italic) } - addRichTextMenuItem(R.drawable.ic_composer_italic, R.string.rich_text_editor_format_italic, ComposerAction.Italic) { - views.composerEditText.toggleInlineFormat(InlineFormat.Italic) + addRichTextMenuItem(R.drawable.ic_composer_underlined, R.string.rich_text_editor_format_underline, ComposerAction.UNDERLINE) { + views.richTextComposerEditText.toggleInlineFormat(InlineFormat.Underline) } - addRichTextMenuItem(R.drawable.ic_composer_underlined, R.string.rich_text_editor_format_underline, ComposerAction.Underline) { - views.composerEditText.toggleInlineFormat(InlineFormat.Underline) + addRichTextMenuItem(R.drawable.ic_composer_strikethrough, R.string.rich_text_editor_format_strikethrough, ComposerAction.STRIKE_THROUGH) { + views.richTextComposerEditText.toggleInlineFormat(InlineFormat.StrikeThrough) } - addRichTextMenuItem(R.drawable.ic_composer_strikethrough, R.string.rich_text_editor_format_strikethrough, ComposerAction.StrikeThrough) { - views.composerEditText.toggleInlineFormat(InlineFormat.StrikeThrough) + addRichTextMenuItem(R.drawable.ic_composer_link, R.string.rich_text_editor_link, ComposerAction.LINK) { + views.richTextComposerEditText.getLinkAction()?.let { + when (it) { + LinkAction.InsertLink -> callback?.onSetLink(isTextSupported = true, initialLink = null) + is LinkAction.SetLink -> callback?.onSetLink(isTextSupported = false, initialLink = it.currentLink) + } + } } + } - views.composerEditText.menuStateChangedListener = EditorEditText.OnMenuStateChangedListener { state -> - if (state is MenuState.Update) { - updateMenuStateFor(ComposerAction.Bold, state) - updateMenuStateFor(ComposerAction.Italic, state) - updateMenuStateFor(ComposerAction.Underline, state) - updateMenuStateFor(ComposerAction.StrikeThrough, state) + fun setLink(link: String?) = + views.richTextComposerEditText.setLink(link) + + fun insertLink(link: String, text: String) = + views.richTextComposerEditText.insertLink(link, text) + + fun removeLink() = + views.richTextComposerEditText.removeLink() + + @SuppressLint("ClickableViewAccessibility") + private fun disallowParentInterceptTouchEvent(view: View) { + view.setOnTouchListener { v, event -> + if (v.hasFocus()) { + v.parent?.requestDisallowInterceptTouchEvent(true) + val action = event.actionMasked + if (action == MotionEvent.ACTION_SCROLL) { + v.parent?.requestDisallowInterceptTouchEvent(false) + return@setOnTouchListener true + } } + false } } + override fun onAttachedToWindow() { + super.onAttachedToWindow() + + views.richTextComposerEditText.actionStatesChangedListener = EditorEditText.OnActionStatesChangedListener { state -> + for (action in state.keys) { + updateMenuStateFor(action, state) + } + } + updateEditTextVisibility() + } + + fun setOnErrorListener(onError: (e: RichTextEditorException) -> Unit) { + views.richTextComposerEditText.rustErrorCollector = RustErrorCollector { + onError(RichTextEditorException(it)) + } + } + + private fun updateEditTextVisibility() { + views.richTextComposerEditText.isVisible = isTextFormattingEnabled + views.richTextMenu.isVisible = isTextFormattingEnabled + views.plainTextComposerEditText.isVisible = !isTextFormattingEnabled + + // The layouts for formatted text mode and plain text mode are different, so we need to update the constraints + val dpToPx = { dp: Int -> dimensionConverter.dpToPx(dp) } + ConstraintSet().apply { + clone(views.composerLayoutContent) + clear(R.id.composerEditTextOuterBorder, ConstraintSet.TOP) + clear(R.id.composerEditTextOuterBorder, ConstraintSet.BOTTOM) + clear(R.id.composerEditTextOuterBorder, ConstraintSet.START) + clear(R.id.composerEditTextOuterBorder, ConstraintSet.END) + if (isTextFormattingEnabled) { + connect(R.id.composerEditTextOuterBorder, ConstraintSet.TOP, R.id.composerLayoutContent, ConstraintSet.TOP, dpToPx(8)) + connect(R.id.composerEditTextOuterBorder, ConstraintSet.BOTTOM, R.id.sendButton, ConstraintSet.TOP, 0) + connect(R.id.composerEditTextOuterBorder, ConstraintSet.START, R.id.composerLayoutContent, ConstraintSet.START, dpToPx(12)) + connect(R.id.composerEditTextOuterBorder, ConstraintSet.END, R.id.composerLayoutContent, ConstraintSet.END, dpToPx(12)) + } else { + connect(R.id.composerEditTextOuterBorder, ConstraintSet.TOP, R.id.composerLayoutContent, ConstraintSet.TOP, dpToPx(8)) + connect(R.id.composerEditTextOuterBorder, ConstraintSet.BOTTOM, R.id.composerLayoutContent, ConstraintSet.BOTTOM, dpToPx(8)) + connect(R.id.composerEditTextOuterBorder, ConstraintSet.START, R.id.attachmentButton, ConstraintSet.END, 0) + connect(R.id.composerEditTextOuterBorder, ConstraintSet.END, R.id.sendButton, ConstraintSet.START, 0) + } + applyTo(views.composerLayoutContent) + } + } + + private fun updateFullScreenButtonVisibility() { + val isLargeScreenDevice = resources.configuration.isLayoutSizeAtLeast(Configuration.SCREENLAYOUT_SIZE_LARGE) + val isLandscape = resources.configuration.orientation == Configuration.ORIENTATION_LANDSCAPE + // There's no point in having full screen in landscape since there's almost no vertical space + views.composerFullScreenButton.isInvisible = !isTextFormattingEnabled || (isLandscape && !isLargeScreenDevice) + } + + /** + * Updates the non-active input with the contents of the active input. + */ + private fun syncEditTexts() = + if (isTextFormattingEnabled) { + views.plainTextComposerEditText.setText(views.richTextComposerEditText.getMarkdown()) + } else { + views.richTextComposerEditText.setMarkdown(views.plainTextComposerEditText.text.toString()) + } + private fun addRichTextMenuItem(@DrawableRes iconId: Int, @StringRes description: Int, action: ComposerAction, onClick: () -> Unit) { val inflater = LayoutInflater.from(context) val button = ViewRichTextMenuButtonBinding.inflate(inflater, views.richTextMenu, true) @@ -164,76 +341,130 @@ class RichTextComposerLayout @JvmOverloads constructor( } } - private fun updateMenuStateFor(action: ComposerAction, menuState: MenuState.Update) { + private fun updateMenuStateFor(action: ComposerAction, menuState: Map) { val button = findViewWithTag(action) ?: return - button.isEnabled = !menuState.disabledActions.contains(action) - button.isSelected = menuState.reversedActions.contains(action) + val stateForAction = menuState[action] + button.isEnabled = stateForAction != ActionState.DISABLED + button.isSelected = stateForAction == ActionState.REVERSED } - private fun updateTextFieldBorder(isExpanded: Boolean) { - val borderResource = if (isExpanded) { - R.drawable.bg_composer_rich_edit_text_expanded - } else { - R.drawable.bg_composer_rich_edit_text_single_line - } - views.composerEditTextOuterBorder.setBackgroundResource(borderResource) + fun estimateCollapsedHeight(): Int { + val editText = this.editText + val originalLines = editText.maxLines + val originalParamsHeight = editText.layoutParams.height + editText.maxLines = MessageComposerView.MAX_LINES_WHEN_COLLAPSED + editText.layoutParams.height = ViewGroup.LayoutParams.WRAP_CONTENT + measure( + MeasureSpec.makeMeasureSpec(width, MeasureSpec.EXACTLY), + MeasureSpec.UNSPECIFIED, + ) + val result = measuredHeight + editText.layoutParams.height = originalParamsHeight + editText.maxLines = originalLines + return result } - override fun replaceFormattedContent(text: CharSequence) { - views.composerEditText.setHtml(text.toString()) - } - - override fun collapse(animate: Boolean, transitionComplete: (() -> Unit)?) { - if (currentConstraintSetId == R.layout.composer_rich_text_layout_constraint_set_compact) { - // ignore we good - return - } - currentConstraintSetId = R.layout.composer_rich_text_layout_constraint_set_compact - applyNewConstraintSet(animate, transitionComplete) + private fun updateTextFieldBorder(isFullScreen: Boolean) { + val isMultiline = editText.editableText.lines().count() > 1 || isFullScreen || hasRelatedMessage + val cornerSize = if (isMultiline) { + resources.getDimensionPixelSize(R.dimen.rich_text_composer_corner_radius_expanded) + } else { + resources.getDimensionPixelSize(R.dimen.rich_text_composer_corner_radius_single_line) + }.toFloat() + borderShapeDrawable.setCornerSize(cornerSize) } - override fun expand(animate: Boolean, transitionComplete: (() -> Unit)?) { - if (currentConstraintSetId == R.layout.composer_rich_text_layout_constraint_set_expanded) { - // ignore we good - return - } - currentConstraintSetId = R.layout.composer_rich_text_layout_constraint_set_expanded - applyNewConstraintSet(animate, transitionComplete) + private fun replaceFormattedContent(text: CharSequence) { + views.richTextComposerEditText.setHtml(text.toString()) + updateTextFieldBorder(isFullScreen) } override fun setTextIfDifferent(text: CharSequence?): Boolean { - return views.composerEditText.setTextIfDifferent(text) + val result = editText.setTextIfDifferent(text) + updateTextFieldBorder(isFullScreen) + return result } - private fun applyNewConstraintSet(animate: Boolean, transitionComplete: (() -> Unit)?) { - // val wasSendButtonInvisible = views.sendButton.isInvisible - if (animate) { - configureAndBeginTransition(transitionComplete) - } - ConstraintSet().also { - it.clone(context, currentConstraintSetId) - it.applyTo(this) + private fun updateEditTextFullScreenState(editText: EditText, isFullScreen: Boolean) { + if (isFullScreen) { + editText.maxLines = Int.MAX_VALUE + } else { + editText.maxLines = MessageComposerView.MAX_LINES_WHEN_COLLAPSED } - // Might be updated by view state just after, but avoid blinks - // views.sendButton.isInvisible = wasSendButtonInvisible } - private fun configureAndBeginTransition(transitionComplete: (() -> Unit)? = null) { - val transition = TransitionSet().apply { - ordering = TransitionSet.ORDERING_SEQUENTIAL - addTransition(ChangeBounds()) - addTransition(Fade(Fade.IN)) - duration = animationDuration - addListener(object : SimpleTransitionListener() { - override fun onTransitionEnd(transition: Transition) { - transitionComplete?.invoke() + override fun renderComposerMode(mode: MessageComposerMode) { + if (mode is MessageComposerMode.Special) { + views.composerModeGroup.isVisible = true + if (isTextFormattingEnabled) { + replaceFormattedContent(mode.defaultContent) + } else { + views.plainTextComposerEditText.setText(mode.defaultContent) + } + hasRelatedMessage = true + editText.showKeyboard(andRequestFocus = true) + } else { + views.composerModeGroup.isGone = true + (mode as? MessageComposerMode.Normal)?.content?.let { text -> + if (isTextFormattingEnabled) { + replaceFormattedContent(text) + } else { + views.plainTextComposerEditText.setText(text) } - }) + } + hasRelatedMessage = false + } + + updateTextFieldBorder(isFullScreen) + + if (this.composerMode == mode) return + this.composerMode = mode + + views.sendButton.apply { + if (mode is MessageComposerMode.Edit) { + contentDescription = resources.getString(R.string.action_save) + setImageResource(R.drawable.ic_composer_rich_text_save) + } else { + contentDescription = resources.getString(R.string.action_send) + setImageResource(R.drawable.ic_rich_composer_send) + } + } + + when (mode) { + is MessageComposerMode.Edit -> { + views.composerModeTitleView.setText(R.string.editing) + views.composerModeIconView.setImageResource(R.drawable.ic_composer_rich_text_editor_edit) + } + is MessageComposerMode.Quote -> { + views.composerModeTitleView.setText(R.string.quoting) + views.composerModeIconView.setImageResource(R.drawable.ic_quote) + } + is MessageComposerMode.Reply -> { + val senderInfo = mode.event.senderInfo + val userName = senderInfo.displayName ?: senderInfo.disambiguatedDisplayName + views.composerModeTitleView.text = resources.getString(R.string.replying_to, userName) + views.composerModeIconView.setImageResource(R.drawable.ic_reply) + } + else -> Unit } - TransitionManager.beginDelayedTransition((parent as? ViewGroup ?: this), transition) } - override fun setInvisible(isInvisible: Boolean) { - this.isInvisible = isInvisible + private class TextChangeListener( + private val onTextChanged: (s: Editable) -> Unit, + private val onExpandedChanged: (isExpanded: Boolean) -> Unit, + ) : TextWatcher { + private var previousTextWasExpanded = false + + override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {} + override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {} + override fun afterTextChanged(s: Editable) { + onTextChanged.invoke(s) + + val isExpanded = s.lines().count() > 1 + if (previousTextWasExpanded != isExpanded) { + onExpandedChanged(isExpanded) + } + previousTextWasExpanded = isExpanded + } } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/RichTextEditorException.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/RichTextEditorException.kt new file mode 100644 index 0000000000..9bdef59ae3 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/RichTextEditorException.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2022 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 im.vector.app.features.home.room.detail.composer + +internal class RichTextEditorException( + cause: Throwable, +) : Exception(cause) diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/link/SetLinkAction.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/link/SetLinkAction.kt new file mode 100644 index 0000000000..5cc31022ea --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/link/SetLinkAction.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2022 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 im.vector.app.features.home.room.detail.composer.link + +import im.vector.app.core.platform.VectorViewModelAction + +sealed class SetLinkAction : VectorViewModelAction { + data class LinkChanged( + val newLink: String + ) : SetLinkAction() + + data class Save( + val link: String, + val text: String, + ) : SetLinkAction() +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/link/SetLinkFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/link/SetLinkFragment.kt new file mode 100644 index 0000000000..008a8017ee --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/link/SetLinkFragment.kt @@ -0,0 +1,131 @@ +/* + * 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 im.vector.app.features.home.room.detail.composer.link + +import android.os.Bundle +import android.os.Parcelable +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.core.view.isGone +import androidx.fragment.app.FragmentManager +import androidx.fragment.app.viewModels +import androidx.lifecycle.lifecycleScope +import com.airbnb.mvrx.args +import com.airbnb.mvrx.fragmentViewModel +import com.airbnb.mvrx.withState +import dagger.hilt.android.AndroidEntryPoint +import im.vector.app.R +import im.vector.app.core.platform.VectorBaseDialogFragment +import im.vector.app.databinding.FragmentSetLinkBinding +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.parcelize.Parcelize +import reactivecircus.flowbinding.android.widget.textChanges + +@AndroidEntryPoint +class SetLinkFragment : + VectorBaseDialogFragment() { + + @Parcelize + data class Args( + val isTextSupported: Boolean, + val initialLink: String?, + ) : Parcelable + + private val viewModel: SetLinkViewModel by fragmentViewModel() + private val sharedActionViewModel: SetLinkSharedActionViewModel by viewModels( + ownerProducer = { requireParentFragment() } + ) + private val args: Args by args() + + override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentSetLinkBinding { + return FragmentSetLinkBinding.inflate(inflater, container, false) + } + + companion object { + fun show(isTextSupported: Boolean, initialLink: String?, fragmentManager: FragmentManager) = + SetLinkFragment().apply { + setArguments(Args(isTextSupported, initialLink)) + }.show(fragmentManager, "SetLinkBottomSheet") + } + + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + + views.link.setText(args.initialLink) + views.link.textChanges() + .onEach { + viewModel.handle(SetLinkAction.LinkChanged(it.toString())) + } + .launchIn(viewLifecycleOwner.lifecycleScope) + + views.save.debouncedClicks { + viewModel.handle( + SetLinkAction.Save( + link = views.link.text.toString(), + text = views.text.text.toString(), + ) + ) + } + + views.cancel.debouncedClicks(::onCancel) + views.remove.debouncedClicks(::onRemove) + + viewModel.observeViewEvents { + when (it) { + is SetLinkViewEvents.SavedLinkAndText -> handleInsert(link = it.link, text = it.text) + is SetLinkViewEvents.SavedLink -> handleSet(link = it.link) + } + } + + views.toolbar.setNavigationOnClickListener { + dismiss() + } + } + + override fun invalidate() = withState(viewModel) { viewState -> + views.toolbar.title = getString( + if (viewState.initialLink != null) { + R.string.set_link_edit + } else { + R.string.set_link_create + } + ) + + views.remove.isGone = !viewState.removeVisible + views.save.isEnabled = viewState.saveEnabled + views.textLayout.isGone = !viewState.isTextSupported + } + + private fun handleInsert(link: String, text: String) { + sharedActionViewModel.post(SetLinkSharedAction.Insert(text, link)) + dismiss() + } + + private fun handleSet(link: String) { + sharedActionViewModel.post(SetLinkSharedAction.Set(link)) + dismiss() + } + + private fun onRemove() { + sharedActionViewModel.post(SetLinkSharedAction.Remove) + dismiss() + } + + private fun onCancel() = dismiss() +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/link/SetLinkSharedActionViewModel.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/link/SetLinkSharedActionViewModel.kt new file mode 100644 index 0000000000..fb9f3f0d5b --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/link/SetLinkSharedActionViewModel.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2022 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 im.vector.app.features.home.room.detail.composer.link + +import im.vector.app.core.platform.VectorSharedAction +import im.vector.app.core.platform.VectorSharedActionViewModel +import javax.inject.Inject + +class SetLinkSharedActionViewModel @Inject constructor() : + VectorSharedActionViewModel() + +sealed interface SetLinkSharedAction : VectorSharedAction { + data class Set( + val link: String, + ) : SetLinkSharedAction + + data class Insert( + val text: String, + val link: String, + ) : SetLinkSharedAction + + object Remove : SetLinkSharedAction +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/link/SetLinkViewEvents.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/link/SetLinkViewEvents.kt new file mode 100644 index 0000000000..cd42651c22 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/link/SetLinkViewEvents.kt @@ -0,0 +1,31 @@ +/* + * 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 im.vector.app.features.home.room.detail.composer.link + +import im.vector.app.core.platform.VectorViewEvents + +sealed class SetLinkViewEvents : VectorViewEvents { + + data class SavedLink( + val link: String, + ) : SetLinkViewEvents() + + data class SavedLinkAndText( + val link: String, + val text: String, + ) : SetLinkViewEvents() +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/link/SetLinkViewModel.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/link/SetLinkViewModel.kt new file mode 100644 index 0000000000..9a5b5cd8dd --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/link/SetLinkViewModel.kt @@ -0,0 +1,55 @@ +/* + * 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 im.vector.app.features.home.room.detail.composer.link + +import com.airbnb.mvrx.MavericksViewModelFactory +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import im.vector.app.core.di.MavericksAssistedViewModelFactory +import im.vector.app.core.di.hiltMavericksViewModelFactory +import im.vector.app.core.platform.VectorViewModel + +class SetLinkViewModel @AssistedInject constructor( + @Assisted private val initialState: SetLinkViewState, +) : VectorViewModel(initialState) { + + @AssistedFactory + interface Factory : MavericksAssistedViewModelFactory { + override fun create(initialState: SetLinkViewState): SetLinkViewModel + } + + companion object : MavericksViewModelFactory by hiltMavericksViewModelFactory() + + override fun handle(action: SetLinkAction) = when (action) { + is SetLinkAction.LinkChanged -> handleLinkChanged(action.newLink) + is SetLinkAction.Save -> handleSave(action.link, action.text) + } + + private fun handleLinkChanged(newLink: String) = setState { + copy(saveEnabled = newLink != initialLink.orEmpty()) + } + + private fun handleSave( + link: String, + text: String + ) = if (initialState.isTextSupported) { + _viewEvents.post(SetLinkViewEvents.SavedLinkAndText(link, text)) + } else { + _viewEvents.post(SetLinkViewEvents.SavedLink(link)) + } +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/link/SetLinkViewState.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/link/SetLinkViewState.kt new file mode 100644 index 0000000000..ea61f7eb72 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/link/SetLinkViewState.kt @@ -0,0 +1,34 @@ +/* + * 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 im.vector.app.features.home.room.detail.composer.link + +import com.airbnb.mvrx.MavericksState + +data class SetLinkViewState( + val isTextSupported: Boolean, + val initialLink: String?, + val saveEnabled: Boolean, +) : MavericksState { + + constructor(args: SetLinkFragment.Args) : this( + isTextSupported = args.isTextSupported, + initialLink = args.initialLink, + saveEnabled = false, + ) + + val removeVisible = initialLink != null +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageRecorderView.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageRecorderView.kt index 13e0477ab6..a7b926f29a 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageRecorderView.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageRecorderView.kt @@ -189,11 +189,9 @@ class VoiceMessageRecorderView @JvmOverloads constructor( val startMs = ((clock.epochMillis() - startAt)).coerceAtLeast(0) recordingTicker?.stop() recordingTicker = CountUpTimer().apply { - tickListener = object : CountUpTimer.TickListener { - override fun onTick(milliseconds: Long) { - val isLocked = startFromLocked || lastKnownState is RecordingUiState.Locked - onRecordingTick(isLocked, milliseconds + startMs) - } + tickListener = CountUpTimer.TickListener { milliseconds -> + val isLocked = startFromLocked || lastKnownState is RecordingUiState.Locked + onRecordingTick(isLocked, milliseconds + startMs) } resume() } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageViews.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageViews.kt index cd41219371..ca31c53bb3 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageViews.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/composer/voice/VoiceMessageViews.kt @@ -147,7 +147,8 @@ class VoiceMessageViews( } fun showRecordingViews() { - views.voiceMessageMicButton.setImageResource(R.drawable.ic_voice_mic_recording) + views.voiceMessageBackgroundView.isVisible = true + views.voiceMessageMicButton.setImageResource(R.drawable.ic_composer_rich_mic_pressed) views.voiceMessageMicButton.setAttributeTintedBackground(R.drawable.circle_with_halo, R.attr.colorPrimary) views.voiceMessageMicButton.updateLayoutParams { setMargins(0, 0, 0, 0) @@ -172,6 +173,7 @@ class VoiceMessageViews( fun hideRecordingViews(recordingState: RecordingUiState) { // We need to animate the lock image first + views.voiceMessageBackgroundView.isVisible = false if (recordingState !is RecordingUiState.Locked) { views.voiceMessageLockImage.isVisible = false views.voiceMessageLockImage.animate().translationY(0f).start() @@ -278,6 +280,7 @@ class VoiceMessageViews( fun showDraftViews() { hideRecordingViews(RecordingUiState.Idle) + views.voiceMessageBackgroundView.isVisible = true views.voiceMessageMicButton.isVisible = false views.voiceMessageSendButton.isVisible = true views.voiceMessagePlaybackLayout.isVisible = true @@ -288,6 +291,7 @@ class VoiceMessageViews( fun showRecordingLockedViews(recordingState: RecordingUiState) { hideRecordingViews(recordingState) + views.voiceMessageBackgroundView.isVisible = true views.voiceMessagePlaybackLayout.isVisible = true views.voiceMessagePlaybackTimerIndicator.isVisible = true views.voicePlaybackControlButton.isVisible = false diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/TimelineEventController.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/TimelineEventController.kt index ec9d58ad80..5e8bf67d97 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/TimelineEventController.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/TimelineEventController.kt @@ -32,6 +32,7 @@ import im.vector.app.core.extensions.localDateTime import im.vector.app.core.extensions.nextOrNull import im.vector.app.core.extensions.prevOrNull import im.vector.app.core.time.Clock +import im.vector.app.features.home.AvatarRenderer import im.vector.app.features.home.room.detail.JitsiState import im.vector.app.features.home.room.detail.RoomDetailAction import im.vector.app.features.home.room.detail.RoomDetailViewState @@ -58,6 +59,7 @@ import im.vector.app.features.home.room.detail.timeline.item.MessageInformationD import im.vector.app.features.home.room.detail.timeline.item.ReactionsSummaryEvents import im.vector.app.features.home.room.detail.timeline.item.ReadReceiptData import im.vector.app.features.home.room.detail.timeline.item.ReadReceiptsItem +import im.vector.app.features.home.room.detail.timeline.item.TypingItem_ import im.vector.app.features.home.room.detail.timeline.url.PreviewUrlRetriever import im.vector.app.features.media.AttachmentData import im.vector.app.features.media.ImageContentRenderer @@ -73,6 +75,7 @@ import org.matrix.android.sdk.api.session.room.model.RoomSummary import org.matrix.android.sdk.api.session.room.model.message.MessageAudioContent import org.matrix.android.sdk.api.session.room.model.message.MessageImageInfoContent import org.matrix.android.sdk.api.session.room.model.message.MessageVideoContent +import org.matrix.android.sdk.api.session.room.read.ReadService import org.matrix.android.sdk.api.session.room.timeline.Timeline import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent import timber.log.Timber @@ -96,6 +99,7 @@ class TimelineEventController @Inject constructor( private val readReceiptsItemFactory: ReadReceiptsItemFactory, private val reactionListFactory: ReactionsSummaryFactory, private val clock: Clock, + private val avatarRenderer: AvatarRenderer, ) : EpoxyController(backgroundHandler, backgroundHandler), Timeline.Listener, EpoxyController.Interceptor { /** @@ -106,7 +110,7 @@ class TimelineEventController @Inject constructor( val highlightedEventId: String? = null, val jitsiState: JitsiState = JitsiState(), val roomSummary: RoomSummary? = null, - val rootThreadEventId: String? = null + val rootThreadEventId: String? = null, ) { constructor(state: RoomDetailViewState) : this( @@ -114,7 +118,7 @@ class TimelineEventController @Inject constructor( highlightedEventId = state.highlightedEventId, jitsiState = state.jitsiState, roomSummary = state.asyncRoomSummary(), - rootThreadEventId = state.rootThreadEventId + rootThreadEventId = state.rootThreadEventId, ) fun isFromThreadTimeline(): Boolean = rootThreadEventId != null @@ -288,7 +292,7 @@ class TimelineEventController @Inject constructor( private val interceptorHelper = TimelineControllerInterceptorHelper( ::positionOfReadMarker, - adapterPositionMapping + adapterPositionMapping, ) init { @@ -337,6 +341,12 @@ class TimelineEventController @Inject constructor( .setVisibilityStateChangedListener(Timeline.Direction.FORWARDS) .addWhenLoading(Timeline.Direction.FORWARDS) + if (!showingForwardLoader) { + val typingUsers = partialState.roomSummary?.typingUsers.orEmpty() + val typingItem = TypingItem_().id("typing_view").avatarRenderer(avatarRenderer).users(typingUsers) + add(typingItem) + } + val timelineModels = getModels() add(timelineModels) if (hasReachedInvite && hasUTD) { @@ -436,6 +446,7 @@ class TimelineEventController @Inject constructor( val timelineEventsGroup = timelineEventsGroups.getOrNull(event) val params = TimelineItemFactoryParams( event = event, + lastEdit = event.annotations?.editSummary?.latestEdit, prevEvent = prevEvent, prevDisplayableEvent = prevDisplayableEvent, nextEvent = nextEvent, @@ -509,7 +520,7 @@ class TimelineEventController @Inject constructor( event.eventId, readReceipts, callback, - partialState.isFromThreadTimeline() + partialState.isFromThreadTimeline(), ), formattedDayModel = formattedDayModel, mergedHeaderModel = mergedHeaderModel @@ -552,7 +563,7 @@ class TimelineEventController @Inject constructor( val event = itr.previous() timelineEventsGroups.addOrIgnore(event) val currentReadReceipts = ArrayList(event.readReceipts).filter { - it.roomMember.userId != session.myUserId + it.roomMember.userId != session.myUserId && it.isVisibleInThisThread() } if (timelineEventVisibilityHelper.shouldShowEvent( timelineEvent = event, @@ -570,6 +581,14 @@ class TimelineEventController @Inject constructor( } } + private fun ReadReceipt.isVisibleInThisThread(): Boolean { + return if (partialState.isFromThreadTimeline()) { + this.threadId == partialState.rootThreadEventId + } else { + this.threadId == null || this.threadId == ReadService.THREAD_ID_MAIN + } + } + private fun buildDaySeparatorItem(originServerTs: Long?): DaySeparatorItem { val formattedDay = dateFormatter.format(originServerTs, DateFormatKind.TIMELINE_DAY_DIVIDER) return DaySeparatorItem_().formattedDay(formattedDay).id(formattedDay) diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/CheckIfCanRedactEventUseCase.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/CheckIfCanRedactEventUseCase.kt index 8cb82691d9..dedd4a53c8 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/CheckIfCanRedactEventUseCase.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/CheckIfCanRedactEventUseCase.kt @@ -33,8 +33,8 @@ class CheckIfCanRedactEventUseCase @Inject constructor( EventType.STICKER, VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO, ) + - EventType.POLL_START + - EventType.STATE_ROOM_BEACON_INFO + EventType.POLL_START.values + + EventType.STATE_ROOM_BEACON_INFO.values return event.root.getClearType() in canRedactEventTypes && // Message sent by the current user can always be redacted, else check permission for messages sent by other users diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/CheckIfCanReplyEventUseCase.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/CheckIfCanReplyEventUseCase.kt index c312ef31b7..a9df059cc1 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/CheckIfCanReplyEventUseCase.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/CheckIfCanReplyEventUseCase.kt @@ -26,7 +26,7 @@ class CheckIfCanReplyEventUseCase @Inject constructor() { fun execute(event: TimelineEvent, messageContent: MessageContent?, actionPermissions: ActionPermissions): Boolean { // Only EventType.MESSAGE, EventType.POLL_START and EventType.STATE_ROOM_BEACON_INFO event types are supported for the moment - if (event.root.getClearType() !in EventType.STATE_ROOM_BEACON_INFO + EventType.POLL_START + EventType.MESSAGE) return false + if (event.root.getClearType() !in EventType.STATE_ROOM_BEACON_INFO.values + EventType.POLL_START.values + EventType.MESSAGE) return false if (!actionPermissions.canSendMessage) return false return when (messageContent?.msgType) { MessageType.MSGTYPE_TEXT, diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionsViewModel.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionsViewModel.kt index 2f551590ea..4a0032ae37 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionsViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/action/MessageActionsViewModel.kt @@ -215,7 +215,7 @@ class MessageActionsViewModel @AssistedInject constructor( EventType.CALL_ANSWER -> { noticeEventFormatter.format(timelineEvent, room?.roomSummary()?.isDirect.orFalse()) } - in EventType.POLL_START -> { + in EventType.POLL_START.values -> { timelineEvent.root.getClearContent().toModel(catchError = true) ?.getBestPollCreationInfo()?.question?.getBestQuestion() ?: "" } @@ -382,7 +382,7 @@ class MessageActionsViewModel @AssistedInject constructor( } if (canRedact(timelineEvent, actionPermissions)) { - if (timelineEvent.root.getClearType() in EventType.POLL_START) { + if (timelineEvent.root.getClearType() in EventType.POLL_START.values) { add( EventSharedAction.Redact( eventId, @@ -532,13 +532,13 @@ class MessageActionsViewModel @AssistedInject constructor( private fun canViewReactions(event: TimelineEvent): Boolean { // Only event of type EventType.MESSAGE, EventType.STICKER and EventType.POLL_START are supported for the moment - if (event.root.getClearType() !in listOf(EventType.MESSAGE, EventType.STICKER) + EventType.POLL_START) return false + if (event.root.getClearType() !in listOf(EventType.MESSAGE, EventType.STICKER) + EventType.POLL_START.values) return false return event.annotations?.reactionsSummary?.isNotEmpty() ?: false } private fun canEdit(event: TimelineEvent, myUserId: String, actionPermissions: ActionPermissions): Boolean { // Only event of type EventType.MESSAGE and EventType.POLL_START are supported for the moment - if (event.root.getClearType() !in listOf(EventType.MESSAGE) + EventType.POLL_START) return false + if (event.root.getClearType() !in listOf(EventType.MESSAGE) + EventType.POLL_START.values) return false if (!actionPermissions.canSendMessage) return false // TODO if user is admin or moderator val messageContent = event.root.getClearContent().toModel() @@ -584,14 +584,14 @@ class MessageActionsViewModel @AssistedInject constructor( } private fun canEndPoll(event: TimelineEvent, actionPermissions: ActionPermissions): Boolean { - return event.root.getClearType() in EventType.POLL_START && + return event.root.getClearType() in EventType.POLL_START.values && canRedact(event, actionPermissions) && event.annotations?.pollResponseSummary?.closedTime == null } private fun canEditPoll(event: TimelineEvent): Boolean { - return event.root.getClearType() in EventType.POLL_START && + return event.root.getClearType() in EventType.POLL_START.values && event.annotations?.pollResponseSummary?.closedTime == null && - event.annotations?.pollResponseSummary?.aggregatedContent?.totalVotes ?: 0 == 0 + (event.annotations?.pollResponseSummary?.aggregatedContent?.totalVotes ?: 0) == 0 } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/CallItemFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/CallItemFactory.kt index 360dbc3fbe..35f5af2e44 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/CallItemFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/CallItemFactory.kt @@ -17,6 +17,7 @@ package im.vector.app.features.home.room.detail.timeline.factory import im.vector.app.core.epoxy.VectorEpoxyModel import im.vector.app.core.resources.UserPreferencesProvider +import im.vector.app.features.VectorFeatures import im.vector.app.features.home.room.detail.timeline.MessageColorProvider import im.vector.app.features.home.room.detail.timeline.TimelineEventController import im.vector.app.features.home.room.detail.timeline.helper.AvatarSizeProvider @@ -34,6 +35,7 @@ import org.matrix.android.sdk.api.util.toMatrixItem import javax.inject.Inject class CallItemFactory @Inject constructor( + private val vectorFeatures: VectorFeatures, private val session: Session, private val userPreferencesProvider: UserPreferencesProvider, private val messageColorProvider: MessageColorProvider, @@ -145,6 +147,7 @@ class CallItemFactory @Inject constructor( val userOfInterest = roomSummary.toMatrixItem() val attributes = messageItemAttributesFactory.create(null, informationData, callback, reactionsSummaryEvents).let { CallTileTimelineItem.Attributes( + isVoipSupported = vectorFeatures.tchapIsVoipSupported(), callId = callId, callKind = callKind, callStatus = callStatus, diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MessageItemFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MessageItemFactory.kt index f613ae8a06..88536fef0a 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MessageItemFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MessageItemFactory.kt @@ -46,6 +46,7 @@ import im.vector.app.features.home.room.detail.timeline.helper.MessageInformatio import im.vector.app.features.home.room.detail.timeline.helper.MessageItemAttributesFactory import im.vector.app.features.home.room.detail.timeline.helper.TimelineMediaSizeProvider import im.vector.app.features.home.room.detail.timeline.item.AbsMessageItem +import im.vector.app.features.home.room.detail.timeline.item.BaseEventItem import im.vector.app.features.home.room.detail.timeline.item.MessageAudioItem import im.vector.app.features.home.room.detail.timeline.item.MessageAudioItem_ import im.vector.app.features.home.room.detail.timeline.item.MessageFileItem @@ -66,6 +67,7 @@ import im.vector.app.features.home.room.detail.timeline.item.RedactedMessageItem import im.vector.app.features.home.room.detail.timeline.item.VerificationRequestItem import im.vector.app.features.home.room.detail.timeline.item.VerificationRequestItem_ import im.vector.app.features.home.room.detail.timeline.render.EventTextRenderer +import im.vector.app.features.home.room.detail.timeline.render.ProcessBodyOfReplyToEventUseCase import im.vector.app.features.home.room.detail.timeline.tools.createLinkMovementMethod import im.vector.app.features.home.room.detail.timeline.tools.linkify import im.vector.app.features.html.EventHtmlRenderer @@ -107,6 +109,7 @@ import org.matrix.android.sdk.api.session.room.model.message.MessageVideoContent import org.matrix.android.sdk.api.session.room.model.message.asMessageAudioEvent import org.matrix.android.sdk.api.session.room.model.message.getFileUrl import org.matrix.android.sdk.api.session.room.model.message.getThumbnailUrl +import org.matrix.android.sdk.api.session.room.model.relation.ReplyToContent import org.matrix.android.sdk.api.settings.LightweightSettingsStorage import org.matrix.android.sdk.api.util.MimeTypes import javax.inject.Inject @@ -141,6 +144,7 @@ class MessageItemFactory @Inject constructor( private val liveLocationShareMessageItemFactory: LiveLocationShareMessageItemFactory, private val pollItemViewStateFactory: PollItemViewStateFactory, private val voiceBroadcastItemFactory: VoiceBroadcastItemFactory, + private val processBodyOfReplyToEventUseCase: ProcessBodyOfReplyToEventUseCase, ) { // TODO inject this properly? @@ -202,8 +206,8 @@ class MessageItemFactory @Inject constructor( is MessageVerificationRequestContent -> buildVerificationRequestMessageItem(messageContent, informationData, highlight, callback, attributes) is MessagePollContent -> buildPollItem(messageContent, informationData, highlight, callback, attributes) is MessageLocationContent -> buildLocationItem(messageContent, informationData, highlight, attributes) - is MessageBeaconInfoContent -> liveLocationShareMessageItemFactory.create(params.event, highlight, attributes) - is MessageVoiceBroadcastInfoContent -> voiceBroadcastItemFactory.create(params, messageContent, highlight, callback, attributes) + is MessageBeaconInfoContent -> liveLocationShareMessageItemFactory.create(event, highlight, attributes) + is MessageVoiceBroadcastInfoContent -> voiceBroadcastItemFactory.create(params, messageContent, highlight, attributes) else -> buildNotHandledMessageItem(messageContent, informationData, highlight, callback, attributes) } return messageItem?.apply { @@ -326,9 +330,11 @@ class MessageItemFactory @Inject constructor( informationData: MessageInformationData, highlight: Boolean, attributes: AbsMessageItem.Attributes - ): MessageVoiceItem? { + ): BaseEventItem<*>? { // Do not display voice broadcast messages - if (params.event.root.asMessageAudioEvent().isVoiceBroadcast()) return null + if (params.event.root.asMessageAudioEvent().isVoiceBroadcast()) { + return noticeItemFactory.create(params) + } val fileUrl = getAudioFileUrl(messageContent, informationData) val playbackControlButtonClickListener = createOnPlaybackButtonClickListener(messageContent, informationData, params) @@ -448,7 +454,14 @@ class MessageItemFactory @Inject constructor( attributes: AbsMessageItem.Attributes ): MessageTextItem? { // For compatibility reason we should display the body - return buildMessageTextItem(messageContent.body, false, informationData, highlight, callback, attributes) + return buildMessageTextItem( + messageContent.body, + false, + informationData, + highlight, + callback, + attributes, + ) } private fun buildImageMessageItem( @@ -554,7 +567,8 @@ class MessageItemFactory @Inject constructor( ): VectorEpoxyModel<*>? { val matrixFormattedBody = messageContent.matrixFormattedBody return if (matrixFormattedBody != null) { - buildFormattedTextItem(matrixFormattedBody, informationData, highlight, callback, attributes) + val replyToContent = messageContent.relatesTo?.inReplyTo + buildFormattedTextItem(matrixFormattedBody, informationData, highlight, callback, attributes, replyToContent) } else { buildMessageTextItem(messageContent.body, false, informationData, highlight, callback, attributes) } @@ -566,10 +580,21 @@ class MessageItemFactory @Inject constructor( highlight: Boolean, callback: TimelineEventController.Callback?, attributes: AbsMessageItem.Attributes, + replyToContent: ReplyToContent?, ): MessageTextItem? { - val compressed = htmlCompressor.compress(matrixFormattedBody) + val processedBody = replyToContent + ?.let { processBodyOfReplyToEventUseCase.execute(roomId, matrixFormattedBody, it) } + ?: matrixFormattedBody + val compressed = htmlCompressor.compress(processedBody) val renderedFormattedBody = htmlRenderer.get().render(compressed, pillsPostProcessor) as Spanned - return buildMessageTextItem(renderedFormattedBody, true, informationData, highlight, callback, attributes) + return buildMessageTextItem( + renderedFormattedBody, + true, + informationData, + highlight, + callback, + attributes, + ) } private fun buildMessageTextItem( diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/ReadReceiptsItemFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/ReadReceiptsItemFactory.kt index 6a711ec2dc..8607af6891 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/ReadReceiptsItemFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/ReadReceiptsItemFactory.kt @@ -21,16 +21,20 @@ import im.vector.app.features.home.room.detail.timeline.TimelineEventController import im.vector.app.features.home.room.detail.timeline.item.ReadReceiptData import im.vector.app.features.home.room.detail.timeline.item.ReadReceiptsItem import im.vector.app.features.home.room.detail.timeline.item.ReadReceiptsItem_ +import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.room.model.ReadReceipt import javax.inject.Inject -class ReadReceiptsItemFactory @Inject constructor(private val avatarRenderer: AvatarRenderer) { +class ReadReceiptsItemFactory @Inject constructor( + private val avatarRenderer: AvatarRenderer, + private val session: Session +) { fun create( eventId: String, readReceipts: List, callback: TimelineEventController.Callback?, - isFromThreadTimeLine: Boolean + isFromThreadTimeLine: Boolean, ): ReadReceiptsItem? { if (readReceipts.isEmpty()) { return null @@ -40,12 +44,13 @@ class ReadReceiptsItemFactory @Inject constructor(private val avatarRenderer: Av ReadReceiptData(it.roomMember.userId, it.roomMember.avatarUrl, it.roomMember.displayName, it.originServerTs) } .sortedByDescending { it.timestamp } + val threadReadReceiptsSupported = session.homeServerCapabilitiesService().getHomeServerCapabilities().canUseThreadReadReceiptsAndNotifications return ReadReceiptsItem_() .id("read_receipts_$eventId") .eventId(eventId) .readReceipts(readReceiptsData) .avatarRenderer(avatarRenderer) - .shouldHideReadReceipts(isFromThreadTimeLine) + .shouldHideReadReceipts(isFromThreadTimeLine && !threadReadReceiptsSupported) .clickListener { callback?.onReadReceiptsClicked(readReceiptsData) } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/TimelineItemFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/TimelineItemFactory.kt index 31ff257214..ae3ea143a7 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/TimelineItemFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/TimelineItemFactory.kt @@ -88,7 +88,7 @@ class TimelineItemFactory @Inject constructor( EventType.STATE_ROOM_ENCRYPTION -> encryptionItemFactory.create(params) // State room create EventType.STATE_ROOM_CREATE -> roomCreateItemFactory.create(params) - in EventType.STATE_ROOM_BEACON_INFO -> messageItemFactory.create(params) + in EventType.STATE_ROOM_BEACON_INFO.values -> messageItemFactory.create(params) VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO -> messageItemFactory.create(params) // Unhandled state event types else -> { @@ -101,7 +101,7 @@ class TimelineItemFactory @Inject constructor( when (event.root.getClearType()) { // Message itemsX EventType.STICKER, - in EventType.POLL_START, + in EventType.POLL_START.values, EventType.MESSAGE -> messageItemFactory.create(params) EventType.REDACTION, EventType.KEY_VERIFICATION_ACCEPT, @@ -114,9 +114,9 @@ class TimelineItemFactory @Inject constructor( EventType.CALL_SELECT_ANSWER, EventType.CALL_NEGOTIATE, EventType.REACTION, - in EventType.POLL_RESPONSE, - in EventType.POLL_END -> noticeItemFactory.create(params) - in EventType.BEACON_LOCATION_DATA -> { + in EventType.POLL_RESPONSE.values, + in EventType.POLL_END.values -> noticeItemFactory.create(params) + in EventType.BEACON_LOCATION_DATA.values -> { if (event.root.isRedacted()) { messageItemFactory.create(params) } else { diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/TimelineItemFactoryParams.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/TimelineItemFactoryParams.kt index 7c02b6f058..dd4494a613 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/TimelineItemFactoryParams.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/TimelineItemFactoryParams.kt @@ -19,10 +19,12 @@ package im.vector.app.features.home.room.detail.timeline.factory import im.vector.app.features.home.room.detail.timeline.TimelineEventController import im.vector.app.features.home.room.detail.timeline.helper.TimelineEventsGroup import im.vector.app.features.home.room.detail.timeline.item.ReactionsSummaryEvents +import org.matrix.android.sdk.api.session.events.model.Event import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent data class TimelineItemFactoryParams( val event: TimelineEvent, + val lastEdit: Event? = null, val prevEvent: TimelineEvent? = null, val prevDisplayableEvent: TimelineEvent? = null, val nextEvent: TimelineEvent? = null, diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/VoiceBroadcastItemFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/VoiceBroadcastItemFactory.kt index 5dc601a91a..cc3a015120 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/VoiceBroadcastItemFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/VoiceBroadcastItemFactory.kt @@ -15,26 +15,28 @@ */ package im.vector.app.features.home.room.detail.timeline.factory -import im.vector.app.core.epoxy.VectorEpoxyHolder -import im.vector.app.core.epoxy.VectorEpoxyModel import im.vector.app.core.resources.ColorProvider import im.vector.app.core.resources.DrawableProvider -import im.vector.app.features.home.room.detail.timeline.TimelineEventController +import im.vector.app.features.displayname.getBestName +import im.vector.app.features.home.room.detail.timeline.helper.AudioMessagePlaybackTracker import im.vector.app.features.home.room.detail.timeline.helper.AvatarSizeProvider import im.vector.app.features.home.room.detail.timeline.helper.VoiceBroadcastEventsGroup import im.vector.app.features.home.room.detail.timeline.item.AbsMessageItem +import im.vector.app.features.home.room.detail.timeline.item.AbsMessageVoiceBroadcastItem +import im.vector.app.features.home.room.detail.timeline.item.BaseEventItem import im.vector.app.features.home.room.detail.timeline.item.MessageVoiceBroadcastListeningItem import im.vector.app.features.home.room.detail.timeline.item.MessageVoiceBroadcastListeningItem_ import im.vector.app.features.home.room.detail.timeline.item.MessageVoiceBroadcastRecordingItem import im.vector.app.features.home.room.detail.timeline.item.MessageVoiceBroadcastRecordingItem_ -import im.vector.app.features.voicebroadcast.VoiceBroadcastPlayer -import im.vector.app.features.voicebroadcast.VoiceBroadcastRecorder +import im.vector.app.features.voicebroadcast.listening.VoiceBroadcastPlayer import im.vector.app.features.voicebroadcast.model.MessageVoiceBroadcastInfoContent +import im.vector.app.features.voicebroadcast.model.VoiceBroadcast import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState import im.vector.app.features.voicebroadcast.model.asVoiceBroadcastEvent +import im.vector.app.features.voicebroadcast.recording.VoiceBroadcastRecorder import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.getRoom -import org.matrix.android.sdk.api.session.getUser +import org.matrix.android.sdk.api.session.getUserOrDefault import org.matrix.android.sdk.api.util.toMatrixItem import javax.inject.Inject @@ -45,87 +47,73 @@ class VoiceBroadcastItemFactory @Inject constructor( private val drawableProvider: DrawableProvider, private val voiceBroadcastRecorder: VoiceBroadcastRecorder?, private val voiceBroadcastPlayer: VoiceBroadcastPlayer, + private val playbackTracker: AudioMessagePlaybackTracker, + private val noticeItemFactory: NoticeItemFactory, ) { fun create( params: TimelineItemFactoryParams, messageContent: MessageVoiceBroadcastInfoContent, highlight: Boolean, - callback: TimelineEventController.Callback?, attributes: AbsMessageItem.Attributes, - ): VectorEpoxyModel? { + ): BaseEventItem<*>? { // Only display item of the initial event with updated data - if (messageContent.voiceBroadcastState != VoiceBroadcastState.STARTED) return null - val eventsGroup = params.eventsGroup ?: return null - val voiceBroadcastEventsGroup = VoiceBroadcastEventsGroup(eventsGroup) - val mostRecentTimelineEvent = voiceBroadcastEventsGroup.getLastDisplayableEvent() - val mostRecentEvent = mostRecentTimelineEvent.root.asVoiceBroadcastEvent() - val mostRecentMessageContent = mostRecentEvent?.content ?: return null - val isRecording = mostRecentMessageContent.voiceBroadcastState != VoiceBroadcastState.STOPPED && mostRecentEvent.root.stateKey == session.myUserId - val recorderName = mostRecentTimelineEvent.root.stateKey?.let { session.getUser(it) }?.displayName ?: mostRecentTimelineEvent.root.stateKey + if (messageContent.voiceBroadcastState != VoiceBroadcastState.STARTED) { + return noticeItemFactory.create(params) + } + + val voiceBroadcastEventsGroup = params.eventsGroup?.let { VoiceBroadcastEventsGroup(it) } ?: return null + val voiceBroadcastEvent = voiceBroadcastEventsGroup.getLastDisplayableEvent().root.asVoiceBroadcastEvent() ?: return null + val voiceBroadcastContent = voiceBroadcastEvent.content ?: return null + val voiceBroadcast = VoiceBroadcast(voiceBroadcastId = voiceBroadcastEventsGroup.voiceBroadcastId, roomId = params.event.roomId) + + val isRecording = voiceBroadcastContent.voiceBroadcastState != VoiceBroadcastState.STOPPED && + voiceBroadcastEvent.root.stateKey == session.myUserId && + messageContent.deviceId == session.sessionParams.deviceId + + val voiceBroadcastAttributes = AbsMessageVoiceBroadcastItem.Attributes( + voiceBroadcast = voiceBroadcast, + voiceBroadcastState = voiceBroadcastContent.voiceBroadcastState, + duration = voiceBroadcastEventsGroup.getDuration(), + recorderName = params.event.root.stateKey?.let { session.getUserOrDefault(it) }?.toMatrixItem()?.getBestName().orEmpty(), + recorder = voiceBroadcastRecorder, + player = voiceBroadcastPlayer, + playbackTracker = playbackTracker, + roomItem = session.getRoom(params.event.roomId)?.roomSummary()?.toMatrixItem(), + colorProvider = colorProvider, + drawableProvider = drawableProvider, + ) + return if (isRecording) { - createRecordingItem( - params.event.roomId, - eventsGroup.groupId, - highlight, - callback, - attributes - ) + createRecordingItem(highlight, attributes, voiceBroadcastAttributes) } else { - createListeningItem( - params.event.roomId, - eventsGroup.groupId, - mostRecentMessageContent.voiceBroadcastState, - recorderName, - highlight, - callback, - attributes - ) + createListeningItem(highlight, attributes, voiceBroadcastAttributes) } } private fun createRecordingItem( - roomId: String, - voiceBroadcastId: String, highlight: Boolean, - callback: TimelineEventController.Callback?, attributes: AbsMessageItem.Attributes, + voiceBroadcastAttributes: AbsMessageVoiceBroadcastItem.Attributes, ): MessageVoiceBroadcastRecordingItem { - val roomSummary = session.getRoom(roomId)?.roomSummary() return MessageVoiceBroadcastRecordingItem_() - .id("voice_broadcast_$voiceBroadcastId") + .id("voice_broadcast_${voiceBroadcastAttributes.voiceBroadcast.voiceBroadcastId}") .attributes(attributes) + .voiceBroadcastAttributes(voiceBroadcastAttributes) .highlighted(highlight) - .roomItem(roomSummary?.toMatrixItem()) - .colorProvider(colorProvider) - .drawableProvider(drawableProvider) - .voiceBroadcastRecorder(voiceBroadcastRecorder) .leftGuideline(avatarSizeProvider.leftGuideline) - .callback(callback) } private fun createListeningItem( - roomId: String, - voiceBroadcastId: String, - voiceBroadcastState: VoiceBroadcastState?, - broadcasterName: String?, highlight: Boolean, - callback: TimelineEventController.Callback?, attributes: AbsMessageItem.Attributes, + voiceBroadcastAttributes: AbsMessageVoiceBroadcastItem.Attributes, ): MessageVoiceBroadcastListeningItem { - val roomSummary = session.getRoom(roomId)?.roomSummary() return MessageVoiceBroadcastListeningItem_() - .id("voice_broadcast_$voiceBroadcastId") + .id("voice_broadcast_${voiceBroadcastAttributes.voiceBroadcast.voiceBroadcastId}") .attributes(attributes) + .voiceBroadcastAttributes(voiceBroadcastAttributes) .highlighted(highlight) - .roomItem(roomSummary?.toMatrixItem()) - .colorProvider(colorProvider) - .drawableProvider(drawableProvider) - .voiceBroadcastPlayer(voiceBroadcastPlayer) - .voiceBroadcastId(voiceBroadcastId) - .voiceBroadcastState(voiceBroadcastState) - .broadcasterName(broadcasterName) .leftGuideline(avatarSizeProvider.leftGuideline) - .callback(callback) } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/WidgetItemFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/WidgetItemFactory.kt index e8017efe0b..8477c16d8f 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/WidgetItemFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/WidgetItemFactory.kt @@ -18,6 +18,7 @@ package im.vector.app.features.home.room.detail.timeline.factory import im.vector.app.core.epoxy.VectorEpoxyModel import im.vector.app.core.resources.UserPreferencesProvider +import im.vector.app.features.VectorFeatures import im.vector.app.features.home.AvatarRenderer import im.vector.app.features.home.room.detail.timeline.MessageColorProvider import im.vector.app.features.home.room.detail.timeline.helper.AvatarSizeProvider @@ -32,6 +33,7 @@ import org.matrix.android.sdk.api.util.toMatrixItem import javax.inject.Inject class WidgetItemFactory @Inject constructor( + private val vectorFeatures: VectorFeatures, private val informationDataFactory: MessageInformationDataFactory, private val noticeItemFactory: NoticeItemFactory, private val avatarSizeProvider: AvatarSizeProvider, @@ -76,6 +78,7 @@ class WidgetItemFactory @Inject constructor( CallTileTimelineItem.CallStatus.ENDED } val attributes = CallTileTimelineItem.Attributes( + isVoipSupported = vectorFeatures.tchapIsVoipSupported(), callId = jitsiWidgetEventsGroup.callId, callKind = CallTileTimelineItem.CallKind.CONFERENCE, callStatus = callStatus, diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/format/DisplayableEventFormatter.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/format/DisplayableEventFormatter.kt index 09be71ecb0..3749fd1a45 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/format/DisplayableEventFormatter.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/format/DisplayableEventFormatter.kt @@ -21,9 +21,15 @@ import fr.gouv.tchap.core.utils.TchapUtils import im.vector.app.EmojiSpanify import im.vector.app.R import im.vector.app.core.extensions.getVectorLastMessageContent +import im.vector.app.core.extensions.orEmpty import im.vector.app.core.resources.ColorProvider +import im.vector.app.core.resources.DrawableProvider import im.vector.app.core.resources.StringProvider import im.vector.app.features.html.EventHtmlRenderer +import im.vector.app.features.voicebroadcast.VoiceBroadcastConstants +import im.vector.app.features.voicebroadcast.isLive +import im.vector.app.features.voicebroadcast.model.asVoiceBroadcastEvent +import me.gujun.android.span.image import me.gujun.android.span.span import org.commonmark.node.Document import org.matrix.android.sdk.api.session.events.model.Event @@ -42,6 +48,7 @@ import javax.inject.Inject class DisplayableEventFormatter @Inject constructor( private val stringProvider: StringProvider, private val colorProvider: ColorProvider, + private val drawableProvider: DrawableProvider, private val emojiSpanify: EmojiSpanify, private val noticeEventFormatter: NoticeEventFormatter, private val htmlRenderer: Lazy @@ -125,19 +132,22 @@ class DisplayableEventFormatter @Inject constructor( EventType.CALL_CANDIDATES -> { span { } } - in EventType.POLL_START -> { + in EventType.POLL_START.values -> { timelineEvent.root.getClearContent().toModel(catchError = true)?.getBestPollCreationInfo()?.question?.getBestQuestion() ?: stringProvider.getString(R.string.sent_a_poll) } - in EventType.POLL_RESPONSE -> { + in EventType.POLL_RESPONSE.values -> { stringProvider.getString(R.string.poll_response_room_list_preview) } - in EventType.POLL_END -> { + in EventType.POLL_END.values -> { stringProvider.getString(R.string.poll_end_room_list_preview) } - in EventType.STATE_ROOM_BEACON_INFO -> { + in EventType.STATE_ROOM_BEACON_INFO.values -> { simpleFormat(senderName, stringProvider.getString(R.string.sent_live_location), appendAuthor) } + VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO -> { + formatVoiceBroadcastEvent(timelineEvent.root, isDm, senderName) + } else -> { span { text = noticeEventFormatter.format(timelineEvent, isDm) ?: "" @@ -223,17 +233,17 @@ class DisplayableEventFormatter @Inject constructor( emojiSpanify.spanify(stringProvider.getString(R.string.sent_a_reaction, it.key)) } ?: span { } } - in EventType.POLL_START -> { + in EventType.POLL_START.values -> { event.getClearContent().toModel(catchError = true)?.pollCreationInfo?.question?.question ?: stringProvider.getString(R.string.sent_a_poll) } - in EventType.POLL_RESPONSE -> { + in EventType.POLL_RESPONSE.values -> { stringProvider.getString(R.string.poll_response_room_list_preview) } - in EventType.POLL_END -> { + in EventType.POLL_END.values -> { stringProvider.getString(R.string.poll_end_room_list_preview) } - in EventType.STATE_ROOM_BEACON_INFO -> { + in EventType.STATE_ROOM_BEACON_INFO.values -> { stringProvider.getString(R.string.sent_live_location) } else -> { @@ -256,4 +266,20 @@ class DisplayableEventFormatter @Inject constructor( body } } + + private fun formatVoiceBroadcastEvent(event: Event, isDm: Boolean, senderName: String): CharSequence { + return if (event.asVoiceBroadcastEvent()?.isLive == true) { + span { + drawableProvider.getDrawable(R.drawable.ic_voice_broadcast, colorProvider.getColor(R.color.palette_vermilion))?.let { + image(it) + +" " + } + span(stringProvider.getString(R.string.voice_broadcast_live_broadcast)) { + textColor = colorProvider.getColor(R.color.palette_vermilion) + } + } + } else { + noticeEventFormatter.format(event, senderName, isDm).orEmpty() + } + } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/format/NoticeEventFormatter.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/format/NoticeEventFormatter.kt index c638a07807..3e55091ba9 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/format/NoticeEventFormatter.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/format/NoticeEventFormatter.kt @@ -22,6 +22,9 @@ import im.vector.app.R import im.vector.app.core.resources.StringProvider import im.vector.app.features.roomprofile.permissions.RoleFormatter import im.vector.app.features.settings.VectorPreferences +import im.vector.app.features.voicebroadcast.VoiceBroadcastConstants +import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState +import im.vector.app.features.voicebroadcast.model.asVoiceBroadcastEvent import org.matrix.android.sdk.api.crypto.MXCRYPTO_ALGORITHM_MEGOLM import org.matrix.android.sdk.api.extensions.appendNl import org.matrix.android.sdk.api.extensions.orFalse @@ -67,36 +70,37 @@ class NoticeEventFormatter @Inject constructor( private fun Event.isSentByCurrentUser() = senderId != null && senderId == currentUserId fun format(timelineEvent: TimelineEvent, isDm: Boolean): CharSequence? { + val event = timelineEvent.root // Tchap: Hide the domain if we are in DM. val senderName = if (isDm) { TchapUtils.getNameFromDisplayName(timelineEvent.senderInfo.disambiguatedDisplayName) } else { timelineEvent.senderInfo.disambiguatedDisplayName } - return when (val type = timelineEvent.root.getClearType()) { - EventType.STATE_ROOM_JOIN_RULES -> formatJoinRulesEvent(timelineEvent.root, senderName, isDm) - EventType.STATE_ROOM_CREATE -> formatRoomCreateEvent(timelineEvent.root, isDm) - EventType.STATE_ROOM_NAME -> formatRoomNameEvent(timelineEvent.root, senderName) - EventType.STATE_ROOM_TOPIC -> formatRoomTopicEvent(timelineEvent.root, senderName) - EventType.STATE_ROOM_AVATAR -> formatRoomAvatarEvent(timelineEvent.root, senderName) - EventType.STATE_ROOM_MEMBER -> formatRoomMemberEvent(timelineEvent.root, senderName, isDm) - EventType.STATE_ROOM_THIRD_PARTY_INVITE -> formatRoomThirdPartyInvite(timelineEvent.root, senderName, isDm) - EventType.STATE_ROOM_ALIASES -> formatRoomAliasesEvent(timelineEvent.root, senderName) - EventType.STATE_ROOM_CANONICAL_ALIAS -> formatRoomCanonicalAliasEvent(timelineEvent.root, senderName) - EventType.STATE_ROOM_HISTORY_VISIBILITY -> - formatRoomHistoryVisibilityEvent(timelineEvent.root, senderName, isDm) - EventType.STATE_ROOM_SERVER_ACL -> formatRoomServerAclEvent(timelineEvent.root, senderName) - EventType.STATE_ROOM_GUEST_ACCESS -> formatRoomGuestAccessEvent(timelineEvent.root, senderName, isDm) - EventType.STATE_ROOM_ENCRYPTION -> formatRoomEncryptionEvent(timelineEvent.root, senderName) + return when (val type = event.getClearType()) { + EventType.STATE_ROOM_JOIN_RULES -> formatJoinRulesEvent(event, senderName, isDm) + EventType.STATE_ROOM_CREATE -> formatRoomCreateEvent(event, isDm) + EventType.STATE_ROOM_NAME -> formatRoomNameEvent(event, senderName) + EventType.STATE_ROOM_TOPIC -> formatRoomTopicEvent(event, senderName) + EventType.STATE_ROOM_AVATAR -> formatRoomAvatarEvent(event, senderName) + EventType.STATE_ROOM_MEMBER -> formatRoomMemberEvent(event, senderName, isDm) + EventType.STATE_ROOM_THIRD_PARTY_INVITE -> formatRoomThirdPartyInvite(event, senderName, isDm) + EventType.STATE_ROOM_ALIASES -> formatRoomAliasesEvent(event, senderName) + EventType.STATE_ROOM_CANONICAL_ALIAS -> formatRoomCanonicalAliasEvent(event, senderName) + EventType.STATE_ROOM_HISTORY_VISIBILITY -> formatRoomHistoryVisibilityEvent(event, senderName, isDm) + EventType.STATE_ROOM_SERVER_ACL -> formatRoomServerAclEvent(event, senderName) + EventType.STATE_ROOM_GUEST_ACCESS -> formatRoomGuestAccessEvent(event, senderName, isDm) + EventType.STATE_ROOM_ENCRYPTION -> formatRoomEncryptionEvent(event, senderName) EventType.STATE_ROOM_WIDGET, - EventType.STATE_ROOM_WIDGET_LEGACY -> formatWidgetEvent(timelineEvent.root, senderName) - EventType.STATE_ROOM_TOMBSTONE -> formatRoomTombstoneEvent(timelineEvent.root, senderName, isDm) - EventType.STATE_ROOM_POWER_LEVELS -> formatRoomPowerLevels(timelineEvent.root, senderName) + EventType.STATE_ROOM_WIDGET_LEGACY -> formatWidgetEvent(event, senderName) + EventType.STATE_ROOM_TOMBSTONE -> formatRoomTombstoneEvent(event, senderName, isDm) + EventType.STATE_ROOM_POWER_LEVELS -> formatRoomPowerLevels(event, senderName) EventType.CALL_INVITE, EventType.CALL_CANDIDATES, EventType.CALL_HANGUP, EventType.CALL_REJECT, - EventType.CALL_ANSWER -> formatCallEvent(type, timelineEvent.root, senderName) + EventType.CALL_ANSWER -> formatCallEvent(type, event, senderName) + VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO -> formatVoiceBroadcastEvent(event, senderName) EventType.CALL_NEGOTIATE, EventType.CALL_SELECT_ANSWER, EventType.CALL_REPLACES, @@ -113,9 +117,9 @@ class NoticeEventFormatter @Inject constructor( EventType.STATE_SPACE_PARENT, EventType.REDACTION, EventType.STICKER, - in EventType.POLL_RESPONSE, - in EventType.POLL_END, - in EventType.BEACON_LOCATION_DATA -> formatDebug(timelineEvent.root) + in EventType.POLL_RESPONSE.values, + in EventType.POLL_END.values, + in EventType.BEACON_LOCATION_DATA.values -> formatDebug(event) else -> { Timber.v("Type $type not handled by this formatter") null @@ -196,6 +200,7 @@ class NoticeEventFormatter @Inject constructor( EventType.CALL_REJECT, EventType.CALL_ANSWER -> formatCallEvent(type, event, senderName) EventType.STATE_ROOM_TOMBSTONE -> formatRoomTombstoneEvent(event, senderName, isDm) + VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO -> formatVoiceBroadcastEvent(event, senderName) else -> { Timber.v("Type $type not handled by this formatter") null @@ -906,4 +911,16 @@ class NoticeEventFormatter @Inject constructor( } } } + + private fun formatVoiceBroadcastEvent(event: Event, senderName: String?): CharSequence { + return if (event.asVoiceBroadcastEvent()?.content?.voiceBroadcastState == VoiceBroadcastState.STOPPED) { + if (event.isSentByCurrentUser()) { + sp.getString(R.string.notice_voice_broadcast_ended_by_you) + } else { + sp.getString(R.string.notice_voice_broadcast_ended, senderName) + } + } else { + formatDebug(event) + } + } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/AudioMessagePlaybackTracker.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/AudioMessagePlaybackTracker.kt index 6937cd3a46..c34cbbc74a 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/AudioMessagePlaybackTracker.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/AudioMessagePlaybackTracker.kt @@ -51,15 +51,7 @@ class AudioMessagePlaybackTracker @Inject constructor() { } fun pauseAllPlaybacks() { - listeners.keys.forEach { key -> - pausePlayback(key) - } - } - - fun makeAllPlaybacksIdle() { - listeners.keys.forEach { key -> - setState(key, Listener.State.Idle) - } + listeners.keys.forEach(::pausePlayback) } /** @@ -75,8 +67,8 @@ class AudioMessagePlaybackTracker @Inject constructor() { } fun startPlayback(id: String) { - val currentPlaybackTime = getPlaybackTime(id) - val currentPercentage = getPercentage(id) + val currentPlaybackTime = getPlaybackTime(id) ?: 0 + val currentPercentage = getPercentage(id) ?: 0f val currentState = Listener.State.Playing(currentPlaybackTime, currentPercentage) setState(id, currentState) // Pause any active playback @@ -93,9 +85,10 @@ class AudioMessagePlaybackTracker @Inject constructor() { } fun pausePlayback(id: String) { - if (getPlaybackState(id) is Listener.State.Playing) { - val currentPlaybackTime = getPlaybackTime(id) - val currentPercentage = getPercentage(id) + val state = getPlaybackState(id) + if (state is Listener.State.Playing) { + val currentPlaybackTime = state.playbackTime + val currentPercentage = state.percentage setState(id, Listener.State.Paused(currentPlaybackTime, currentPercentage)) } } @@ -118,37 +111,38 @@ class AudioMessagePlaybackTracker @Inject constructor() { fun getPlaybackState(id: String) = states[id] - fun getPlaybackTime(id: String): Int { + fun getPlaybackTime(id: String): Int? { return when (val state = states[id]) { is Listener.State.Playing -> state.playbackTime is Listener.State.Paused -> state.playbackTime - /* Listener.State.Idle, */ - else -> 0 + is Listener.State.Recording, + Listener.State.Idle, + null -> null } } - private fun getPercentage(id: String): Float { + fun getPercentage(id: String): Float? { return when (val state = states[id]) { is Listener.State.Playing -> state.percentage is Listener.State.Paused -> state.percentage - /* Listener.State.Idle, */ - else -> 0f + is Listener.State.Recording, + Listener.State.Idle, + null -> null } } - fun clear() { + fun unregisterListeners() { listeners.forEach { it.value.onUpdate(Listener.State.Idle) } listeners.clear() - states.clear() } companion object { const val RECORDING_ID = "RECORDING_ID" } - interface Listener { + fun interface Listener { fun onUpdate(state: State) diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/MessageInformationDataFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/MessageInformationDataFactory.kt index 33d771a3aa..6e1da0bc5a 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/MessageInformationDataFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/MessageInformationDataFactory.kt @@ -31,11 +31,13 @@ import im.vector.app.features.home.room.detail.timeline.style.TimelineMessageLay import org.matrix.android.sdk.api.extensions.orFalse import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.crypto.verification.VerificationState +import org.matrix.android.sdk.api.session.events.model.Event import org.matrix.android.sdk.api.session.events.model.EventType import org.matrix.android.sdk.api.session.events.model.getMsgType import org.matrix.android.sdk.api.session.events.model.isAttachmentMessage import org.matrix.android.sdk.api.session.events.model.isSticker import org.matrix.android.sdk.api.session.events.model.toModel +import org.matrix.android.sdk.api.session.events.model.toValidDecryptedEvent import org.matrix.android.sdk.api.session.room.model.ReferencesAggregatedContent import org.matrix.android.sdk.api.session.room.model.RoomSummary import org.matrix.android.sdk.api.session.room.model.message.MessageType @@ -72,8 +74,8 @@ class MessageInformationDataFactory @Inject constructor( prevDisplayableEvent?.root?.localDateTime()?.toLocalDate() != date.toLocalDate() val time = dateFormatter.format(event.root.originServerTs, DateFormatKind.MESSAGE_SIMPLE) - val e2eDecoration = getE2EDecoration(roomSummary, event) - + val e2eDecoration = getE2EDecoration(roomSummary, params.lastEdit ?: event.root) + val senderId = getSenderId(event) // SendState Decoration val sendStateDecoration = if (isSentByMe) { getSendStateDecoration( @@ -89,7 +91,7 @@ class MessageInformationDataFactory @Inject constructor( return MessageInformationData( eventId = eventId, - senderId = event.root.senderId ?: "", + senderId = senderId, sendState = event.root.sendState, time = time, ageLocalTS = event.root.ageLocalTs, @@ -132,6 +134,14 @@ class MessageInformationDataFactory @Inject constructor( ) } + private fun getSenderId(event: TimelineEvent) = if (event.isEncrypted()) { + event.root.toValidDecryptedEvent()?.let { + session.cryptoService().deviceWithIdentityKey(it.cryptoSenderKey, it.algorithm)?.userId + } ?: event.root.senderId.orEmpty() + } else { + event.root.senderId.orEmpty() + } + private fun getSendStateDecoration( event: TimelineEvent, lastSentEventWithoutReadReceipts: String?, @@ -149,34 +159,34 @@ class MessageInformationDataFactory @Inject constructor( } } - private fun getE2EDecoration(roomSummary: RoomSummary?, event: TimelineEvent): E2EDecoration { + private fun getE2EDecoration(roomSummary: RoomSummary?, event: Event): E2EDecoration { if (roomSummary?.isEncrypted != true) { // No decoration for clear room // Questionable? what if the event is E2E? return E2EDecoration.NONE } - if (event.root.sendState != SendState.SYNCED) { + if (event.sendState != SendState.SYNCED) { // we don't display e2e decoration if event not synced back return E2EDecoration.NONE } val userCrossSigningInfo = session.cryptoService() .crossSigningService() - .getUserCrossSigningKeys(event.root.senderId.orEmpty()) + .getUserCrossSigningKeys(event.senderId.orEmpty()) if (userCrossSigningInfo?.isTrusted() == true) { return if (event.isEncrypted()) { // Do not decorate failed to decrypt, or redaction (we lost sender device info) - if (event.root.getClearType() == EventType.ENCRYPTED || event.root.isRedacted()) { + if (event.getClearType() == EventType.ENCRYPTED || event.isRedacted()) { E2EDecoration.NONE } else { - val sendingDevice = event.root.getSenderKey() + val sendingDevice = event.getSenderKey() ?.let { session.cryptoService().deviceWithIdentityKey( it, - event.root.content?.get("algorithm") as? String ?: "" + event.content?.get("algorithm") as? String ?: "" ) } - if (event.root.mxDecryptionResult?.isSafe == false) { + if (event.mxDecryptionResult?.isSafe == false) { E2EDecoration.WARN_UNSAFE_KEY } else { when { @@ -203,8 +213,8 @@ class MessageInformationDataFactory @Inject constructor( } else { return if (!event.isEncrypted()) { e2EDecorationForClearEventInE2ERoom(event, roomSummary) - } else if (event.root.mxDecryptionResult != null) { - if (event.root.mxDecryptionResult?.isSafe == true) { + } else if (event.mxDecryptionResult != null) { + if (event.mxDecryptionResult?.isSafe == true) { E2EDecoration.NONE } else { E2EDecoration.WARN_UNSAFE_KEY @@ -215,13 +225,13 @@ class MessageInformationDataFactory @Inject constructor( } } - private fun e2EDecorationForClearEventInE2ERoom(event: TimelineEvent, roomSummary: RoomSummary) = - if (event.root.isStateEvent()) { + private fun e2EDecorationForClearEventInE2ERoom(event: Event, roomSummary: RoomSummary) = + if (event.isStateEvent()) { // Do not warn for state event, they are always in clear E2EDecoration.NONE } else { val ts = roomSummary.encryptionEventTs ?: 0 - val eventTs = event.root.originServerTs ?: 0 + val eventTs = event.originServerTs ?: 0 // If event is in clear after the room enabled encryption we should warn if (eventTs > ts) E2EDecoration.WARN_IN_CLEAR else E2EDecoration.NONE } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineDisplayableEvents.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineDisplayableEvents.kt index 6965de4f1d..ef2c5576c2 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineDisplayableEvents.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineDisplayableEvents.kt @@ -55,9 +55,9 @@ object TimelineDisplayableEvents { EventType.KEY_VERIFICATION_CANCEL, VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO, ) + - EventType.POLL_START + - EventType.STATE_ROOM_BEACON_INFO + - EventType.BEACON_LOCATION_DATA + EventType.POLL_START.values + + EventType.STATE_ROOM_BEACON_INFO.values + + EventType.BEACON_LOCATION_DATA.values } fun TimelineEvent.isRoomConfiguration(roomCreatorUserId: String?): Boolean { diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineEventVisibilityHelper.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineEventVisibilityHelper.kt index d22b649b36..703a5cb911 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineEventVisibilityHelper.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineEventVisibilityHelper.kt @@ -18,6 +18,10 @@ package im.vector.app.features.home.room.detail.timeline.helper import im.vector.app.core.extensions.localDateTime import im.vector.app.core.resources.UserPreferencesProvider +import im.vector.app.features.voicebroadcast.VoiceBroadcastConstants +import im.vector.app.features.voicebroadcast.isVoiceBroadcast +import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState +import im.vector.app.features.voicebroadcast.model.asVoiceBroadcastEvent import org.matrix.android.sdk.api.session.events.model.Event import org.matrix.android.sdk.api.session.events.model.EventType import org.matrix.android.sdk.api.session.events.model.RelationType @@ -28,6 +32,7 @@ import org.matrix.android.sdk.api.session.events.model.toModel import org.matrix.android.sdk.api.session.room.model.Membership import org.matrix.android.sdk.api.session.room.model.RoomMemberContent import org.matrix.android.sdk.api.session.room.model.localecho.RoomLocalEcho +import org.matrix.android.sdk.api.session.room.model.message.asMessageAudioEvent import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent import javax.inject.Inject @@ -242,10 +247,19 @@ class TimelineEventVisibilityHelper @Inject constructor( } else root.eventId != rootThreadEventId } - if (root.getClearType() in EventType.BEACON_LOCATION_DATA) { + if (root.getClearType() in EventType.BEACON_LOCATION_DATA.values) { return !root.isRedacted() } + if (root.getClearType() == VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO && + root.asVoiceBroadcastEvent()?.content?.voiceBroadcastState !in arrayOf(VoiceBroadcastState.STARTED, VoiceBroadcastState.STOPPED)) { + return true + } + + if (root.asMessageAudioEvent().isVoiceBroadcast()) { + return true + } + return false } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineEventsGroups.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineEventsGroups.kt index d8817c1f44..a4bfa9e155 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineEventsGroups.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/helper/TimelineEventsGroups.kt @@ -18,6 +18,7 @@ package im.vector.app.features.home.room.detail.timeline.helper import im.vector.app.core.utils.TextUtils import im.vector.app.features.voicebroadcast.VoiceBroadcastConstants +import im.vector.app.features.voicebroadcast.duration import im.vector.app.features.voicebroadcast.getVoiceBroadcastEventId import im.vector.app.features.voicebroadcast.isVoiceBroadcast import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState @@ -141,8 +142,15 @@ class CallSignalingEventsGroup(private val group: TimelineEventsGroup) { } class VoiceBroadcastEventsGroup(private val group: TimelineEventsGroup) { + + val voiceBroadcastId = group.groupId + fun getLastDisplayableEvent(): TimelineEvent { return group.events.find { it.root.asVoiceBroadcastEvent()?.content?.voiceBroadcastState == VoiceBroadcastState.STOPPED } ?: group.events.filter { it.root.type == VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO }.maxBy { it.root.originServerTs ?: 0L } } + + fun getDuration(): Int { + return group.events.mapNotNull { it.root.asMessageAudioEvent()?.duration }.sum() + } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/AbsMessageVoiceBroadcastItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/AbsMessageVoiceBroadcastItem.kt new file mode 100644 index 0000000000..c6b90cdabe --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/AbsMessageVoiceBroadcastItem.kt @@ -0,0 +1,111 @@ +/* + * Copyright (c) 2022 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 im.vector.app.features.home.room.detail.timeline.item + +import android.widget.ImageView +import android.widget.TextView +import androidx.annotation.IdRes +import androidx.core.view.isVisible +import com.airbnb.epoxy.EpoxyAttribute +import im.vector.app.R +import im.vector.app.core.extensions.tintBackground +import im.vector.app.core.resources.ColorProvider +import im.vector.app.core.resources.DrawableProvider +import im.vector.app.features.home.room.detail.timeline.helper.AudioMessagePlaybackTracker +import im.vector.app.features.voicebroadcast.listening.VoiceBroadcastPlayer +import im.vector.app.features.voicebroadcast.model.VoiceBroadcast +import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState +import im.vector.app.features.voicebroadcast.recording.VoiceBroadcastRecorder +import org.matrix.android.sdk.api.util.MatrixItem + +abstract class AbsMessageVoiceBroadcastItem : AbsMessageItem() { + + @EpoxyAttribute + lateinit var voiceBroadcastAttributes: Attributes + + protected val voiceBroadcast get() = voiceBroadcastAttributes.voiceBroadcast + protected val voiceBroadcastState get() = voiceBroadcastAttributes.voiceBroadcastState + protected val recorderName get() = voiceBroadcastAttributes.recorderName + protected val recorder get() = voiceBroadcastAttributes.recorder + protected val player get() = voiceBroadcastAttributes.player + protected val playbackTracker get() = voiceBroadcastAttributes.playbackTracker + protected val duration get() = voiceBroadcastAttributes.duration + protected val roomItem get() = voiceBroadcastAttributes.roomItem + protected val colorProvider get() = voiceBroadcastAttributes.colorProvider + protected val drawableProvider get() = voiceBroadcastAttributes.drawableProvider + protected val avatarRenderer get() = attributes.avatarRenderer + protected val callback get() = attributes.callback + + override fun isCacheable(): Boolean = false + + override fun bind(holder: H) { + super.bind(holder) + renderHeader(holder) + } + + private fun renderHeader(holder: H) { + with(holder) { + roomItem?.let { + avatarRenderer.render(it, roomAvatarImageView) + titleText.text = it.displayName + } + } + renderLiveIndicator(holder) + renderMetadata(holder) + } + + abstract fun renderLiveIndicator(holder: H) + + protected fun renderPlayingLiveIndicator(holder: H) { + with(holder) { + liveIndicator.tintBackground(colorProvider.getColorFromAttribute(R.attr.colorError)) + liveIndicator.isVisible = true + } + } + + protected fun renderPausedLiveIndicator(holder: H) { + with(holder) { + liveIndicator.tintBackground(colorProvider.getColorFromAttribute(R.attr.vctr_content_quaternary)) + liveIndicator.isVisible = true + } + } + + protected fun renderNoLiveIndicator(holder: H) { + holder.liveIndicator.isVisible = false + } + + abstract fun renderMetadata(holder: H) + + abstract class Holder(@IdRes stubId: Int) : AbsMessageItem.Holder(stubId) { + val liveIndicator by bind(R.id.liveIndicator) + val roomAvatarImageView by bind(R.id.roomAvatarImageView) + val titleText by bind(R.id.titleText) + } + + data class Attributes( + val voiceBroadcast: VoiceBroadcast, + val voiceBroadcastState: VoiceBroadcastState?, + val duration: Int, + val recorderName: String, + val recorder: VoiceBroadcastRecorder?, + val player: VoiceBroadcastPlayer, + val playbackTracker: AudioMessagePlaybackTracker, + val roomItem: MatrixItem?, + val colorProvider: ColorProvider, + val drawableProvider: DrawableProvider, + ) +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/CallTileTimelineItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/CallTileTimelineItem.kt index 9bec0c9df3..7d846a78aa 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/CallTileTimelineItem.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/CallTileTimelineItem.kt @@ -28,7 +28,6 @@ import androidx.core.view.isVisible import androidx.core.view.updateLayoutParams import com.airbnb.epoxy.EpoxyAttribute import com.airbnb.epoxy.EpoxyModelClass -import im.vector.app.BuildConfig import im.vector.app.R import im.vector.app.core.epoxy.ClickListener import im.vector.app.core.epoxy.onClick @@ -225,10 +224,9 @@ abstract class CallTileTimelineItem : AbsBaseMessageItem() { } private fun renderStateBasedOnAudioPlayback(holder: Holder) { - audioMessagePlaybackTracker.track(attributes.informationData.eventId, object : AudioMessagePlaybackTracker.Listener { - override fun onUpdate(state: AudioMessagePlaybackTracker.Listener.State) { - when (state) { - is AudioMessagePlaybackTracker.Listener.State.Idle -> renderIdleState(holder) - is AudioMessagePlaybackTracker.Listener.State.Playing -> renderPlayingState(holder, state) - is AudioMessagePlaybackTracker.Listener.State.Paused -> renderPausedState(holder, state) - is AudioMessagePlaybackTracker.Listener.State.Recording -> Unit - } + audioMessagePlaybackTracker.track(attributes.informationData.eventId) { state -> + when (state) { + is AudioMessagePlaybackTracker.Listener.State.Idle -> renderIdleState(holder) + is AudioMessagePlaybackTracker.Listener.State.Playing -> renderPlayingState(holder, state) + is AudioMessagePlaybackTracker.Listener.State.Paused -> renderPausedState(holder, state) + is AudioMessagePlaybackTracker.Listener.State.Recording -> Unit } - }) + } } private fun renderIdleState(holder: Holder) { diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceBroadcastListeningItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceBroadcastListeningItem.kt index 5b58dda4e6..b788d79214 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceBroadcastListeningItem.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceBroadcastListeningItem.kt @@ -16,57 +16,27 @@ package im.vector.app.features.home.room.detail.timeline.item -import android.view.View +import android.text.format.DateUtils import android.widget.ImageButton -import android.widget.ImageView +import android.widget.SeekBar import android.widget.TextView +import androidx.core.view.isInvisible import androidx.core.view.isVisible -import com.airbnb.epoxy.EpoxyAttribute import com.airbnb.epoxy.EpoxyModelClass import im.vector.app.R import im.vector.app.core.epoxy.onClick -import im.vector.app.core.extensions.tintBackground -import im.vector.app.core.resources.ColorProvider -import im.vector.app.core.resources.DrawableProvider -import im.vector.app.features.home.room.detail.RoomDetailAction -import im.vector.app.features.home.room.detail.timeline.TimelineEventController -import im.vector.app.features.voicebroadcast.VoiceBroadcastPlayer +import im.vector.app.features.home.room.detail.RoomDetailAction.VoiceBroadcastAction +import im.vector.app.features.home.room.detail.timeline.helper.AudioMessagePlaybackTracker.Listener.State +import im.vector.app.features.voicebroadcast.listening.VoiceBroadcastPlayer import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState -import org.matrix.android.sdk.api.util.MatrixItem +import im.vector.app.features.voicebroadcast.views.VoiceBroadcastBufferingView +import im.vector.app.features.voicebroadcast.views.VoiceBroadcastMetadataView @EpoxyModelClass -abstract class MessageVoiceBroadcastListeningItem : AbsMessageItem() { - - @EpoxyAttribute - var callback: TimelineEventController.Callback? = null - - @EpoxyAttribute - var voiceBroadcastPlayer: VoiceBroadcastPlayer? = null - - @EpoxyAttribute - lateinit var voiceBroadcastId: String - - @EpoxyAttribute - var voiceBroadcastState: VoiceBroadcastState? = null - - @EpoxyAttribute - var broadcasterName: String? = null - - @EpoxyAttribute - lateinit var colorProvider: ColorProvider - - @EpoxyAttribute - lateinit var drawableProvider: DrawableProvider - - @EpoxyAttribute - var roomItem: MatrixItem? = null - - @EpoxyAttribute - var title: String? = null +abstract class MessageVoiceBroadcastListeningItem : AbsMessageVoiceBroadcastItem() { private lateinit var playerListener: VoiceBroadcastPlayer.Listener - - override fun isCacheable(): Boolean = false + private var isUserSeeking = false override fun bind(holder: Holder) { super.bind(holder) @@ -74,103 +44,149 @@ abstract class MessageVoiceBroadcastListeningItem : AbsMessageItem - renderState(holder, state) + playerListener = object : VoiceBroadcastPlayer.Listener { + override fun onPlayingStateChanged(state: VoiceBroadcastPlayer.State) { + renderPlayingState(holder, state) + } + + override fun onLiveModeChanged(isLive: Boolean) { + renderLiveIndicator(holder) + } } - voiceBroadcastPlayer?.addListener(playerListener) - renderHeader(holder) - renderLiveIcon(holder) + player.addListener(voiceBroadcast, playerListener) + bindSeekBar(holder) + bindButtons(holder) } - private fun renderHeader(holder: Holder) { + private fun bindButtons(holder: Holder) { with(holder) { - roomItem?.let { - attributes.avatarRenderer.render(it, roomAvatarImageView) - titleText.text = it.displayName + playPauseButton.setOnClickListener { + if (player.currentVoiceBroadcast == voiceBroadcast) { + when (player.playingState) { + VoiceBroadcastPlayer.State.PLAYING, + VoiceBroadcastPlayer.State.BUFFERING -> callback?.onTimelineItemAction(VoiceBroadcastAction.Listening.Pause) + VoiceBroadcastPlayer.State.PAUSED, + VoiceBroadcastPlayer.State.IDLE -> callback?.onTimelineItemAction(VoiceBroadcastAction.Listening.PlayOrResume(voiceBroadcast)) + } + } else { + callback?.onTimelineItemAction(VoiceBroadcastAction.Listening.PlayOrResume(voiceBroadcast)) + } + } + fastBackwardButton.setOnClickListener { + val newPos = seekBar.progress.minus(30_000).coerceIn(0, duration) + callback?.onTimelineItemAction(VoiceBroadcastAction.Listening.SeekTo(voiceBroadcast, newPos, duration)) + } + fastForwardButton.setOnClickListener { + val newPos = seekBar.progress.plus(30_000).coerceIn(0, duration) + callback?.onTimelineItemAction(VoiceBroadcastAction.Listening.SeekTo(voiceBroadcast, newPos, duration)) } - broadcasterNameText.text = broadcasterName } } - private fun renderLiveIcon(holder: Holder) { + override fun renderMetadata(holder: Holder) { with(holder) { - when (voiceBroadcastState) { - VoiceBroadcastState.STARTED, - VoiceBroadcastState.RESUMED -> { - liveIndicator.tintBackground(colorProvider.getColorFromAttribute(R.attr.colorError)) - liveIndicator.isVisible = true - } - VoiceBroadcastState.PAUSED -> { - liveIndicator.tintBackground(colorProvider.getColorFromAttribute(R.attr.vctr_content_quaternary)) - liveIndicator.isVisible = true - } - VoiceBroadcastState.STOPPED, null -> { - liveIndicator.isVisible = false - } - } + broadcasterNameMetadata.value = recorderName + listenersCountMetadata.isVisible = false } } - private fun renderState(holder: Holder, state: VoiceBroadcastPlayer.State) { - if (isCurrentMediaActive()) { - renderActiveMedia(holder, state) - } else { - renderInactiveMedia(holder) + override fun renderLiveIndicator(holder: Holder) { + when { + voiceBroadcastState == null || voiceBroadcastState == VoiceBroadcastState.STOPPED -> renderNoLiveIndicator(holder) + voiceBroadcastState == VoiceBroadcastState.PAUSED -> renderPausedLiveIndicator(holder) + else -> renderPlayingLiveIndicator(holder) } } - private fun renderActiveMedia(holder: Holder, state: VoiceBroadcastPlayer.State) { + private fun renderPlayingState(holder: Holder, state: VoiceBroadcastPlayer.State) { with(holder) { bufferingView.isVisible = state == VoiceBroadcastPlayer.State.BUFFERING - playPauseButton.isVisible = state != VoiceBroadcastPlayer.State.BUFFERING + voiceBroadcastMetadata.isVisible = state != VoiceBroadcastPlayer.State.BUFFERING when (state) { - VoiceBroadcastPlayer.State.PLAYING -> { + VoiceBroadcastPlayer.State.PLAYING, + VoiceBroadcastPlayer.State.BUFFERING -> { playPauseButton.setImageResource(R.drawable.ic_play_pause_pause) - playPauseButton.contentDescription = view.resources.getString(R.string.a11y_play_voice_broadcast) - playPauseButton.onClick { attributes.callback?.onTimelineItemAction(RoomDetailAction.VoiceBroadcastAction.Listening.Pause) } + playPauseButton.contentDescription = view.resources.getString(R.string.a11y_pause_voice_broadcast) } VoiceBroadcastPlayer.State.IDLE, VoiceBroadcastPlayer.State.PAUSED -> { playPauseButton.setImageResource(R.drawable.ic_play_pause_play) - playPauseButton.contentDescription = view.resources.getString(R.string.a11y_pause_voice_broadcast) - playPauseButton.onClick { - attributes.callback?.onTimelineItemAction(RoomDetailAction.VoiceBroadcastAction.Listening.PlayOrResume(voiceBroadcastId)) - } + playPauseButton.contentDescription = view.resources.getString(R.string.a11y_play_voice_broadcast) } - VoiceBroadcastPlayer.State.BUFFERING -> Unit } + + renderLiveIndicator(holder) } } - private fun renderInactiveMedia(holder: Holder) { + private fun bindSeekBar(holder: Holder) { with(holder) { - bufferingView.isVisible = false - playPauseButton.isVisible = true - playPauseButton.setImageResource(R.drawable.ic_play_pause_play) - playPauseButton.contentDescription = view.resources.getString(R.string.a11y_pause_voice_broadcast) - playPauseButton.onClick { - attributes.callback?.onTimelineItemAction(RoomDetailAction.VoiceBroadcastAction.Listening.PlayOrResume(voiceBroadcastId)) + remainingTimeView.text = formatRemainingTime(duration) + elapsedTimeView.text = formatPlaybackTime(0) + seekBar.max = duration + seekBar.setOnSeekBarChangeListener(object : SeekBar.OnSeekBarChangeListener { + override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) { + remainingTimeView.text = formatRemainingTime(duration - progress) + elapsedTimeView.text = formatPlaybackTime(progress) + } + + override fun onStartTrackingTouch(seekBar: SeekBar) { + isUserSeeking = true + } + + override fun onStopTrackingTouch(seekBar: SeekBar) { + callback?.onTimelineItemAction(VoiceBroadcastAction.Listening.SeekTo(voiceBroadcast, seekBar.progress, duration)) + isUserSeeking = false + } + }) + } + playbackTracker.track(voiceBroadcast.voiceBroadcastId) { playbackState -> + renderBackwardForwardButtons(holder, playbackState) + renderLiveIndicator(holder) + if (!isUserSeeking) { + holder.seekBar.progress = playbackTracker.getPlaybackTime(voiceBroadcast.voiceBroadcastId) ?: 0 } } } - private fun isCurrentMediaActive() = voiceBroadcastPlayer?.currentVoiceBroadcastId == voiceBroadcastId + private fun renderBackwardForwardButtons(holder: Holder, playbackState: State) { + val isPlayingOrPaused = playbackState is State.Playing || playbackState is State.Paused + val playbackTime = playbackTracker.getPlaybackTime(voiceBroadcast.voiceBroadcastId) ?: 0 + val canBackward = isPlayingOrPaused && playbackTime > 0 + val canForward = isPlayingOrPaused && playbackTime < duration + holder.fastBackwardButton.isInvisible = !canBackward + holder.fastForwardButton.isInvisible = !canForward + } + + private fun formatPlaybackTime(time: Int) = DateUtils.formatElapsedTime((time / 1000).toLong()) + private fun formatRemainingTime(time: Int) = if (time < 1000) formatPlaybackTime(time) else String.format("-%s", formatPlaybackTime(time)) override fun unbind(holder: Holder) { super.unbind(holder) - voiceBroadcastPlayer?.removeListener(playerListener) + player.removeListener(voiceBroadcast, playerListener) + playbackTracker.untrack(voiceBroadcast.voiceBroadcastId) + with(holder) { + seekBar.setOnSeekBarChangeListener(null) + playPauseButton.onClick(null) + fastForwardButton.onClick(null) + fastBackwardButton.onClick(null) + } } override fun getViewStubId() = STUB_ID - class Holder : AbsMessageItem.Holder(STUB_ID) { - val liveIndicator by bind(R.id.liveIndicator) - val roomAvatarImageView by bind(R.id.roomAvatarImageView) - val titleText by bind(R.id.titleText) + class Holder : AbsMessageVoiceBroadcastItem.Holder(STUB_ID) { val playPauseButton by bind(R.id.playPauseButton) - val bufferingView by bind(R.id.bufferingView) - val broadcasterNameText by bind(R.id.broadcasterNameText) + val bufferingView by bind(R.id.bufferingMetadata) + val fastBackwardButton by bind(R.id.fastBackwardButton) + val fastForwardButton by bind(R.id.fastForwardButton) + val seekBar by bind(R.id.seekBar) + val remainingTimeView by bind(R.id.remainingTime) + val elapsedTimeView by bind(R.id.elapsedTime) + val broadcasterNameMetadata by bind(R.id.broadcasterNameMetadata) + val voiceBroadcastMetadata by bind(R.id.voiceBroadcastMetadata) + val listenersCountMetadata by bind(R.id.listenersCountMetadata) } companion object { diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceBroadcastRecordingItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceBroadcastRecordingItem.kt index c417053b2a..39d2d73c68 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceBroadcastRecordingItem.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceBroadcastRecordingItem.kt @@ -17,45 +17,21 @@ package im.vector.app.features.home.room.detail.timeline.item import android.widget.ImageButton -import android.widget.ImageView -import android.widget.TextView import androidx.core.view.isVisible -import com.airbnb.epoxy.EpoxyAttribute import com.airbnb.epoxy.EpoxyModelClass import im.vector.app.R import im.vector.app.core.epoxy.onClick -import im.vector.app.core.extensions.tintBackground -import im.vector.app.core.resources.ColorProvider -import im.vector.app.core.resources.DrawableProvider +import im.vector.app.core.utils.TextUtils import im.vector.app.features.home.room.detail.RoomDetailAction.VoiceBroadcastAction -import im.vector.app.features.home.room.detail.timeline.TimelineEventController -import im.vector.app.features.voicebroadcast.VoiceBroadcastRecorder -import org.matrix.android.sdk.api.util.MatrixItem +import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState +import im.vector.app.features.voicebroadcast.recording.VoiceBroadcastRecorder +import im.vector.app.features.voicebroadcast.views.VoiceBroadcastMetadataView +import org.threeten.bp.Duration @EpoxyModelClass -abstract class MessageVoiceBroadcastRecordingItem : AbsMessageItem() { +abstract class MessageVoiceBroadcastRecordingItem : AbsMessageVoiceBroadcastItem() { - @EpoxyAttribute - var callback: TimelineEventController.Callback? = null - - @EpoxyAttribute - var voiceBroadcastRecorder: VoiceBroadcastRecorder? = null - - @EpoxyAttribute - lateinit var colorProvider: ColorProvider - - @EpoxyAttribute - lateinit var drawableProvider: DrawableProvider - - @EpoxyAttribute - var roomItem: MatrixItem? = null - - @EpoxyAttribute - var title: String? = null - - private lateinit var recorderListener: VoiceBroadcastRecorder.Listener - - override fun isCacheable(): Boolean = false + private var recorderListener: VoiceBroadcastRecorder.Listener? = null override fun bind(holder: Holder) { super.bind(holder) @@ -63,73 +39,107 @@ abstract class MessageVoiceBroadcastRecordingItem : AbsMessageItem renderPlayingLiveIndicator(holder) + VoiceBroadcastState.PAUSED -> renderPausedLiveIndicator(holder) + VoiceBroadcastState.STOPPED, null -> renderNoLiveIndicator(holder) } } - private fun renderState(holder: Holder, state: VoiceBroadcastRecorder.State) { - with(holder) { - when (state) { - VoiceBroadcastRecorder.State.Recording -> { - stopRecordButton.isEnabled = true - recordButton.isEnabled = true - - liveIndicator.isVisible = true - liveIndicator.tintBackground(colorProvider.getColorFromAttribute(R.attr.colorError)) - - val drawableColor = colorProvider.getColorFromAttribute(R.attr.vctr_content_secondary) - val drawable = drawableProvider.getDrawable(R.drawable.ic_play_pause_pause, drawableColor) - recordButton.setImageDrawable(drawable) - recordButton.contentDescription = holder.view.resources.getString(R.string.a11y_pause_voice_broadcast_record) - recordButton.onClick { attributes.callback?.onTimelineItemAction(VoiceBroadcastAction.Recording.Pause) } - stopRecordButton.onClick { attributes.callback?.onTimelineItemAction(VoiceBroadcastAction.Recording.Stop) } - } - VoiceBroadcastRecorder.State.Paused -> { - stopRecordButton.isEnabled = true - recordButton.isEnabled = true + override fun renderMetadata(holder: Holder) { + holder.listenersCountMetadata.isVisible = false + } - liveIndicator.isVisible = true - liveIndicator.tintBackground(colorProvider.getColorFromAttribute(R.attr.vctr_content_quaternary)) + private fun renderRemainingTime(holder: Holder, remainingTime: Long?) { + if (remainingTime != null) { + val formattedDuration = TextUtils.formatDurationWithUnits( + holder.view.context, + Duration.ofSeconds(remainingTime.coerceAtLeast(0L)) + ) + holder.remainingTimeMetadata.value = holder.view.resources.getString(R.string.voice_broadcast_recording_time_left, formattedDuration) + holder.remainingTimeMetadata.isVisible = true + } else { + holder.remainingTimeMetadata.isVisible = false + } + } - recordButton.setImageResource(R.drawable.ic_recording_dot) - recordButton.contentDescription = holder.view.resources.getString(R.string.a11y_resume_voice_broadcast_record) - recordButton.onClick { attributes.callback?.onTimelineItemAction(VoiceBroadcastAction.Recording.Resume) } - stopRecordButton.onClick { attributes.callback?.onTimelineItemAction(VoiceBroadcastAction.Recording.Stop) } - } - VoiceBroadcastRecorder.State.Idle -> { - recordButton.isEnabled = false - stopRecordButton.isEnabled = false - liveIndicator.isVisible = false - } - } + private fun renderRecordingState(holder: Holder, state: VoiceBroadcastRecorder.State) { + when (state) { + VoiceBroadcastRecorder.State.Recording -> renderRecordingState(holder) + VoiceBroadcastRecorder.State.Paused -> renderPausedState(holder) + VoiceBroadcastRecorder.State.Idle -> renderStoppedState(holder) + } + } + + private fun renderVoiceBroadcastState(holder: Holder) { + when (voiceBroadcastState) { + VoiceBroadcastState.STARTED, + VoiceBroadcastState.RESUMED -> renderRecordingState(holder) + VoiceBroadcastState.PAUSED -> renderPausedState(holder) + VoiceBroadcastState.STOPPED, + null -> renderStoppedState(holder) } } + private fun renderRecordingState(holder: Holder) = with(holder) { + stopRecordButton.isEnabled = true + recordButton.isEnabled = true + + val drawableColor = colorProvider.getColorFromAttribute(R.attr.vctr_content_secondary) + val drawable = drawableProvider.getDrawable(R.drawable.ic_play_pause_pause, drawableColor) + recordButton.setImageDrawable(drawable) + recordButton.contentDescription = holder.view.resources.getString(R.string.a11y_pause_voice_broadcast_record) + recordButton.onClick { callback?.onTimelineItemAction(VoiceBroadcastAction.Recording.Pause) } + stopRecordButton.onClick { callback?.onTimelineItemAction(VoiceBroadcastAction.Recording.Stop) } + } + + private fun renderPausedState(holder: Holder) = with(holder) { + stopRecordButton.isEnabled = true + recordButton.isEnabled = true + + recordButton.setImageResource(R.drawable.ic_recording_dot) + recordButton.contentDescription = holder.view.resources.getString(R.string.a11y_resume_voice_broadcast_record) + recordButton.onClick { callback?.onTimelineItemAction(VoiceBroadcastAction.Recording.Resume) } + stopRecordButton.onClick { callback?.onTimelineItemAction(VoiceBroadcastAction.Recording.Stop) } + } + + private fun renderStoppedState(holder: Holder) = with(holder) { + recordButton.isEnabled = false + stopRecordButton.isEnabled = false + } + override fun unbind(holder: Holder) { super.unbind(holder) - voiceBroadcastRecorder?.removeListener(recorderListener) + recorderListener?.let { recorder?.removeListener(it) } + recorderListener = null + with(holder) { + recordButton.onClick(null) + stopRecordButton.onClick(null) + } } override fun getViewStubId() = STUB_ID - class Holder : AbsMessageItem.Holder(STUB_ID) { - val liveIndicator by bind(R.id.liveIndicator) - val roomAvatarImageView by bind(R.id.roomAvatarImageView) - val titleText by bind(R.id.titleText) + class Holder : AbsMessageVoiceBroadcastItem.Holder(STUB_ID) { + val listenersCountMetadata by bind(R.id.listenersCountMetadata) + val remainingTimeMetadata by bind(R.id.remainingTimeMetadata) val recordButton by bind(R.id.recordButton) val stopRecordButton by bind(R.id.stopRecordButton) } diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceItem.kt index bd04916616..84c6a49cde 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceItem.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceItem.kt @@ -135,16 +135,14 @@ abstract class MessageVoiceItem : AbsMessageItem() { true } - audioMessagePlaybackTracker.track(attributes.informationData.eventId, object : AudioMessagePlaybackTracker.Listener { - override fun onUpdate(state: AudioMessagePlaybackTracker.Listener.State) { - when (state) { - is AudioMessagePlaybackTracker.Listener.State.Idle -> renderIdleState(holder, waveformColorIdle, waveformColorPlayed) - is AudioMessagePlaybackTracker.Listener.State.Playing -> renderPlayingState(holder, state, waveformColorIdle, waveformColorPlayed) - is AudioMessagePlaybackTracker.Listener.State.Paused -> renderPausedState(holder, state, waveformColorIdle, waveformColorPlayed) - is AudioMessagePlaybackTracker.Listener.State.Recording -> Unit - } + audioMessagePlaybackTracker.track(attributes.informationData.eventId) { state -> + when (state) { + is AudioMessagePlaybackTracker.Listener.State.Idle -> renderIdleState(holder, waveformColorIdle, waveformColorPlayed) + is AudioMessagePlaybackTracker.Listener.State.Playing -> renderPlayingState(holder, state, waveformColorIdle, waveformColorPlayed) + is AudioMessagePlaybackTracker.Listener.State.Paused -> renderPausedState(holder, state, waveformColorIdle, waveformColorPlayed) + is AudioMessagePlaybackTracker.Listener.State.Recording -> Unit } - }) + } } private fun getTouchedPositionPercentage(motionEvent: MotionEvent, view: View) = (motionEvent.x / view.width).coerceIn(0f, 1f) diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/TypingItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/TypingItem.kt new file mode 100644 index 0000000000..2ca0ebea48 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/TypingItem.kt @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2022 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 im.vector.app.features.home.room.detail.timeline.item + +import androidx.core.view.isInvisible +import androidx.core.view.isVisible +import com.airbnb.epoxy.EpoxyAttribute +import com.airbnb.epoxy.EpoxyModelClass +import com.airbnb.epoxy.EpoxyModelWithHolder +import im.vector.app.R +import im.vector.app.core.epoxy.VectorEpoxyHolder +import im.vector.app.core.ui.views.TypingMessageView +import im.vector.app.features.home.AvatarRenderer +import org.matrix.android.sdk.api.session.room.sender.SenderInfo + +@EpoxyModelClass +abstract class TypingItem : EpoxyModelWithHolder() { + + companion object { + private const val MAX_TYPING_MESSAGE_USERS_COUNT = 4 + } + + @EpoxyAttribute(EpoxyAttribute.Option.DoNotHash) + lateinit var avatarRenderer: AvatarRenderer + + @EpoxyAttribute + var users: List = emptyList() + + override fun getDefaultLayout(): Int = R.layout.item_typing_users + + override fun bind(holder: TypingHolder) { + super.bind(holder) + + val typingUsers = users.take(MAX_TYPING_MESSAGE_USERS_COUNT) + holder.typingView.apply { + animate().cancel() + val duration = 100L + if (typingUsers.isEmpty()) { + animate().translationY(height.toFloat()) + .alpha(0f) + .setDuration(duration) + .withEndAction { + isInvisible = true + }.start() + } else { + isVisible = true + + translationY = height.toFloat() + alpha = 0f + render(typingUsers, avatarRenderer) + animate().translationY(0f) + .alpha(1f) + .setDuration(duration) + .start() + } + } + } + + class TypingHolder : VectorEpoxyHolder() { + val typingView by bind(R.id.typingMessageView) + } +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/render/EventTextRenderer.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/render/EventTextRenderer.kt index 920f3e3b80..c46112f995 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/render/EventTextRenderer.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/render/EventTextRenderer.kt @@ -34,22 +34,22 @@ class EventTextRenderer @AssistedInject constructor( @Assisted private val roomId: String?, private val context: Context, private val avatarRenderer: AvatarRenderer, - private val sessionHolder: ActiveSessionHolder + private val activeSessionHolder: ActiveSessionHolder, ) { - /* ========================================================================================== - * Public api - * ========================================================================================== */ - @AssistedFactory interface Factory { fun create(roomId: String?): EventTextRenderer } /** - * @param text the text you want to render + * @param text the text to be rendered */ fun render(text: CharSequence): CharSequence { + return renderNotifyEveryone(text) + } + + private fun renderNotifyEveryone(text: CharSequence): CharSequence { return if (roomId != null && text.contains(MatrixItem.NOTIFY_EVERYONE)) { SpannableStringBuilder(text).apply { addNotifyEveryoneSpans(this, roomId) @@ -59,12 +59,8 @@ class EventTextRenderer @AssistedInject constructor( } } - /* ========================================================================================== - * Helper methods - * ========================================================================================== */ - private fun addNotifyEveryoneSpans(text: Spannable, roomId: String) { - val room: RoomSummary? = sessionHolder.getSafeActiveSession()?.roomService()?.getRoomSummary(roomId) + val room: RoomSummary? = activeSessionHolder.getSafeActiveSession()?.roomService()?.getRoomSummary(roomId) val matrixItem = MatrixItem.EveryoneInRoomItem( id = roomId, avatarUrl = room?.avatarUrl, diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/render/ProcessBodyOfReplyToEventUseCase.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/render/ProcessBodyOfReplyToEventUseCase.kt new file mode 100644 index 0000000000..2197d89a2c --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/render/ProcessBodyOfReplyToEventUseCase.kt @@ -0,0 +1,126 @@ +/* + * Copyright (c) 2022 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 im.vector.app.features.home.room.detail.timeline.render + +import im.vector.app.R +import im.vector.app.core.di.ActiveSessionHolder +import im.vector.app.core.resources.StringProvider +import org.matrix.android.sdk.api.session.events.model.getPollQuestion +import org.matrix.android.sdk.api.session.events.model.isAudioMessage +import org.matrix.android.sdk.api.session.events.model.isFileMessage +import org.matrix.android.sdk.api.session.events.model.isImageMessage +import org.matrix.android.sdk.api.session.events.model.isLiveLocation +import org.matrix.android.sdk.api.session.events.model.isPoll +import org.matrix.android.sdk.api.session.events.model.isSticker +import org.matrix.android.sdk.api.session.events.model.isVideoMessage +import org.matrix.android.sdk.api.session.events.model.isVoiceMessage +import org.matrix.android.sdk.api.session.getRoom +import org.matrix.android.sdk.api.session.room.getTimelineEvent +import org.matrix.android.sdk.api.session.room.model.relation.ReplyToContent +import javax.inject.Inject + +private const val IN_REPLY_TO = "In reply to" +private const val BREAKING_LINE = "
" +private const val ENDING_BLOCK_QUOTE = "" + +class ProcessBodyOfReplyToEventUseCase @Inject constructor( + private val activeSessionHolder: ActiveSessionHolder, + private val stringProvider: StringProvider, +) { + + fun execute(roomId: String, matrixFormattedBody: String, replyToContent: ReplyToContent): String { + val repliedToEvent = replyToContent.eventId?.let { getEvent(it, roomId) } + val breakingLineIndex = matrixFormattedBody.indexOf(BREAKING_LINE) + val endOfBlockQuoteIndex = matrixFormattedBody.lastIndexOf(ENDING_BLOCK_QUOTE) + + val withTranslatedContent = if (repliedToEvent != null && breakingLineIndex != -1 && endOfBlockQuoteIndex != -1) { + val afterBreakingLineIndex = breakingLineIndex + BREAKING_LINE.length + when { + repliedToEvent.isFileMessage() -> { + matrixFormattedBody.replaceRange( + afterBreakingLineIndex, + endOfBlockQuoteIndex, + stringProvider.getString(R.string.message_reply_to_sender_sent_file) + ) + } + repliedToEvent.isVoiceMessage() -> { + matrixFormattedBody.replaceRange( + afterBreakingLineIndex, + endOfBlockQuoteIndex, + stringProvider.getString(R.string.message_reply_to_sender_sent_voice_message) + ) + } + repliedToEvent.isAudioMessage() -> { + matrixFormattedBody.replaceRange( + afterBreakingLineIndex, + endOfBlockQuoteIndex, + stringProvider.getString(R.string.message_reply_to_sender_sent_audio_file) + ) + } + repliedToEvent.isImageMessage() -> { + matrixFormattedBody.replaceRange( + afterBreakingLineIndex, + endOfBlockQuoteIndex, + stringProvider.getString(R.string.message_reply_to_sender_sent_image) + ) + } + repliedToEvent.isVideoMessage() -> { + matrixFormattedBody.replaceRange( + afterBreakingLineIndex, + endOfBlockQuoteIndex, + stringProvider.getString(R.string.message_reply_to_sender_sent_video) + ) + } + repliedToEvent.isSticker() -> { + matrixFormattedBody.replaceRange( + afterBreakingLineIndex, + endOfBlockQuoteIndex, + stringProvider.getString(R.string.message_reply_to_sender_sent_sticker) + ) + } + repliedToEvent.isPoll() -> { + matrixFormattedBody.replaceRange( + afterBreakingLineIndex, + endOfBlockQuoteIndex, + repliedToEvent.getPollQuestion() ?: stringProvider.getString(R.string.message_reply_to_sender_created_poll) + ) + } + repliedToEvent.isLiveLocation() -> { + matrixFormattedBody.replaceRange( + afterBreakingLineIndex, + endOfBlockQuoteIndex, + stringProvider.getString(R.string.live_location_description) + ) + } + else -> matrixFormattedBody + } + } else { + matrixFormattedBody + } + + return withTranslatedContent.replace( + IN_REPLY_TO, + stringProvider.getString(R.string.message_reply_to_prefix) + ) + } + + private fun getEvent(eventId: String, roomId: String) = + activeSessionHolder.getSafeActiveSession() + ?.getRoom(roomId) + ?.getTimelineEvent(eventId) + ?.root +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/style/TimelineMessageLayoutFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/style/TimelineMessageLayoutFactory.kt index 379e5b3b91..c207a5f67e 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/style/TimelineMessageLayoutFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/style/TimelineMessageLayoutFactory.kt @@ -47,8 +47,10 @@ class TimelineMessageLayoutFactory @Inject constructor( private val EVENT_TYPES_WITH_BUBBLE_LAYOUT = setOf( EventType.MESSAGE, EventType.ENCRYPTED, - EventType.STICKER - ) + EventType.POLL_START + EventType.STATE_ROOM_BEACON_INFO + EventType.STICKER, + ) + + EventType.POLL_START.values + + EventType.STATE_ROOM_BEACON_INFO.values // Can't be rendered in bubbles, so get back to default layout private val MSG_TYPES_WITHOUT_BUBBLE_LAYOUT = setOf( diff --git a/vector/src/main/java/im/vector/app/features/home/room/list/RoomSummaryItemFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/list/RoomSummaryItemFactory.kt index bce73acd22..5748c19389 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/list/RoomSummaryItemFactory.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/list/RoomSummaryItemFactory.kt @@ -24,6 +24,7 @@ import fr.gouv.tchap.core.utils.TchapUtils import im.vector.app.R import im.vector.app.core.date.DateFormatKind import im.vector.app.core.date.VectorDateFormatter +import im.vector.app.core.di.ActiveSessionHolder import im.vector.app.core.epoxy.VectorEpoxyModel import im.vector.app.core.error.ErrorFormatter import im.vector.app.core.resources.StringProvider @@ -32,23 +33,33 @@ import im.vector.app.features.home.AvatarRenderer import im.vector.app.features.home.RoomListDisplayMode import im.vector.app.features.home.room.detail.timeline.format.DisplayableEventFormatter import im.vector.app.features.home.room.typing.TypingHelper +import im.vector.app.features.voicebroadcast.isLive +import im.vector.app.features.voicebroadcast.isVoiceBroadcast +import im.vector.app.features.voicebroadcast.model.asVoiceBroadcastEvent +import im.vector.app.features.voicebroadcast.usecase.GetRoomLiveVoiceBroadcastsUseCase import im.vector.lib.core.utils.epoxy.charsequence.toEpoxyCharSequence -import org.matrix.android.sdk.api.session.Session +import org.matrix.android.sdk.api.extensions.orFalse +import org.matrix.android.sdk.api.session.events.model.EventType +import org.matrix.android.sdk.api.session.getRoom +import org.matrix.android.sdk.api.session.room.getTimelineEvent import org.matrix.android.sdk.api.session.room.members.ChangeMembershipState import org.matrix.android.sdk.api.session.room.model.Membership import org.matrix.android.sdk.api.session.room.model.RoomSummary import org.matrix.android.sdk.api.session.room.model.SpaceChildInfo +import org.matrix.android.sdk.api.session.room.model.message.asMessageAudioEvent +import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent import org.matrix.android.sdk.api.util.toMatrixItem import javax.inject.Inject class RoomSummaryItemFactory @Inject constructor( + private val sessionHolder: ActiveSessionHolder, private val displayableEventFormatter: DisplayableEventFormatter, private val dateFormatter: VectorDateFormatter, private val stringProvider: StringProvider, private val typingHelper: TypingHelper, private val avatarRenderer: AvatarRenderer, private val errorFormatter: ErrorFormatter, - private val session: Session + private val getRoomLiveVoiceBroadcastsUseCase: GetRoomLiveVoiceBroadcastsUseCase, ) { fun create( @@ -103,7 +114,7 @@ class RoomSummaryItemFactory @Inject constructor( ): VectorEpoxyModel<*> { // Tchap: userXXX invited you val secondLine = roomSummary.inviterId?.let { userId -> - val displayName = session.userService().getUser(userId)?.toMatrixItem()?.getBestName() + val displayName = sessionHolder.getSafeActiveSession()?.userService()?.getUser(userId)?.toMatrixItem()?.getBestName() ?.let { displayName -> if (roomSummary.isDirect) { // We remove the user domain in second line because it is already present in first line @@ -142,13 +153,15 @@ class RoomSummaryItemFactory @Inject constructor( val showSelected = selectedRoomIds.contains(roomSummary.roomId) var latestFormattedEvent: CharSequence = "" var latestEventTime = "" - val latestEvent = roomSummary.latestPreviewableEvent + val latestEvent = roomSummary.getVectorLatestPreviewableEvent() if (latestEvent != null) { latestFormattedEvent = displayableEventFormatter.format(latestEvent, roomSummary.isDirect, roomSummary.isDirect.not()) latestEventTime = dateFormatter.format(latestEvent.root.originServerTs, DateFormatKind.ROOM_LIST) } val typingMessage = typingHelper.getTypingMessage(roomSummary.typingUsers) + // Skip typing while there is a live voice broadcast + .takeUnless { latestEvent?.root?.asVoiceBroadcastEvent()?.isLive.orFalse() }.orEmpty() return if (subtitle.isBlank() && displayMode == RoomListDisplayMode.FILTERED) { createCenteredRoomSummaryItem(roomSummary, displayMode, showSelected, unreadCount, onClick, onLongClick) @@ -240,4 +253,14 @@ class RoomSummaryItemFactory @Inject constructor( else -> stringProvider.getQuantityString(R.plurals.search_space_multiple_parents, size - 1, directParentNames[0], size - 1) } } + + private fun RoomSummary.getVectorLatestPreviewableEvent(): TimelineEvent? { + val room = sessionHolder.getSafeActiveSession()?.getRoom(roomId) ?: return latestPreviewableEvent + val liveVoiceBroadcastTimelineEvent = getRoomLiveVoiceBroadcastsUseCase.execute(roomId).lastOrNull() + ?.root?.eventId?.let { room.getTimelineEvent(it) } + return latestPreviewableEvent?.takeIf { it.root.getClearType() == EventType.CALL_INVITE } + ?: liveVoiceBroadcastTimelineEvent + ?: latestPreviewableEvent + ?.takeUnless { it.root.asMessageAudioEvent()?.isVoiceBroadcast().orFalse() } // Skip voice messages related to voice broadcast + } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/threads/ThreadsActivity.kt b/vector/src/main/java/im/vector/app/features/home/room/threads/ThreadsActivity.kt index b3f2ef1f75..014b9f0504 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/threads/ThreadsActivity.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/threads/ThreadsActivity.kt @@ -26,6 +26,7 @@ import im.vector.app.core.extensions.addFragmentToBackstack import im.vector.app.core.extensions.replaceFragment import im.vector.app.core.platform.VectorBaseActivity import im.vector.app.databinding.ActivityThreadsBinding +import im.vector.app.features.MainActivity import im.vector.app.features.analytics.extensions.toAnalyticsInteraction import im.vector.app.features.analytics.plan.Interaction import im.vector.app.features.home.AvatarRenderer @@ -143,13 +144,20 @@ class ThreadsActivity : VectorBaseActivity() { context: Context, threadTimelineArgs: ThreadTimelineArgs?, threadListArgs: ThreadListArgs?, - eventIdToNavigate: String? = null + eventIdToNavigate: String? = null, + firstStartMainActivity: Boolean = false ): Intent { - return Intent(context, ThreadsActivity::class.java).apply { + val intent = Intent(context, ThreadsActivity::class.java).apply { putExtra(THREAD_TIMELINE_ARGS, threadTimelineArgs) putExtra(THREAD_EVENT_ID_TO_NAVIGATE, eventIdToNavigate) putExtra(THREAD_LIST_ARGS, threadListArgs) } + + return if (firstStartMainActivity) { + MainActivity.getIntentWithNextIntent(context, intent) + } else { + intent + } } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/threads/list/viewmodel/ThreadListController.kt b/vector/src/main/java/im/vector/app/features/home/room/threads/list/viewmodel/ThreadListController.kt index 14098fd8b0..3cbe652076 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/threads/list/viewmodel/ThreadListController.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/threads/list/viewmodel/ThreadListController.kt @@ -19,24 +19,18 @@ package im.vector.app.features.home.room.threads.list.viewmodel import com.airbnb.epoxy.EpoxyController import im.vector.app.core.date.DateFormatKind import im.vector.app.core.date.VectorDateFormatter -import im.vector.app.core.resources.StringProvider import im.vector.app.features.home.AvatarRenderer import im.vector.app.features.home.room.detail.timeline.format.DisplayableEventFormatter import im.vector.app.features.home.room.threads.list.model.threadListItem -import org.matrix.android.sdk.api.session.Session -import org.matrix.android.sdk.api.session.room.threads.model.ThreadSummary import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent import org.matrix.android.sdk.api.session.threads.ThreadNotificationState import org.matrix.android.sdk.api.util.toMatrixItem -import org.matrix.android.sdk.api.util.toMatrixItemOrNull import javax.inject.Inject class ThreadListController @Inject constructor( private val avatarRenderer: AvatarRenderer, - private val stringProvider: StringProvider, private val dateFormatter: VectorDateFormatter, private val displayableEventFormatter: DisplayableEventFormatter, - private val session: Session ) : EpoxyController() { var listener: Listener? = null @@ -48,64 +42,7 @@ class ThreadListController @Inject constructor( requestModelBuild() } - override fun buildModels() = - when (session.homeServerCapabilitiesService().getHomeServerCapabilities().canUseThreading) { - true -> buildThreadSummaries() - false -> buildThreadList() - } - - /** - * Building thread summaries when homeserver supports threading. - */ - private fun buildThreadSummaries() { - val safeViewState = viewState ?: return - val host = this - safeViewState.threadSummaryList.invoke() - ?.filter { - if (safeViewState.shouldFilterThreads) { - it.isUserParticipating - } else { - true - } - } - ?.forEach { threadSummary -> - val date = dateFormatter.format(threadSummary.latestEvent?.originServerTs, DateFormatKind.ROOM_LIST) - val lastMessageFormatted = threadSummary.let { - displayableEventFormatter.formatThreadSummary( - event = it.latestEvent, - latestEdition = it.threadEditions.latestThreadEdition - ).toString() - } - val rootMessageFormatted = threadSummary.let { - displayableEventFormatter.formatThreadSummary( - event = it.rootEvent, - latestEdition = it.threadEditions.rootThreadEdition - ).toString() - } - threadListItem { - id(threadSummary.rootEvent?.eventId) - avatarRenderer(host.avatarRenderer) - matrixItem(threadSummary.rootThreadSenderInfo.toMatrixItem()) - title(threadSummary.rootThreadSenderInfo.displayName.orEmpty()) - date(date) - rootMessageDeleted(threadSummary.rootEvent?.isRedacted() ?: false) - // TODO refactor notifications that with the new thread summary - threadNotificationState(threadSummary.rootEvent?.threadDetails?.threadNotificationState ?: ThreadNotificationState.NO_NEW_MESSAGE) - rootMessage(rootMessageFormatted) - lastMessage(lastMessageFormatted) - lastMessageCounter(threadSummary.numberOfThreads.toString()) - lastMessageMatrixItem(threadSummary.latestThreadSenderInfo.toMatrixItemOrNull()) - itemClickListener { - host.listener?.onThreadSummaryClicked(threadSummary) - } - } - } - } - - /** - * Building local thread list when homeserver do not support threading. - */ - private fun buildThreadList() { + override fun buildModels() { val safeViewState = viewState ?: return val host = this safeViewState.rootThreadEventList.invoke() @@ -152,7 +89,6 @@ class ThreadListController @Inject constructor( } interface Listener { - fun onThreadSummaryClicked(threadSummary: ThreadSummary) fun onThreadListClicked(timelineEvent: TimelineEvent) } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/threads/list/viewmodel/ThreadListPagedController.kt b/vector/src/main/java/im/vector/app/features/home/room/threads/list/viewmodel/ThreadListPagedController.kt new file mode 100644 index 0000000000..171b690a33 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/home/room/threads/list/viewmodel/ThreadListPagedController.kt @@ -0,0 +1,84 @@ +/* + * Copyright 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 im.vector.app.features.home.room.threads.list.viewmodel + +import com.airbnb.epoxy.EpoxyModel +import com.airbnb.epoxy.paging.PagedListEpoxyController +import im.vector.app.core.date.DateFormatKind +import im.vector.app.core.date.VectorDateFormatter +import im.vector.app.core.utils.createUIHandler +import im.vector.app.features.home.AvatarRenderer +import im.vector.app.features.home.room.detail.timeline.format.DisplayableEventFormatter +import im.vector.app.features.home.room.threads.list.model.ThreadListItem_ +import org.matrix.android.sdk.api.session.room.threads.model.ThreadSummary +import org.matrix.android.sdk.api.session.threads.ThreadNotificationState +import org.matrix.android.sdk.api.util.toMatrixItem +import org.matrix.android.sdk.api.util.toMatrixItemOrNull +import javax.inject.Inject + +class ThreadListPagedController @Inject constructor( + private val avatarRenderer: AvatarRenderer, + private val dateFormatter: VectorDateFormatter, + private val displayableEventFormatter: DisplayableEventFormatter, +) : PagedListEpoxyController( + // Important it must match the PageList builder notify Looper + modelBuildingHandler = createUIHandler() +) { + + var listener: Listener? = null + + override fun buildItemModel(currentPosition: Int, item: ThreadSummary?): EpoxyModel<*> { + if (item == null) { + throw java.lang.NullPointerException() + } + val host = this + val date = dateFormatter.format(item.latestEvent?.originServerTs, DateFormatKind.ROOM_LIST) + val lastMessageFormatted = item.let { + displayableEventFormatter.formatThreadSummary( + event = it.latestEvent, + latestEdition = it.threadEditions.latestThreadEdition + ).toString() + } + val rootMessageFormatted = item.let { + displayableEventFormatter.formatThreadSummary( + event = it.rootEvent, + latestEdition = it.threadEditions.rootThreadEdition + ).toString() + } + + return ThreadListItem_() + .id(item.rootEvent?.eventId) + .avatarRenderer(host.avatarRenderer) + .matrixItem(item.rootThreadSenderInfo.toMatrixItem()) + .title(item.rootThreadSenderInfo.displayName.orEmpty()) + .date(date) + .rootMessageDeleted(item.rootEvent?.isRedacted() ?: false) + // TODO refactor notifications that with the new thread summary + .threadNotificationState(item.rootEvent?.threadDetails?.threadNotificationState ?: ThreadNotificationState.NO_NEW_MESSAGE) + .rootMessage(rootMessageFormatted) + .lastMessage(lastMessageFormatted) + .lastMessageCounter(item.numberOfThreads.toString()) + .lastMessageMatrixItem(item.latestThreadSenderInfo.toMatrixItemOrNull()) + .itemClickListener { + host.listener?.onThreadSummaryClicked(item) + } + } + + interface Listener { + fun onThreadSummaryClicked(threadSummary: ThreadSummary) + } +} diff --git a/vector/src/main/java/im/vector/app/features/home/room/threads/list/viewmodel/ThreadListViewModel.kt b/vector/src/main/java/im/vector/app/features/home/room/threads/list/viewmodel/ThreadListViewModel.kt index 4b7af330fb..7124727bb7 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/threads/list/viewmodel/ThreadListViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/threads/list/viewmodel/ThreadListViewModel.kt @@ -16,6 +16,11 @@ package im.vector.app.features.home.room.threads.list.viewmodel +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.Observer +import androidx.lifecycle.asFlow +import androidx.paging.PagedList import com.airbnb.mvrx.FragmentViewModelContext import com.airbnb.mvrx.MavericksViewModelFactory import com.airbnb.mvrx.ViewModelContext @@ -29,23 +34,47 @@ import im.vector.app.features.analytics.AnalyticsTracker import im.vector.app.features.analytics.extensions.toAnalyticsInteraction import im.vector.app.features.analytics.plan.Interaction import im.vector.app.features.home.room.threads.list.views.ThreadListFragment +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.getRoom +import org.matrix.android.sdk.api.session.room.threads.FetchThreadsResult +import org.matrix.android.sdk.api.session.room.threads.ThreadFilter +import org.matrix.android.sdk.api.session.room.threads.model.ThreadSummary import org.matrix.android.sdk.api.session.threads.ThreadTimelineEvent import org.matrix.android.sdk.flow.flow class ThreadListViewModel @AssistedInject constructor( @Assisted val initialState: ThreadListViewState, private val analyticsTracker: AnalyticsTracker, - private val session: Session -) : - VectorViewModel(initialState) { + private val session: Session, +) : VectorViewModel(initialState) { private val room = session.getRoom(initialState.roomId) + private val defaultPagedListConfig = PagedList.Config.Builder() + .setPageSize(20) + .setInitialLoadSizeHint(40) + .setEnablePlaceholders(false) + .setPrefetchDistance(10) + .build() + + private var nextBatchId: String? = null + private var hasReachedEnd: Boolean = false + private var boundariesJob: Job? = null + + private var livePagedList: LiveData>? = null + private val _threadsLivePagedList = MutableLiveData>() + val threadsLivePagedList: LiveData> = _threadsLivePagedList + private val internalPagedListObserver = Observer> { + _threadsLivePagedList.postValue(it) + setLoading(false) + } + @AssistedFactory interface Factory { fun create(initialState: ThreadListViewState): ThreadListViewModel @@ -54,7 +83,7 @@ class ThreadListViewModel @AssistedInject constructor( companion object : MavericksViewModelFactory { @JvmStatic - override fun create(viewModelContext: ViewModelContext, state: ThreadListViewState): ThreadListViewModel? { + override fun create(viewModelContext: ViewModelContext, state: ThreadListViewState): ThreadListViewModel { val fragment: ThreadListFragment = (viewModelContext as FragmentViewModelContext).fragment() return fragment.threadListViewModelFactory.create(state) } @@ -72,7 +101,7 @@ class ThreadListViewModel @AssistedInject constructor( private fun fetchAndObserveThreads() { when (session.homeServerCapabilitiesService().getHomeServerCapabilities().canUseThreading) { true -> { - fetchThreadList() + setLoading(true) observeThreadSummaries() } false -> observeThreadsList() @@ -82,14 +111,33 @@ class ThreadListViewModel @AssistedInject constructor( /** * Observing thread summaries when homeserver support threading. */ - private fun observeThreadSummaries() { - room?.flow() - ?.liveThreadSummaries() - ?.map { room.threadsService().enhanceThreadWithEditions(it) } - ?.flowOn(room.coroutineDispatchers.io) - ?.execute { asyncThreads -> - copy(threadSummaryList = asyncThreads) - } + private fun observeThreadSummaries() = withState { state -> + viewModelScope.launch { + nextBatchId = null + hasReachedEnd = false + + livePagedList?.removeObserver(internalPagedListObserver) + + room?.threadsService() + ?.getPagedThreadsList(state.shouldFilterThreads, defaultPagedListConfig)?.let { result -> + livePagedList = result.livePagedList + + livePagedList?.observeForever(internalPagedListObserver) + + boundariesJob = result.liveBoundaries.asFlow() + .onEach { + if (it.endLoaded) { + if (!hasReachedEnd) { + fetchNextPage() + } + } + } + .launchIn(viewModelScope) + } + + setLoading(true) + fetchNextPage() + } } /** @@ -111,14 +159,6 @@ class ThreadListViewModel @AssistedInject constructor( } } - private fun fetchThreadList() { - viewModelScope.launch { - setLoading(true) - room?.threadsService()?.fetchThreadSummaries() - setLoading(false) - } - } - private fun setLoading(isLoading: Boolean) { setState { copy(isLoading = isLoading) @@ -132,5 +172,30 @@ class ThreadListViewModel @AssistedInject constructor( setState { copy(shouldFilterThreads = shouldFilterThreads) } + + fetchAndObserveThreads() + } + + private suspend fun fetchNextPage() { + val filter = when (awaitState().shouldFilterThreads) { + true -> ThreadFilter.PARTICIPATED + false -> ThreadFilter.ALL + } + room?.threadsService()?.fetchThreadList( + nextBatchId = nextBatchId, + limit = defaultPagedListConfig.pageSize, + filter = filter, + ).let { result -> + when (result) { + is FetchThreadsResult.ReachedEnd -> { + hasReachedEnd = true + } + is FetchThreadsResult.ShouldFetchMore -> { + nextBatchId = result.nextBatch + } + else -> { + } + } + } } } diff --git a/vector/src/main/java/im/vector/app/features/home/room/threads/list/viewmodel/ThreadListViewState.kt b/vector/src/main/java/im/vector/app/features/home/room/threads/list/viewmodel/ThreadListViewState.kt index 2328da0b8a..60ccfb59af 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/threads/list/viewmodel/ThreadListViewState.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/threads/list/viewmodel/ThreadListViewState.kt @@ -20,11 +20,9 @@ import com.airbnb.mvrx.Async import com.airbnb.mvrx.MavericksState import com.airbnb.mvrx.Uninitialized import im.vector.app.features.home.room.threads.arguments.ThreadListArgs -import org.matrix.android.sdk.api.session.room.threads.model.ThreadSummary import org.matrix.android.sdk.api.session.threads.ThreadTimelineEvent data class ThreadListViewState( - val threadSummaryList: Async> = Uninitialized, val rootThreadEventList: Async> = Uninitialized, val shouldFilterThreads: Boolean = false, val isLoading: Boolean = false, diff --git a/vector/src/main/java/im/vector/app/features/home/room/threads/list/views/ThreadListFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/threads/list/views/ThreadListFragment.kt index f91fe9bd91..318c250906 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/threads/list/views/ThreadListFragment.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/threads/list/views/ThreadListFragment.kt @@ -40,6 +40,7 @@ import im.vector.app.features.home.room.threads.ThreadsActivity import im.vector.app.features.home.room.threads.arguments.ThreadListArgs import im.vector.app.features.home.room.threads.arguments.ThreadTimelineArgs import im.vector.app.features.home.room.threads.list.viewmodel.ThreadListController +import im.vector.app.features.home.room.threads.list.viewmodel.ThreadListPagedController import im.vector.app.features.home.room.threads.list.viewmodel.ThreadListViewModel import im.vector.app.features.home.room.threads.list.viewmodel.ThreadListViewState import im.vector.app.features.rageshake.BugReporter @@ -52,12 +53,14 @@ import javax.inject.Inject @AndroidEntryPoint class ThreadListFragment : VectorBaseFragment(), + ThreadListPagedController.Listener, ThreadListController.Listener, VectorMenuProvider { @Inject lateinit var avatarRenderer: AvatarRenderer @Inject lateinit var bugReporter: BugReporter - @Inject lateinit var threadListController: ThreadListController + @Inject lateinit var threadListController: ThreadListPagedController + @Inject lateinit var legacyThreadListController: ThreadListController @Inject lateinit var threadListViewModelFactory: ThreadListViewModel.Factory private val threadListViewModel: ThreadListViewModel by fragmentViewModel() @@ -100,7 +103,7 @@ class ThreadListFragment : val filterBadge = filterIcon.findViewById(R.id.threadListFilterBadge) filterBadge.isVisible = state.shouldFilterThreads when (threadListViewModel.canHomeserverUseThreading()) { - true -> menu.findItem(R.id.menu_thread_list_filter).isVisible = !state.threadSummaryList.invoke().isNullOrEmpty() + true -> menu.findItem(R.id.menu_thread_list_filter).isVisible = true false -> menu.findItem(R.id.menu_thread_list_filter).isVisible = !state.rootThreadEventList.invoke().isNullOrEmpty() } } @@ -111,8 +114,18 @@ class ThreadListFragment : initToolbar() initTextConstants() initBetaFeedback() - views.threadListRecyclerView.configureWith(threadListController, TimelineItemAnimator(), hasFixedSize = false) - threadListController.listener = this + + if (threadListViewModel.canHomeserverUseThreading()) { + views.threadListRecyclerView.configureWith(threadListController, TimelineItemAnimator(), hasFixedSize = false) + threadListController.listener = this + + threadListViewModel.threadsLivePagedList.observe(viewLifecycleOwner) { threadsList -> + threadListController.submitList(threadsList) + } + } else { + views.threadListRecyclerView.configureWith(legacyThreadListController, TimelineItemAnimator(), hasFixedSize = false) + legacyThreadListController.listener = this + } } override fun onDestroyView() { @@ -144,7 +157,9 @@ class ThreadListFragment : override fun invalidate() = withState(threadListViewModel) { state -> invalidateOptionsMenu() renderEmptyStateIfNeeded(state) - threadListController.update(state) + if (!threadListViewModel.canHomeserverUseThreading()) { + legacyThreadListController.update(state) + } renderLoaderIfNeeded(state) } @@ -185,7 +200,7 @@ class ThreadListFragment : private fun renderEmptyStateIfNeeded(state: ThreadListViewState) { when (threadListViewModel.canHomeserverUseThreading()) { - true -> views.threadListEmptyConstraintLayout.isVisible = state.threadSummaryList.invoke().isNullOrEmpty() + true -> views.threadListEmptyConstraintLayout.isVisible = false false -> views.threadListEmptyConstraintLayout.isVisible = state.rootThreadEventList.invoke().isNullOrEmpty() } } diff --git a/vector/src/main/java/im/vector/app/features/html/EventHtmlRenderer.kt b/vector/src/main/java/im/vector/app/features/html/EventHtmlRenderer.kt index 9e869ecde1..21fcbffb03 100644 --- a/vector/src/main/java/im/vector/app/features/html/EventHtmlRenderer.kt +++ b/vector/src/main/java/im/vector/app/features/html/EventHtmlRenderer.kt @@ -27,6 +27,7 @@ package im.vector.app.features.html import android.content.Context import android.content.res.Resources +import android.graphics.Typeface import android.graphics.drawable.Drawable import android.text.Spannable import androidx.core.text.toSpannable @@ -40,6 +41,7 @@ import im.vector.app.features.settings.VectorPreferences import io.noties.markwon.AbstractMarkwonPlugin import io.noties.markwon.Markwon import io.noties.markwon.MarkwonPlugin +import io.noties.markwon.MarkwonSpansFactory import io.noties.markwon.PrecomputedFutureTextSetterCompat import io.noties.markwon.ext.latex.JLatexMathPlugin import io.noties.markwon.ext.latex.JLatexMathTheme @@ -50,6 +52,8 @@ import io.noties.markwon.inlineparser.EntityInlineProcessor import io.noties.markwon.inlineparser.HtmlInlineProcessor import io.noties.markwon.inlineparser.MarkwonInlineParser import io.noties.markwon.inlineparser.MarkwonInlineParserPlugin +import me.gujun.android.span.style.CustomTypefaceSpan +import org.commonmark.node.Emphasis import org.commonmark.node.Node import org.commonmark.parser.Parser import org.matrix.android.sdk.api.MatrixUrls.isMxcUrl @@ -123,6 +127,12 @@ class EventHtmlRenderer @Inject constructor( ) ) .usePlugin(object : AbstractMarkwonPlugin() { + override fun configureSpansFactory(builder: MarkwonSpansFactory.Builder) { + builder.setFactory( + Emphasis::class.java + ) { _, _ -> CustomTypefaceSpan(Typeface.create(Typeface.DEFAULT, Typeface.ITALIC)) } + } + override fun configureParser(builder: Parser.Builder) { /* Configuring the Markwon block formatting processor. * Default settings are all Markdown blocks. Turn those off. diff --git a/vector/src/main/java/im/vector/app/features/html/PillsPostProcessor.kt b/vector/src/main/java/im/vector/app/features/html/PillsPostProcessor.kt index 85cfb76ff7..f6e10a6df9 100644 --- a/vector/src/main/java/im/vector/app/features/html/PillsPostProcessor.kt +++ b/vector/src/main/java/im/vector/app/features/html/PillsPostProcessor.kt @@ -83,6 +83,20 @@ class PillsPostProcessor @AssistedInject constructor( val pillSpan = linkSpan.createPillSpan(roomId) ?: return@forEach val startSpan = renderedText.getSpanStart(linkSpan) val endSpan = renderedText.getSpanEnd(linkSpan) + // GlideImagesPlugin causes duplicated pills if we have a nested spans in the pill span, + // such as images or italic text. + // Accordingly, it's better to remove all spans that are contained in this span before rendering. + renderedText.getSpans(startSpan, endSpan, Any::class.java).forEach remove@{ + if (it !is LinkSpan) { + // Make sure to only remove spans that are contained in this link, and not are bigger than this link, e.g. like reply-blocks + val start = renderedText.getSpanStart(it) + if (start < startSpan) return@remove + val end = renderedText.getSpanEnd(it) + if (end > endSpan) return@remove + + renderedText.removeSpan(it) + } + } addPillSpan(renderedText, pillSpan, startSpan, endSpan) } } diff --git a/vector/src/main/java/im/vector/app/features/location/LocationSharingViewModel.kt b/vector/src/main/java/im/vector/app/features/location/LocationSharingViewModel.kt index 4c7abd99b8..cdef7d3302 100644 --- a/vector/src/main/java/im/vector/app/features/location/LocationSharingViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/location/LocationSharingViewModel.kt @@ -83,7 +83,7 @@ class LocationSharingViewModel @AssistedInject constructor( .distinctUntilChanged() .setOnEach { val powerLevelsHelper = PowerLevelsHelper(it) - val canShareLiveLocation = EventType.STATE_ROOM_BEACON_INFO + val canShareLiveLocation = EventType.STATE_ROOM_BEACON_INFO.values .all { beaconInfoType -> powerLevelsHelper.isUserAllowedToSend(session.myUserId, true, beaconInfoType) } diff --git a/vector/src/main/java/im/vector/app/features/location/live/map/LiveLocationUserItem.kt b/vector/src/main/java/im/vector/app/features/location/live/map/LiveLocationUserItem.kt index bab7f4c7f9..c108e83e76 100644 --- a/vector/src/main/java/im/vector/app/features/location/live/map/LiveLocationUserItem.kt +++ b/vector/src/main/java/im/vector/app/features/location/live/map/LiveLocationUserItem.kt @@ -79,10 +79,8 @@ abstract class LiveLocationUserItem : VectorEpoxyModel diff --git a/vector/src/main/java/im/vector/app/features/login/LoginSplashFragment.kt b/vector/src/main/java/im/vector/app/features/login/LoginSplashFragment.kt index 6a8de819fd..dd563483ec 100644 --- a/vector/src/main/java/im/vector/app/features/login/LoginSplashFragment.kt +++ b/vector/src/main/java/im/vector/app/features/login/LoginSplashFragment.kt @@ -65,8 +65,7 @@ class LoginSplashFragment : views.loginSplashVersion.isVisible = true @SuppressLint("SetTextI18n") views.loginSplashVersion.text = "Version : ${buildMeta.versionName}\n" + - "Branch: ${buildMeta.gitBranchName}\n" + - "Build: ${buildMeta.buildNumber}" + "Branch: ${buildMeta.gitBranchName} ${buildMeta.gitRevision}" views.loginSplashVersion.debouncedClicks { navigator.openDebug(requireContext()) } } } diff --git a/vector/src/main/java/im/vector/app/features/notifications/NotifiableEventProcessor.kt b/vector/src/main/java/im/vector/app/features/notifications/NotifiableEventProcessor.kt index 6bdd2ab511..81b9844e36 100644 --- a/vector/src/main/java/im/vector/app/features/notifications/NotifiableEventProcessor.kt +++ b/vector/src/main/java/im/vector/app/features/notifications/NotifiableEventProcessor.kt @@ -30,13 +30,13 @@ class NotifiableEventProcessor @Inject constructor( private val autoAcceptInvites: AutoAcceptInvites ) { - fun process(queuedEvents: List, currentRoomId: String?, renderedEvents: ProcessedEvents): ProcessedEvents { + fun process(queuedEvents: List, currentRoomId: String?, currentThreadId: String?, renderedEvents: ProcessedEvents): ProcessedEvents { val processedEvents = queuedEvents.map { val type = when (it) { is InviteNotifiableEvent -> if (autoAcceptInvites.hideInvites) REMOVE else KEEP is NotifiableMessageEvent -> when { - shouldIgnoreMessageEventInRoom(currentRoomId, it.roomId) -> REMOVE - .also { Timber.d("notification message removed due to currently viewing the same room") } + it.shouldIgnoreMessageEventInRoom(currentRoomId, currentThreadId) -> REMOVE + .also { Timber.d("notification message removed due to currently viewing the same room or thread") } outdatedDetector.isMessageOutdated(it) -> REMOVE .also { Timber.d("notification message removed due to being read") } else -> KEEP @@ -55,8 +55,4 @@ class NotifiableEventProcessor @Inject constructor( return removedEventsDiff + processedEvents } - - private fun shouldIgnoreMessageEventInRoom(currentRoomId: String?, roomId: String?): Boolean { - return currentRoomId != null && roomId == currentRoomId - } } diff --git a/vector/src/main/java/im/vector/app/features/notifications/NotifiableEventResolver.kt b/vector/src/main/java/im/vector/app/features/notifications/NotifiableEventResolver.kt index ba1d5c7f6f..988ab01ef8 100644 --- a/vector/src/main/java/im/vector/app/features/notifications/NotifiableEventResolver.kt +++ b/vector/src/main/java/im/vector/app/features/notifications/NotifiableEventResolver.kt @@ -32,6 +32,7 @@ import org.matrix.android.sdk.api.session.crypto.MXCryptoError import org.matrix.android.sdk.api.session.crypto.model.OlmDecryptionResult import org.matrix.android.sdk.api.session.events.model.Event import org.matrix.android.sdk.api.session.events.model.EventType +import org.matrix.android.sdk.api.session.events.model.getRootThreadEventId import org.matrix.android.sdk.api.session.events.model.isEdition import org.matrix.android.sdk.api.session.events.model.isImageMessage import org.matrix.android.sdk.api.session.events.model.supportsNotification @@ -66,7 +67,7 @@ class NotifiableEventResolver @Inject constructor( ) { private val nonEncryptedNotifiableEventTypes: List = - listOf(EventType.MESSAGE) + EventType.POLL_START + EventType.STATE_ROOM_BEACON_INFO + listOf(EventType.MESSAGE) + EventType.POLL_START.values + EventType.STATE_ROOM_BEACON_INFO.values suspend fun resolveEvent(event: Event, session: Session, isNoisy: Boolean): NotifiableEvent? { val roomID = event.roomId ?: return null @@ -133,7 +134,7 @@ class NotifiableEventResolver @Inject constructor( } } - private suspend fun resolveMessageEvent(event: TimelineEvent, session: Session, canBeReplaced: Boolean, isNoisy: Boolean): NotifiableEvent? { + private suspend fun resolveMessageEvent(event: TimelineEvent, session: Session, canBeReplaced: Boolean, isNoisy: Boolean): NotifiableMessageEvent? { // The event only contains an eventId, and roomId (type is m.room.*) , we need to get the displayable content (names, avatar, text, etc...) val room = session.getRoom(event.root.roomId!! /*roomID cannot be null*/) @@ -155,6 +156,7 @@ class NotifiableEventResolver @Inject constructor( body = body.toString(), imageUriString = event.fetchImageIfPresent(session)?.toString(), roomId = event.root.roomId!!, + threadId = event.root.getRootThreadEventId(), roomName = roomName, matrixID = session.myUserId ) @@ -178,6 +180,7 @@ class NotifiableEventResolver @Inject constructor( body = body, imageUriString = event.fetchImageIfPresent(session)?.toString(), roomId = event.root.roomId!!, + threadId = event.root.getRootThreadEventId(), roomName = roomName, roomIsDirect = room.roomSummary()?.isDirect ?: false, roomAvatarPath = session.contentUrlResolver() diff --git a/vector/src/main/java/im/vector/app/features/notifications/NotifiableMessageEvent.kt b/vector/src/main/java/im/vector/app/features/notifications/NotifiableMessageEvent.kt index 68268739a0..bbd8c6638c 100644 --- a/vector/src/main/java/im/vector/app/features/notifications/NotifiableMessageEvent.kt +++ b/vector/src/main/java/im/vector/app/features/notifications/NotifiableMessageEvent.kt @@ -31,6 +31,7 @@ data class NotifiableMessageEvent( // NotSerializableException when persisting this to storage val imageUriString: String?, val roomId: String, + val threadId: String?, val roomName: String?, val roomIsDirect: Boolean = false, val roomAvatarPath: String? = null, @@ -51,3 +52,10 @@ data class NotifiableMessageEvent( val imageUri: Uri? get() = imageUriString?.let { Uri.parse(it) } } + +fun NotifiableMessageEvent.shouldIgnoreMessageEventInRoom(currentRoomId: String?, currentThreadId: String?): Boolean { + return when (currentRoomId) { + null -> false + else -> roomId == currentRoomId && threadId == currentThreadId + } +} diff --git a/vector/src/main/java/im/vector/app/features/notifications/NotificationBroadcastReceiver.kt b/vector/src/main/java/im/vector/app/features/notifications/NotificationBroadcastReceiver.kt index 3fe0898eb4..e231686c27 100644 --- a/vector/src/main/java/im/vector/app/features/notifications/NotificationBroadcastReceiver.kt +++ b/vector/src/main/java/im/vector/app/features/notifications/NotificationBroadcastReceiver.kt @@ -109,7 +109,7 @@ class NotificationBroadcastReceiver : BroadcastReceiver() { val room = session.getRoom(roomId) if (room != null) { session.coroutineScope.launch { - tryOrNull { room.readService().markAsRead(ReadService.MarkAsReadParams.READ_RECEIPT) } + tryOrNull { room.readService().markAsRead(ReadService.MarkAsReadParams.READ_RECEIPT, mainTimeLineOnly = false) } } } } @@ -118,6 +118,7 @@ class NotificationBroadcastReceiver : BroadcastReceiver() { private fun handleSmartReply(intent: Intent, context: Context) { val message = getReplyMessage(intent) val roomId = intent.getStringExtra(KEY_ROOM_ID) + val threadId = intent.getStringExtra(KEY_THREAD_ID) if (message.isNullOrBlank() || roomId.isNullOrBlank()) { // ignore this event @@ -126,13 +127,20 @@ class NotificationBroadcastReceiver : BroadcastReceiver() { } activeSessionHolder.getActiveSession().let { session -> session.getRoom(roomId)?.let { room -> - sendMatrixEvent(message, session, room, context) + sendMatrixEvent(message, threadId, session, room, context) } } } - private fun sendMatrixEvent(message: String, session: Session, room: Room, context: Context?) { - room.sendService().sendTextMessage(message) + private fun sendMatrixEvent(message: String, threadId: String?, session: Session, room: Room, context: Context?) { + if (threadId != null) { + room.relationService().replyInThread( + rootThreadEventId = threadId, + replyInThreadText = message, + ) + } else { + room.sendService().sendTextMessage(message) + } // Create a new event to be displayed in the notification drawer, right now @@ -148,6 +156,7 @@ class NotificationBroadcastReceiver : BroadcastReceiver() { body = message, imageUriString = null, roomId = room.roomId, + threadId = threadId, roomName = room.roomSummary()?.displayName ?: room.roomId, roomIsDirect = room.roomSummary()?.isDirect == true, outGoingMessage = true, @@ -222,6 +231,7 @@ class NotificationBroadcastReceiver : BroadcastReceiver() { companion object { const val KEY_ROOM_ID = "roomID" + const val KEY_THREAD_ID = "threadID" const val KEY_TEXT_REPLY = "key_text_reply" } } diff --git a/vector/src/main/java/im/vector/app/features/notifications/NotificationDrawerManager.kt b/vector/src/main/java/im/vector/app/features/notifications/NotificationDrawerManager.kt index 2623045cf3..4f05e83bd4 100644 --- a/vector/src/main/java/im/vector/app/features/notifications/NotificationDrawerManager.kt +++ b/vector/src/main/java/im/vector/app/features/notifications/NotificationDrawerManager.kt @@ -63,6 +63,7 @@ class NotificationDrawerManager @Inject constructor( private val notificationState by lazy { createInitialNotificationState() } private val avatarSize = context.resources.getDimensionPixelSize(R.dimen.profile_avatar_size) private var currentRoomId: String? = null + private var currentThreadId: String? = null private val firstThrottler = FirstThrottler(200) private var useCompleteNotificationFormat = vectorPreferences.useCompleteNotificationFormat() @@ -123,6 +124,22 @@ class NotificationDrawerManager @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. + */ + fun setCurrentThread(threadId: String?) { + updateEvents { + val hasChanged = threadId != currentThreadId + currentThreadId = threadId + currentRoomId?.let { roomId -> + if (hasChanged && threadId != null) { + it.clearMessagesForThread(roomId, threadId) + } + } + } + } + fun notificationStyleChanged() { updateEvents { val newSettings = vectorPreferences.useCompleteNotificationFormat() @@ -164,7 +181,7 @@ class NotificationDrawerManager @Inject constructor( private fun refreshNotificationDrawerBg() { Timber.v("refreshNotificationDrawerBg()") val eventsToRender = notificationState.updateQueuedEvents(this) { queuedEvents, renderedEvents -> - notifiableEventProcessor.process(queuedEvents.rawEvents(), currentRoomId, renderedEvents).also { + notifiableEventProcessor.process(queuedEvents.rawEvents(), currentRoomId, currentThreadId, renderedEvents).also { queuedEvents.clearAndAdd(it.onlyKeptEvents()) } } @@ -198,8 +215,8 @@ class NotificationDrawerManager @Inject constructor( notificationRenderer.render(session.myUserId, myUserDisplayName, myUserAvatarUrl, useCompleteNotificationFormat, eventsToRender) } - fun shouldIgnoreMessageEventInRoom(roomId: String?): Boolean { - return currentRoomId != null && roomId == currentRoomId + fun shouldIgnoreMessageEventInRoom(resolvedEvent: NotifiableMessageEvent): Boolean { + return resolvedEvent.shouldIgnoreMessageEventInRoom(currentRoomId, currentThreadId) } companion object { diff --git a/vector/src/main/java/im/vector/app/features/notifications/NotificationEventQueue.kt b/vector/src/main/java/im/vector/app/features/notifications/NotificationEventQueue.kt index f02424803a..8aff9c3bf2 100644 --- a/vector/src/main/java/im/vector/app/features/notifications/NotificationEventQueue.kt +++ b/vector/src/main/java/im/vector/app/features/notifications/NotificationEventQueue.kt @@ -122,15 +122,20 @@ data class NotificationEventQueue( } fun clearMemberShipNotificationForRoom(roomId: String) { - Timber.v("clearMemberShipOfRoom $roomId") + Timber.d("clearMemberShipOfRoom $roomId") queue.removeAll { it is InviteNotifiableEvent && it.roomId == roomId } } fun clearMessagesForRoom(roomId: String) { - Timber.v("clearMessageEventOfRoom $roomId") + Timber.d("clearMessageEventOfRoom $roomId") queue.removeAll { it is NotifiableMessageEvent && it.roomId == roomId } } + fun clearMessagesForThread(roomId: String, threadId: String) { + Timber.d("clearMessageEventOfThread $roomId, $threadId") + queue.removeAll { it is NotifiableMessageEvent && it.roomId == roomId && it.threadId == threadId } + } + fun rawEvents(): List = queue } diff --git a/vector/src/main/java/im/vector/app/features/notifications/NotificationUtils.kt b/vector/src/main/java/im/vector/app/features/notifications/NotificationUtils.kt index e789d390f5..e6af6be33b 100755 --- a/vector/src/main/java/im/vector/app/features/notifications/NotificationUtils.kt +++ b/vector/src/main/java/im/vector/app/features/notifications/NotificationUtils.kt @@ -60,6 +60,8 @@ import im.vector.app.features.displayname.getBestName import im.vector.app.features.home.HomeActivity import im.vector.app.features.home.room.detail.RoomDetailActivity import im.vector.app.features.home.room.detail.arguments.TimelineArgs +import im.vector.app.features.home.room.threads.ThreadsActivity +import im.vector.app.features.home.room.threads.arguments.ThreadTimelineArgs import im.vector.app.features.settings.VectorPreferences import im.vector.app.features.settings.troubleshoot.TestNotificationReceiver import im.vector.app.features.themes.ThemeUtils @@ -574,6 +576,7 @@ class NotificationUtils @Inject constructor( fun buildMessagesListNotification( messageStyle: NotificationCompat.MessagingStyle, roomInfo: RoomEventGroupInfo, + threadId: String?, largeIcon: Bitmap?, lastMessageTimestamp: Long, senderDisplayNameForReplyCompat: String?, @@ -581,7 +584,11 @@ class NotificationUtils @Inject constructor( ): Notification { val accentColor = ContextCompat.getColor(context, R.color.notification_accent_color) // Build the pending intent for when the notification is clicked - val openRoomIntent = buildOpenRoomIntent(roomInfo.roomId) + val openIntent = when { + threadId != null && vectorPreferences.areThreadMessagesEnabled() -> buildOpenThreadIntent(roomInfo, threadId) + else -> buildOpenRoomIntent(roomInfo.roomId) + } + val smallIcon = R.drawable.ic_notification val channelID = if (roomInfo.shouldBing) NOISY_NOTIFICATION_CHANNEL_ID else SILENT_NOTIFICATION_CHANNEL_ID @@ -650,7 +657,7 @@ class NotificationUtils @Inject constructor( // Quick reply if (!roomInfo.hasSmartReplyError) { - buildQuickReplyIntent(roomInfo.roomId, senderDisplayNameForReplyCompat)?.let { replyPendingIntent -> + buildQuickReplyIntent(roomInfo.roomId, threadId, senderDisplayNameForReplyCompat)?.let { replyPendingIntent -> val remoteInput = RemoteInput.Builder(NotificationBroadcastReceiver.KEY_TEXT_REPLY) .setLabel(stringProvider.getString(R.string.action_quick_reply)) .build() @@ -666,8 +673,8 @@ class NotificationUtils @Inject constructor( } } - if (openRoomIntent != null) { - setContentIntent(openRoomIntent) + if (openIntent != null) { + setContentIntent(openIntent) } if (largeIcon != null) { @@ -826,6 +833,45 @@ class NotificationUtils @Inject constructor( ) } + private fun buildOpenThreadIntent(roomInfo: RoomEventGroupInfo, threadId: String?): PendingIntent? { + val threadTimelineArgs = ThreadTimelineArgs( + startsThread = false, + roomId = roomInfo.roomId, + rootThreadEventId = threadId, + showKeyboard = false, + displayName = roomInfo.roomDisplayName, + avatarUrl = null, + roomEncryptionTrustLevel = null, + ) + val threadIntentTap = ThreadsActivity.newIntent( + context = context, + threadTimelineArgs = threadTimelineArgs, + threadListArgs = null, + firstStartMainActivity = true, + ) + threadIntentTap.action = actionIds.tapToView + // pending intent get reused by system, this will mess up the extra params, so put unique info to avoid that + threadIntentTap.data = createIgnoredUri("openThread?$threadId") + + val roomIntent = RoomDetailActivity.newIntent( + context = context, + timelineArgs = TimelineArgs( + roomId = roomInfo.roomId, + switchToParentSpace = true + ), + firstStartMainActivity = false + ) + // Recreate the back stack + return TaskStackBuilder.create(context) + .addNextIntentWithParentStack(HomeActivity.newIntent(context, firstStartMainActivity = false)) + .addNextIntentWithParentStack(roomIntent) + .addNextIntent(threadIntentTap) + .getPendingIntent( + clock.epochMillis().toInt(), + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntentCompat.FLAG_IMMUTABLE + ) + } + private fun buildOpenHomePendingIntentForSummary(): PendingIntent { val intent = HomeActivity.newIntent(context, firstStartMainActivity = false, clearNotification = true) intent.flags = Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP @@ -846,13 +892,17 @@ class NotificationUtils @Inject constructor( However, for Android devices running Marshmallow and below (API level 23 and below), it will be more appropriate to use an activity. Since you have to provide your own UI. */ - private fun buildQuickReplyIntent(roomId: String, senderName: String?): PendingIntent? { + private fun buildQuickReplyIntent(roomId: String, threadId: String?, senderName: String?): PendingIntent? { val intent: Intent if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { intent = Intent(context, NotificationBroadcastReceiver::class.java) intent.action = actionIds.smartReply intent.data = createIgnoredUri(roomId) intent.putExtra(NotificationBroadcastReceiver.KEY_ROOM_ID, roomId) + threadId?.let { + intent.putExtra(NotificationBroadcastReceiver.KEY_THREAD_ID, it) + } + return PendingIntent.getBroadcast( context, clock.epochMillis().toInt(), diff --git a/vector/src/main/java/im/vector/app/features/notifications/RoomGroupMessageCreator.kt b/vector/src/main/java/im/vector/app/features/notifications/RoomGroupMessageCreator.kt index 7fdfa3535e..767f427f39 100644 --- a/vector/src/main/java/im/vector/app/features/notifications/RoomGroupMessageCreator.kt +++ b/vector/src/main/java/im/vector/app/features/notifications/RoomGroupMessageCreator.kt @@ -33,14 +33,14 @@ class RoomGroupMessageCreator @Inject constructor( ) { fun createRoomMessage(events: List, roomId: String, userDisplayName: String, userAvatarUrl: String?): RoomNotification.Message { - val firstKnownRoomEvent = events[0] - val roomName = firstKnownRoomEvent.roomName ?: firstKnownRoomEvent.senderName ?: "" - val roomIsGroup = !firstKnownRoomEvent.roomIsDirect + val lastKnownRoomEvent = events.last() + val roomName = lastKnownRoomEvent.roomName ?: lastKnownRoomEvent.senderName ?: "" + val roomIsGroup = !lastKnownRoomEvent.roomIsDirect val style = NotificationCompat.MessagingStyle( Person.Builder() .setName(userDisplayName) .setIcon(bitmapLoader.getUserIcon(userAvatarUrl)) - .setKey(firstKnownRoomEvent.matrixID) + .setKey(lastKnownRoomEvent.matrixID) .build() ).also { it.conversationTitle = roomName.takeIf { roomIsGroup } @@ -75,6 +75,7 @@ class RoomGroupMessageCreator @Inject constructor( it.customSound = events.last().soundName it.isUpdated = events.last().isUpdated }, + threadId = lastKnownRoomEvent.threadId, largeIcon = largeBitmap, lastMessageTimestamp, userDisplayName, diff --git a/vector/src/main/java/im/vector/app/features/onboarding/OnboardingViewModel.kt b/vector/src/main/java/im/vector/app/features/onboarding/OnboardingViewModel.kt index 40f7baa892..924529181b 100644 --- a/vector/src/main/java/im/vector/app/features/onboarding/OnboardingViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/onboarding/OnboardingViewModel.kt @@ -126,7 +126,7 @@ class OnboardingViewModel @AssistedInject constructor( } } - private fun checkQrCodeLoginCapability(homeServerUrl: String) { + private fun checkQrCodeLoginCapability() { if (!vectorFeatures.isQrCodeLoginEnabled()) { setState { copy( @@ -141,16 +141,10 @@ class OnboardingViewModel @AssistedInject constructor( ) } } else { - viewModelScope.launch { - // check if selected server supports MSC3882 first - homeServerConnectionConfigFactory.create(homeServerUrl)?.let { - val canLoginWithQrCode = authenticationService.isQrLoginSupported(it) - setState { - copy( - canLoginWithQrCode = canLoginWithQrCode - ) - } - } + setState { + copy( + canLoginWithQrCode = selectedHomeserver.isLoginWithQrSupported + ) } } } @@ -726,8 +720,10 @@ class OnboardingViewModel @AssistedInject constructor( // This is invalid _viewEvents.post(OnboardingViewEvents.Failure(Throwable("Unable to create a HomeServerConnectionConfig"))) } else { - startAuthenticationFlow(action, homeServerConnectionConfig, serverTypeOverride, postAction) - checkQrCodeLoginCapability(homeServerConnectionConfig.homeServerUri.toString()) + startAuthenticationFlow(action, homeServerConnectionConfig, serverTypeOverride, suspend { + checkQrCodeLoginCapability() + postAction() + }) } } diff --git a/vector/src/main/java/im/vector/app/features/onboarding/OnboardingViewState.kt b/vector/src/main/java/im/vector/app/features/onboarding/OnboardingViewState.kt index 6e7d58338e..ea0d940952 100644 --- a/vector/src/main/java/im/vector/app/features/onboarding/OnboardingViewState.kt +++ b/vector/src/main/java/im/vector/app/features/onboarding/OnboardingViewState.kt @@ -76,6 +76,7 @@ data class SelectedHomeserverState( val preferredLoginMode: LoginMode = LoginMode.Unknown, val supportedLoginTypes: List = emptyList(), val isLogoutDevicesSupported: Boolean = false, + val isLoginWithQrSupported: Boolean = false, ) : Parcelable @Parcelize diff --git a/vector/src/main/java/im/vector/app/features/onboarding/StartAuthenticationFlowUseCase.kt b/vector/src/main/java/im/vector/app/features/onboarding/StartAuthenticationFlowUseCase.kt index db21a53854..9b8f0a1cc4 100644 --- a/vector/src/main/java/im/vector/app/features/onboarding/StartAuthenticationFlowUseCase.kt +++ b/vector/src/main/java/im/vector/app/features/onboarding/StartAuthenticationFlowUseCase.kt @@ -47,7 +47,8 @@ class StartAuthenticationFlowUseCase @Inject constructor( upstreamUrl = authFlow.homeServerUrl, preferredLoginMode = preferredLoginMode, supportedLoginTypes = authFlow.supportedLoginTypes, - isLogoutDevicesSupported = authFlow.isLogoutDevicesSupported + isLogoutDevicesSupported = authFlow.isLogoutDevicesSupported, + isLoginWithQrSupported = authFlow.isLoginWithQrSupported, ) private fun LoginFlowResult.findPreferredLoginMode() = when { diff --git a/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthSplashCarouselFragment.kt b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthSplashCarouselFragment.kt index f41bb4f547..3d23d2b4c3 100644 --- a/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthSplashCarouselFragment.kt +++ b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthSplashCarouselFragment.kt @@ -94,8 +94,8 @@ class FtueAuthSplashCarouselFragment : if (buildMeta.isDebug || vectorPreferences.developerMode()) { views.loginSplashVersion.isVisible = true @SuppressLint("SetTextI18n") - views.loginSplashVersion.text = "Version : ${buildMeta.versionName}#${buildMeta.buildNumber}\n" + - "Branch: ${buildMeta.gitBranchName}" + views.loginSplashVersion.text = "Version : ${buildMeta.versionName}\n" + + "Branch: ${buildMeta.gitBranchName} ${buildMeta.gitRevision}" views.loginSplashVersion.debouncedClicks { navigator.openDebug(requireContext()) } } views.splashCarousel.registerAutomaticUntilInteractionTransitions() diff --git a/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthSplashFragment.kt b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthSplashFragment.kt index 275a06721c..a5f7254be1 100644 --- a/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthSplashFragment.kt +++ b/vector/src/main/java/im/vector/app/features/onboarding/ftueauth/FtueAuthSplashFragment.kt @@ -67,8 +67,7 @@ class FtueAuthSplashFragment : views.loginSplashVersion.isVisible = true @SuppressLint("SetTextI18n") views.loginSplashVersion.text = "Version : ${buildMeta.versionName}\n" + - "Branch: ${buildMeta.gitBranchName}\n" + - "Build: ${buildMeta.buildNumber}" + "Branch: ${buildMeta.gitBranchName} ${buildMeta.gitRevision}" views.loginSplashVersion.debouncedClicks { navigator.openDebug(requireContext()) } } } diff --git a/vector/src/main/java/im/vector/app/features/popup/PopupAlertManager.kt b/vector/src/main/java/im/vector/app/features/popup/PopupAlertManager.kt index b1327f0caf..e0310b340e 100644 --- a/vector/src/main/java/im/vector/app/features/popup/PopupAlertManager.kt +++ b/vector/src/main/java/im/vector/app/features/popup/PopupAlertManager.kt @@ -50,6 +50,12 @@ class PopupAlertManager @Inject constructor( companion object { const val INCOMING_CALL_PRIORITY = Int.MAX_VALUE + const val INCOMING_VERIFICATION_REQUEST_PRIORITY = 1 + const val DEFAULT_PRIORITY = 0 + const val REVIEW_LOGIN_UID = "review_login" + const val UPGRADE_SECURITY_UID = "upgrade_security" + const val VERIFY_SESSION_UID = "verify_session" + const val ENABLE_PUSH_UID = "enable_push" } private var weakCurrentActivity: WeakReference? = null @@ -145,7 +151,7 @@ class PopupAlertManager @Inject constructor( private fun displayNextIfPossible() { val currentActivity = weakCurrentActivity?.get() - if (Alerter.isShowing || currentActivity == null || currentActivity.isDestroyed) { + if (currentActivity == null || currentActivity.isDestroyed) { // will retry later return } diff --git a/vector/src/main/java/im/vector/app/features/popup/VectorAlert.kt b/vector/src/main/java/im/vector/app/features/popup/VectorAlert.kt index ffdba8e04d..1597d927d8 100644 --- a/vector/src/main/java/im/vector/app/features/popup/VectorAlert.kt +++ b/vector/src/main/java/im/vector/app/features/popup/VectorAlert.kt @@ -98,7 +98,7 @@ open class DefaultVectorAlert( override val dismissOnClick: Boolean = true - override val priority: Int = 0 + override val priority: Int = PopupAlertManager.DEFAULT_PRIORITY override val isLight: Boolean = false diff --git a/vector/src/main/java/im/vector/app/features/popup/VerificationVectorAlert.kt b/vector/src/main/java/im/vector/app/features/popup/VerificationVectorAlert.kt index 14d34b659e..da95d29cea 100644 --- a/vector/src/main/java/im/vector/app/features/popup/VerificationVectorAlert.kt +++ b/vector/src/main/java/im/vector/app/features/popup/VerificationVectorAlert.kt @@ -30,6 +30,7 @@ class VerificationVectorAlert( title: String, override val description: String, @DrawableRes override val iconId: Int?, + override val priority: Int = PopupAlertManager.DEFAULT_PRIORITY, /** * Alert are displayed by default, but let this lambda return false to prevent displaying. */ diff --git a/vector/src/main/java/im/vector/app/features/rageshake/BugReporter.kt b/vector/src/main/java/im/vector/app/features/rageshake/BugReporter.kt index 7073afa768..6602b2b669 100755 --- a/vector/src/main/java/im/vector/app/features/rageshake/BugReporter.kt +++ b/vector/src/main/java/im/vector/app/features/rageshake/BugReporter.kt @@ -292,7 +292,7 @@ class BugReporter @Inject constructor( .addFormDataPart("user_id", userId) .addFormDataPart("can_contact", canContact.toString()) .addFormDataPart("device_id", deviceId) - .addFormDataPart("version", versionProvider.getVersion(longFormat = true, useBuildNumber = false)) + .addFormDataPart("version", versionProvider.getVersion(longFormat = true)) .addFormDataPart("branch_name", buildMeta.gitBranchName) .addFormDataPart("matrix_sdk_version", Matrix.getSdkVersion()) .addFormDataPart("olm_version", olmVersion) @@ -314,11 +314,6 @@ class BugReporter @Inject constructor( } } - val buildNumber = buildMeta.buildNumber - if (buildNumber.isNotEmpty() && buildNumber != "0") { - builder.addFormDataPart("build_number", buildNumber) - } - // add the gzipped files for (file in gzippedFiles) { builder.addFormDataPart("compressed-log", file.name, file.asRequestBody(MimeTypes.OctetStream.toMediaTypeOrNull())) diff --git a/vector/src/main/java/im/vector/app/features/rageshake/VectorUncaughtExceptionHandler.kt b/vector/src/main/java/im/vector/app/features/rageshake/VectorUncaughtExceptionHandler.kt index 23b4fe04a8..d3174631e8 100644 --- a/vector/src/main/java/im/vector/app/features/rageshake/VectorUncaughtExceptionHandler.kt +++ b/vector/src/main/java/im/vector/app/features/rageshake/VectorUncaughtExceptionHandler.kt @@ -70,7 +70,7 @@ class VectorUncaughtExceptionHandler @Inject constructor( val appName = "Element" // TODO Matrix.getApplicationName() b.append(appName + " Build : " + versionCodeProvider.getVersionCode() + "\n") - b.append("$appName Version : ${versionProvider.getVersion(longFormat = true, useBuildNumber = true)}\n") + b.append("$appName Version : ${versionProvider.getVersion(longFormat = true)}\n") b.append("SDK Version : ${Matrix.getSdkVersion()}\n") b.append("Phone : " + Build.MODEL.trim() + " (" + Build.VERSION.INCREMENTAL + " " + Build.VERSION.RELEASE + " " + Build.VERSION.CODENAME + ")\n") diff --git a/vector/src/main/java/im/vector/app/features/room/VectorRoomDisplayNameFallbackProvider.kt b/vector/src/main/java/im/vector/app/features/room/VectorRoomDisplayNameFallbackProvider.kt index 22f2ebc9cb..b7a5f6cb31 100644 --- a/vector/src/main/java/im/vector/app/features/room/VectorRoomDisplayNameFallbackProvider.kt +++ b/vector/src/main/java/im/vector/app/features/room/VectorRoomDisplayNameFallbackProvider.kt @@ -18,7 +18,7 @@ package im.vector.app.features.room import android.content.Context import im.vector.app.R -import org.matrix.android.sdk.api.RoomDisplayNameFallbackProvider +import org.matrix.android.sdk.api.provider.RoomDisplayNameFallbackProvider import javax.inject.Inject class VectorRoomDisplayNameFallbackProvider @Inject constructor( diff --git a/vector/src/main/java/im/vector/app/features/roomprofile/uploads/RoomUploadsViewModel.kt b/vector/src/main/java/im/vector/app/features/roomprofile/uploads/RoomUploadsViewModel.kt index da516531e1..56e7ae62eb 100644 --- a/vector/src/main/java/im/vector/app/features/roomprofile/uploads/RoomUploadsViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/roomprofile/uploads/RoomUploadsViewModel.kt @@ -26,11 +26,13 @@ import dagger.assisted.AssistedInject import im.vector.app.core.di.MavericksAssistedViewModelFactory import im.vector.app.core.di.hiltMavericksViewModelFactory import im.vector.app.core.platform.VectorViewModel +import im.vector.app.features.voicebroadcast.isVoiceBroadcast import kotlinx.coroutines.launch import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.contentscanner.ScanState import org.matrix.android.sdk.api.session.getRoom import org.matrix.android.sdk.api.session.room.model.message.MessageType +import org.matrix.android.sdk.api.session.room.model.message.asMessageAudioEvent import org.matrix.android.sdk.flow.flow import org.matrix.android.sdk.flow.unwrap @@ -79,6 +81,8 @@ class RoomUploadsViewModel @AssistedInject constructor( token = result.nextToken val groupedUploadEvents = result.uploadEvents + // Remove voice broadcast chunks from the attachments + .filterNot { it.root.asMessageAudioEvent().isVoiceBroadcast() } .groupBy { it.contentWithAttachmentContent.msgType == MessageType.MSGTYPE_IMAGE || it.contentWithAttachmentContent.msgType == MessageType.MSGTYPE_VIDEO diff --git a/vector/src/main/java/im/vector/app/features/settings/VectorPreferences.kt b/vector/src/main/java/im/vector/app/features/settings/VectorPreferences.kt index a259430541..c848e304aa 100755 --- a/vector/src/main/java/im/vector/app/features/settings/VectorPreferences.kt +++ b/vector/src/main/java/im/vector/app/features/settings/VectorPreferences.kt @@ -110,6 +110,7 @@ class VectorPreferences @Inject constructor( const val SETTINGS_SHOW_URL_PREVIEW_KEY = "SETTINGS_SHOW_URL_PREVIEW_KEY" private const val SETTINGS_SEND_TYPING_NOTIF_KEY = "SETTINGS_SEND_TYPING_NOTIF_KEY" private const val SETTINGS_ENABLE_MARKDOWN_KEY = "SETTINGS_ENABLE_MARKDOWN_KEY" + private const val SETTINGS_ENABLE_RICH_TEXT_FORMATTING_KEY = "SETTINGS_ENABLE_RICH_TEXT_FORMATTING_KEY" private const val SETTINGS_ALWAYS_SHOW_TIMESTAMPS_KEY = "SETTINGS_ALWAYS_SHOW_TIMESTAMPS_KEY" private const val SETTINGS_12_24_TIMESTAMPS_KEY = "SETTINGS_12_24_TIMESTAMPS_KEY" private const val SETTINGS_SHOW_READ_RECEIPTS_KEY = "SETTINGS_SHOW_READ_RECEIPTS_KEY" @@ -124,6 +125,7 @@ class VectorPreferences @Inject constructor( private const val SETTINGS_LABS_ENABLE_LATEX_MATHS = "SETTINGS_LABS_ENABLE_LATEX_MATHS" const val SETTINGS_PRESENCE_USER_ALWAYS_APPEARS_OFFLINE = "SETTINGS_PRESENCE_USER_ALWAYS_APPEARS_OFFLINE" const val SETTINGS_AUTOPLAY_ANIMATED_IMAGES = "SETTINGS_AUTOPLAY_ANIMATED_IMAGES" + private const val SETTINGS_ENABLE_DIRECT_SHARE = "SETTINGS_ENABLE_DIRECT_SHARE" // Room directory private const val SETTINGS_ROOM_DIRECTORY_SHOW_ALL_PUBLIC_ROOMS = "SETTINGS_ROOM_DIRECTORY_SHOW_ALL_PUBLIC_ROOMS" @@ -209,6 +211,9 @@ class VectorPreferences @Inject constructor( private const val SETTINGS_SECURITY_USE_GRACE_PERIOD_FLAG = "SETTINGS_SECURITY_USE_GRACE_PERIOD_FLAG" const val SETTINGS_SECURITY_USE_COMPLETE_NOTIFICATIONS_FLAG = "SETTINGS_SECURITY_USE_COMPLETE_NOTIFICATIONS_FLAG" + // New Session Manager + const val SETTINGS_SESSION_MANAGER_SHOW_IP_ADDRESS = "SETTINGS_SESSION_MANAGER_SHOW_IP_ADDRESS" + // other const val SETTINGS_MEDIA_SAVING_PERIOD_KEY = "SETTINGS_MEDIA_SAVING_PERIOD_KEY" private const val SETTINGS_MEDIA_SAVING_PERIOD_SELECTED_KEY = "SETTINGS_MEDIA_SAVING_PERIOD_SELECTED_KEY" @@ -229,8 +234,6 @@ class VectorPreferences @Inject constructor( private const val MEDIA_SAVING_1_MONTH = 2 private const val MEDIA_SAVING_FOREVER = 3 - private const val SETTINGS_UNKNOWN_DEVICE_DISMISSED_LIST = "SETTINGS_UNKNWON_DEVICE_DISMISSED_LIST" - private const val TAKE_PHOTO_VIDEO_MODE = "TAKE_PHOTO_VIDEO_MODE" private const val SETTINGS_LABS_ENABLE_LIVE_LOCATION = "SETTINGS_LABS_ENABLE_LIVE_LOCATION" @@ -242,11 +245,15 @@ class VectorPreferences @Inject constructor( // This key will be used to identify clients with the new thread support enabled m.thread const val SETTINGS_LABS_ENABLE_THREAD_MESSAGES = "SETTINGS_LABS_ENABLE_THREAD_MESSAGES_FINAL" + const val SETTINGS_LABS_THREAD_MESSAGES_CHANGED_BY_USER = "SETTINGS_LABS_THREAD_MESSAGES_CHANGED_BY_USER" const val SETTINGS_THREAD_MESSAGES_SYNCED = "SETTINGS_THREAD_MESSAGES_SYNCED" // This key will be used to enable user for displaying live user info or not. const val SETTINGS_TIMELINE_SHOW_LIVE_SENDER_INFO = "SETTINGS_TIMELINE_SHOW_LIVE_SENDER_INFO" + const val SETTINGS_UNVERIFIED_SESSIONS_ALERT_LAST_SHOWN_MILLIS = "SETTINGS_UNVERIFIED_SESSIONS_ALERT_LAST_SHOWN_MILLIS_" + const val SETTINGS_NEW_LOGIN_ALERT_SHOWN_FOR_DEVICE = "SETTINGS_NEW_LOGIN_ALERT_SHOWN_FOR_DEVICE_" + // Possible values for TAKE_PHOTO_VIDEO_MODE const val TAKE_PHOTO_VIDEO_MODE_ALWAYS_ASK = 0 const val TAKE_PHOTO_VIDEO_MODE_PHOTO = 1 @@ -522,18 +529,6 @@ class VectorPreferences @Inject constructor( return defaultPrefs.getBoolean(SETTINGS_PLAY_SHUTTER_SOUND_KEY, true) } - fun storeUnknownDeviceDismissedList(deviceIds: List) { - defaultPrefs.edit(true) { - putStringSet(SETTINGS_UNKNOWN_DEVICE_DISMISSED_LIST, deviceIds.toSet()) - } - } - - fun getUnknownDeviceDismissedList(): List { - return tryOrNull { - defaultPrefs.getStringSet(SETTINGS_UNKNOWN_DEVICE_DISMISSED_LIST, null)?.toList() - }.orEmpty() - } - /** * Update the notification ringtone. * @@ -766,6 +761,24 @@ class VectorPreferences @Inject constructor( } } + /** + * Tells if text formatting is enabled within the rich text editor. + * + * @return true if the text formatting is enabled + */ + fun isTextFormattingEnabled(): Boolean = + defaultPrefs.getBoolean(SETTINGS_ENABLE_RICH_TEXT_FORMATTING_KEY, true) + + /** + * Update whether text formatting is enabled within the rich text editor. + * + * @param isEnabled true to enable the text formatting + */ + fun setTextFormattingEnabled(isEnabled: Boolean) = + defaultPrefs.edit { + putBoolean(SETTINGS_ENABLE_RICH_TEXT_FORMATTING_KEY, isEnabled) + } + /** * Tells if a confirmation dialog should be displayed before staring a call. */ @@ -1011,6 +1024,10 @@ class VectorPreferences @Inject constructor( return defaultPrefs.getBoolean(SETTINGS_ENABLE_CHAT_EFFECTS, true) } + fun directShareEnabled(): Boolean { + return defaultPrefs.getBoolean(SETTINGS_ENABLE_DIRECT_SHARE, true) + } + /** * Return true if Pin code is disabled, or if user set the settings to see full notification content. */ @@ -1121,6 +1138,24 @@ class VectorPreferences @Inject constructor( .apply() } + /** + * Indicates whether or not user changed threads flag manually. We need this to not force flag to be enabled on app start. + * Should be removed when Threads flag will be removed + */ + fun wasThreadFlagChangedManually(): Boolean { + return defaultPrefs.getBoolean(SETTINGS_LABS_THREAD_MESSAGES_CHANGED_BY_USER, false) + } + + /** + * Sets the flag to indicate that user changed threads flag (e.g. disabled them). + */ + fun setThreadFlagChangedManually() { + defaultPrefs + .edit() + .putBoolean(SETTINGS_LABS_THREAD_MESSAGES_CHANGED_BY_USER, true) + .apply() + } + /** * Indicates whether or not the user will be notified about the new thread support. * We should notify the user only if he had old thread support enabled. @@ -1217,4 +1252,34 @@ class VectorPreferences @Inject constructor( return vectorFeatures.isVoiceBroadcastEnabled() && defaultPrefs.getBoolean(SETTINGS_LABS_VOICE_BROADCAST_KEY, getDefault(R.bool.settings_labs_enable_voice_broadcast_default)) } + + fun showIpAddressInSessionManagerScreens(): Boolean { + return defaultPrefs.getBoolean(SETTINGS_SESSION_MANAGER_SHOW_IP_ADDRESS, getDefault(R.bool.settings_session_manager_show_ip_address)) + } + + fun setIpAddressVisibilityInDeviceManagerScreens(isVisible: Boolean) { + defaultPrefs.edit { + putBoolean(SETTINGS_SESSION_MANAGER_SHOW_IP_ADDRESS, isVisible) + } + } + + fun getUnverifiedSessionsAlertLastShownMillis(deviceId: String): Long { + return defaultPrefs.getLong(SETTINGS_UNVERIFIED_SESSIONS_ALERT_LAST_SHOWN_MILLIS + deviceId, 0) + } + + fun setUnverifiedSessionsAlertLastShownMillis(deviceId: String, lastShownMillis: Long) { + defaultPrefs.edit { + putLong(SETTINGS_UNVERIFIED_SESSIONS_ALERT_LAST_SHOWN_MILLIS + deviceId, lastShownMillis) + } + } + + fun isNewLoginAlertShownForDevice(deviceId: String): Boolean { + return defaultPrefs.getBoolean(SETTINGS_NEW_LOGIN_ALERT_SHOWN_FOR_DEVICE + deviceId, false) + } + + fun setNewLoginAlertShownForDevice(deviceId: String) { + defaultPrefs.edit { + putBoolean(SETTINGS_NEW_LOGIN_ALERT_SHOWN_FOR_DEVICE + deviceId, true) + } + } } diff --git a/vector/src/main/java/im/vector/app/features/settings/VectorSettingsAdvancedSettingsFragment.kt b/vector/src/main/java/im/vector/app/features/settings/VectorSettingsAdvancedSettingsFragment.kt index 9c08d446f4..b6fa997f41 100644 --- a/vector/src/main/java/im/vector/app/features/settings/VectorSettingsAdvancedSettingsFragment.kt +++ b/vector/src/main/java/im/vector/app/features/settings/VectorSettingsAdvancedSettingsFragment.kt @@ -22,10 +22,13 @@ import androidx.preference.SeekBarPreference import dagger.hilt.android.AndroidEntryPoint import im.vector.app.R import im.vector.app.core.platform.VectorBaseActivity +import im.vector.app.core.preference.VectorPreference import im.vector.app.core.preference.VectorPreferenceCategory import im.vector.app.core.preference.VectorSwitchPreference import im.vector.app.features.analytics.plan.MobileScreen +import im.vector.app.features.home.NightlyProxy import im.vector.app.features.rageshake.RageShake +import javax.inject.Inject @AndroidEntryPoint class VectorSettingsAdvancedSettingsFragment : @@ -34,6 +37,8 @@ class VectorSettingsAdvancedSettingsFragment : override var titleRes = R.string.settings_advanced_settings override val preferenceXmlRes = R.xml.vector_settings_advanced_settings + @Inject lateinit var nightlyProxy: NightlyProxy + private var rageshake: RageShake? = null override fun onCreate(savedInstanceState: Bundle?) { @@ -57,6 +62,11 @@ class VectorSettingsAdvancedSettingsFragment : } override fun bindPref() { + setupRageShakeSection() + setupNightlySection() + } + + private fun setupRageShakeSection() { val isRageShakeAvailable = RageShake.isAvailable(requireContext()) if (isRageShakeAvailable) { @@ -86,4 +96,12 @@ class VectorSettingsAdvancedSettingsFragment : findPreference("SETTINGS_RAGE_SHAKE_CATEGORY_KEY")!!.isVisible = false } } + + private fun setupNightlySection() { + findPreference("SETTINGS_NIGHTLY_BUILD_PREFERENCE_KEY")?.isVisible = nightlyProxy.isNightlyBuild() + findPreference("SETTINGS_NIGHTLY_BUILD_UPDATE_PREFERENCE_KEY")?.setOnPreferenceClickListener { + nightlyProxy.updateApplication() + true + } + } } diff --git a/vector/src/main/java/im/vector/app/features/settings/VectorSettingsBaseFragment.kt b/vector/src/main/java/im/vector/app/features/settings/VectorSettingsBaseFragment.kt index 176909b48d..38ba949a49 100644 --- a/vector/src/main/java/im/vector/app/features/settings/VectorSettingsBaseFragment.kt +++ b/vector/src/main/java/im/vector/app/features/settings/VectorSettingsBaseFragment.kt @@ -28,6 +28,8 @@ import im.vector.app.R import im.vector.app.core.error.ErrorFormatter import im.vector.app.core.extensions.singletonEntryPoint import im.vector.app.core.platform.VectorBaseActivity +import im.vector.app.core.platform.VectorViewEvents +import im.vector.app.core.platform.VectorViewModel import im.vector.app.core.utils.toast import im.vector.app.features.analytics.AnalyticsTracker import im.vector.app.features.analytics.plan.MobileScreen @@ -60,6 +62,19 @@ abstract class VectorSettingsBaseFragment : PreferenceFragmentCompat(), Maverick protected lateinit var session: Session protected lateinit var errorFormatter: ErrorFormatter + /* ========================================================================================== + * ViewEvents + * ========================================================================================== */ + + protected fun VectorViewModel<*, *, T>.observeViewEvents(observer: (T) -> Unit) { + viewEvents + .stream() + .onEach { + observer(it) + } + .launchIn(viewLifecycleOwner.lifecycleScope) + } + /* ========================================================================================== * Views * ========================================================================================== */ @@ -148,7 +163,7 @@ abstract class VectorSettingsBaseFragment : PreferenceFragmentCompat(), Maverick } } - protected fun displayErrorDialog(throwable: Throwable) { + protected fun displayErrorDialog(throwable: Throwable?) { displayErrorDialog(errorFormatter.toHumanReadable(throwable)) } diff --git a/vector/src/main/java/im/vector/app/features/settings/VectorSettingsHelpAboutFragment.kt b/vector/src/main/java/im/vector/app/features/settings/VectorSettingsHelpAboutFragment.kt index 8c7afaabc0..f1a9b724e2 100644 --- a/vector/src/main/java/im/vector/app/features/settings/VectorSettingsHelpAboutFragment.kt +++ b/vector/src/main/java/im/vector/app/features/settings/VectorSettingsHelpAboutFragment.kt @@ -69,10 +69,12 @@ class VectorSettingsHelpAboutFragment : // application version findPreference(VectorPreferences.SETTINGS_VERSION_PREFERENCE_KEY)!!.let { it.summary = buildString { - append(versionProvider.getVersion(longFormat = false, useBuildNumber = true)) + append(versionProvider.getVersion(longFormat = false)) if (buildMeta.isDebug) { append(" ") append(buildMeta.gitBranchName) + append(" ") + append(buildMeta.gitRevision) } } diff --git a/vector/src/main/java/im/vector/app/features/settings/VectorSettingsSecurityPrivacyFragment.kt b/vector/src/main/java/im/vector/app/features/settings/VectorSettingsSecurityPrivacyFragment.kt index c0a3925027..3be7943053 100644 --- a/vector/src/main/java/im/vector/app/features/settings/VectorSettingsSecurityPrivacyFragment.kt +++ b/vector/src/main/java/im/vector/app/features/settings/VectorSettingsSecurityPrivacyFragment.kt @@ -35,7 +35,6 @@ import androidx.recyclerview.widget.RecyclerView import com.airbnb.mvrx.fragmentViewModel import com.google.android.material.dialog.MaterialAlertDialogBuilder import dagger.hilt.android.AndroidEntryPoint -import im.vector.app.BuildConfig import im.vector.app.R import im.vector.app.core.di.ActiveSessionHolder import im.vector.app.core.dialogs.ExportKeysDialog @@ -52,6 +51,7 @@ import im.vector.app.core.utils.copyToClipboard import im.vector.app.core.utils.openFileSelection import im.vector.app.core.utils.toast import im.vector.app.databinding.DialogImportE2eKeysBinding +import im.vector.app.features.VectorFeatures import im.vector.app.features.analytics.AnalyticsConfig import im.vector.app.features.analytics.plan.MobileScreen import im.vector.app.features.analytics.ui.consent.AnalyticsConsentViewActions @@ -84,6 +84,7 @@ import javax.inject.Inject class VectorSettingsSecurityPrivacyFragment : VectorSettingsBaseFragment() { + @Inject lateinit var vectorFeatures: VectorFeatures @Inject lateinit var activeSessionHolder: ActiveSessionHolder @Inject lateinit var pinCodeStore: PinCodeStore @Inject lateinit var keysExporter: KeysExporter @@ -211,8 +212,7 @@ class VectorSettingsSecurityPrivacyFragment : private fun refresh4SSection(info: SecretsSynchronisationInfo) { // it's a lot of if / else if / else // But it's not yet clear how to manage all cases - val isKeyBackupSupported: Boolean = BuildConfig.IS_KEY_BACKUP_SUPPORTED - if (!isKeyBackupSupported || !info.isCrossSigningEnabled) { + if (!vectorFeatures.tchapIsKeyBackupEnabled() || !info.isCrossSigningEnabled) { // Key backup is not supported or there is not cross signing, so we can remove the section secureBackupCategory.isVisible = false } else { @@ -352,7 +352,7 @@ class VectorSettingsSecurityPrivacyFragment : // Todo this should be refactored and use same state as 4S section private fun refreshXSigningStatus() { - if (BuildConfig.ENABLE_CROSS_SIGNING) { + if (vectorFeatures.tchapIsCrossSigningEnabled()) { val crossSigningKeys = session.cryptoService().crossSigningService().getMyCrossSigningKeys() val xSigningIsEnableInAccount = crossSigningKeys != null val xSigningKeysAreTrusted = session.cryptoService().crossSigningService().checkUserTrust(session.myUserId).isVerified() diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/DeviceItem.kt b/vector/src/main/java/im/vector/app/features/settings/devices/DeviceItem.kt index 300d761e44..9de903a3c6 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/DeviceItem.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/DeviceItem.kt @@ -86,12 +86,12 @@ abstract class DeviceItem : VectorEpoxyModel(R.layout.item_de trusted ) - holder.trustIcon.render(shield) + holder.trustIcon.renderDeviceShield(shield) // Tchap: Show the shield only in this view on Tchap holder.trustIcon.visibility = View.VISIBLE } else { - holder.trustIcon.render(null) + holder.trustIcon.renderDeviceShield(null) } val detailedModeLabels = listOf( diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/DevicesViewModel.kt b/vector/src/main/java/im/vector/app/features/settings/devices/DevicesViewModel.kt index 0638b78586..bde3a66a44 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/DevicesViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/DevicesViewModel.kt @@ -88,7 +88,7 @@ data class DevicesViewState( data class DeviceFullInfo( val deviceInfo: DeviceInfo, val cryptoDeviceInfo: CryptoDeviceInfo?, - val trustLevelForShield: RoomEncryptionTrustLevel, + val trustLevelForShield: RoomEncryptionTrustLevel?, val isInactive: Boolean, ) diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/DeviceFullInfo.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/DeviceFullInfo.kt index 4864c41394..186a6ebe69 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/DeviceFullInfo.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/DeviceFullInfo.kt @@ -25,7 +25,7 @@ import org.matrix.android.sdk.api.session.crypto.model.RoomEncryptionTrustLevel data class DeviceFullInfo( val deviceInfo: DeviceInfo, val cryptoDeviceInfo: CryptoDeviceInfo?, - val roomEncryptionTrustLevel: RoomEncryptionTrustLevel, + val roomEncryptionTrustLevel: RoomEncryptionTrustLevel?, val isInactive: Boolean, val isCurrentDevice: Boolean, val deviceExtendedInfo: DeviceExtendedInfo, diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/DevicesAction.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/DevicesAction.kt index c7437db44c..6f002359c8 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/DevicesAction.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/DevicesAction.kt @@ -20,6 +20,14 @@ import im.vector.app.core.platform.VectorViewModelAction import org.matrix.android.sdk.api.session.crypto.model.CryptoDeviceInfo sealed class DevicesAction : VectorViewModelAction { + // ReAuth + object SsoAuthDone : DevicesAction() + data class PasswordAuthDone(val password: String) : DevicesAction() + object ReAuthCancelled : DevicesAction() + + // Others object VerifyCurrentSession : DevicesAction() data class MarkAsManuallyVerified(val cryptoDeviceInfo: CryptoDeviceInfo) : DevicesAction() + object MultiSignoutOtherSessions : DevicesAction() + object ToggleIpAddressVisibility : DevicesAction() } diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/DevicesViewEvent.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/DevicesViewEvent.kt index c78c20f792..9f5257693e 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/DevicesViewEvent.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/DevicesViewEvent.kt @@ -19,15 +19,17 @@ package im.vector.app.features.settings.devices.v2 import im.vector.app.core.platform.VectorViewEvents import org.matrix.android.sdk.api.auth.registration.RegistrationFlowResponse import org.matrix.android.sdk.api.session.crypto.model.CryptoDeviceInfo -import org.matrix.android.sdk.api.session.crypto.model.DeviceInfo sealed class DevicesViewEvent : VectorViewEvents { - data class Loading(val message: CharSequence? = null) : DevicesViewEvent() - data class Failure(val throwable: Throwable) : DevicesViewEvent() - data class RequestReAuth(val registrationFlowResponse: RegistrationFlowResponse, val lastErrorCode: String?) : DevicesViewEvent() - data class PromptRenameDevice(val deviceInfo: DeviceInfo) : DevicesViewEvent() + data class RequestReAuth( + val registrationFlowResponse: RegistrationFlowResponse, + val lastErrorCode: String? + ) : DevicesViewEvent() + data class ShowVerifyDevice(val userId: String, val transactionId: String?) : DevicesViewEvent() object SelfVerification : DevicesViewEvent() data class ShowManuallyVerify(val cryptoDeviceInfo: CryptoDeviceInfo) : DevicesViewEvent() object PromptResetSecrets : DevicesViewEvent() + object SignoutSuccess : DevicesViewEvent() + data class SignoutError(val error: Throwable) : DevicesViewEvent() } diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/DevicesViewModel.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/DevicesViewModel.kt index a5405756eb..232fcd50f7 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/DevicesViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/DevicesViewModel.kt @@ -16,6 +16,7 @@ package im.vector.app.features.settings.devices.v2 +import android.content.SharedPreferences import com.airbnb.mvrx.MavericksViewModelFactory import com.airbnb.mvrx.Success import dagger.assisted.Assisted @@ -24,13 +25,19 @@ import dagger.assisted.AssistedInject import im.vector.app.core.di.ActiveSessionHolder import im.vector.app.core.di.MavericksAssistedViewModelFactory import im.vector.app.core.di.hiltMavericksViewModelFactory +import im.vector.app.features.auth.PendingAuthHandler +import im.vector.app.features.settings.VectorPreferences import im.vector.app.features.settings.devices.v2.filter.DeviceManagerFilterType +import im.vector.app.features.settings.devices.v2.signout.SignoutSessionsReAuthNeeded +import im.vector.app.features.settings.devices.v2.signout.SignoutSessionsUseCase import im.vector.app.features.settings.devices.v2.verification.CheckIfCurrentSessionCanBeVerifiedUseCase import im.vector.app.features.settings.devices.v2.verification.GetCurrentSessionCrossSigningInfoUseCase import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import org.matrix.android.sdk.api.extensions.orFalse +import org.matrix.android.sdk.api.session.uia.DefaultBaseAuth +import timber.log.Timber class DevicesViewModel @AssistedInject constructor( @Assisted initialState: DevicesViewState, @@ -39,8 +46,15 @@ class DevicesViewModel @AssistedInject constructor( private val getDeviceFullInfoListUseCase: GetDeviceFullInfoListUseCase, private val refreshDevicesOnCryptoDevicesChangeUseCase: RefreshDevicesOnCryptoDevicesChangeUseCase, private val checkIfCurrentSessionCanBeVerifiedUseCase: CheckIfCurrentSessionCanBeVerifiedUseCase, + private val signoutSessionsUseCase: SignoutSessionsUseCase, + private val pendingAuthHandler: PendingAuthHandler, refreshDevicesUseCase: RefreshDevicesUseCase, -) : VectorSessionsListViewModel(initialState, activeSessionHolder, refreshDevicesUseCase) { + private val vectorPreferences: VectorPreferences, + private val toggleIpAddressVisibilityUseCase: ToggleIpAddressVisibilityUseCase, +) : VectorSessionsListViewModel(initialState, activeSessionHolder, refreshDevicesUseCase), + SharedPreferences.OnSharedPreferenceChangeListener { @AssistedFactory interface Factory : MavericksAssistedViewModelFactory { @@ -54,6 +68,28 @@ class DevicesViewModel @AssistedInject constructor( observeDevices() refreshDevicesOnCryptoDevicesChange() refreshDeviceList() + refreshIpAddressVisibility() + observePreferences() + } + + override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { + refreshIpAddressVisibility() + } + + private fun observePreferences() { + vectorPreferences.subscribeToChanges(this) + } + + override fun onCleared() { + vectorPreferences.unsubscribeToChanges(this) + super.onCleared() + } + + private fun refreshIpAddressVisibility() { + val shouldShowIpAddress = vectorPreferences.showIpAddressInSessionManagerScreens() + setState { + copy(isShowingIpAddress = shouldShowIpAddress) + } } private fun observeCurrentSessionCrossSigningInfo() { @@ -76,6 +112,7 @@ class DevicesViewModel @AssistedInject constructor( val deviceFullInfoList = async.invoke() val unverifiedSessionsCount = deviceFullInfoList.count { !it.cryptoDeviceInfo?.trustLevel?.isCrossSigningVerified().orFalse() } val inactiveSessionsCount = deviceFullInfoList.count { it.isInactive } + copy( devices = async, unverifiedSessionsCount = unverifiedSessionsCount, @@ -97,11 +134,20 @@ class DevicesViewModel @AssistedInject constructor( override fun handle(action: DevicesAction) { when (action) { + is DevicesAction.PasswordAuthDone -> handlePasswordAuthDone(action) + DevicesAction.ReAuthCancelled -> handleReAuthCancelled() + DevicesAction.SsoAuthDone -> handleSsoAuthDone() is DevicesAction.VerifyCurrentSession -> handleVerifyCurrentSessionAction() is DevicesAction.MarkAsManuallyVerified -> handleMarkAsManuallyVerifiedAction() + DevicesAction.MultiSignoutOtherSessions -> handleMultiSignoutOtherSessions() + DevicesAction.ToggleIpAddressVisibility -> handleToggleIpAddressVisibility() } } + private fun handleToggleIpAddressVisibility() { + toggleIpAddressVisibilityUseCase.execute() + } + private fun handleVerifyCurrentSessionAction() { viewModelScope.launch { val currentSessionCanBeVerified = checkIfCurrentSessionCanBeVerifiedUseCase.execute() @@ -116,4 +162,66 @@ class DevicesViewModel @AssistedInject constructor( private fun handleMarkAsManuallyVerifiedAction() { // TODO implement when needed } + + private fun handleMultiSignoutOtherSessions() = withState { state -> + viewModelScope.launch { + setLoading(true) + val deviceIds = getDeviceIdsOfOtherSessions(state) + if (deviceIds.isEmpty()) { + return@launch + } + val result = signout(deviceIds) + setLoading(false) + + val error = result.exceptionOrNull() + if (error == null) { + onSignoutSuccess() + } else { + onSignoutFailure(error) + } + } + } + + private fun getDeviceIdsOfOtherSessions(state: DevicesViewState): List { + val currentDeviceId = state.currentSessionCrossSigningInfo.deviceId + return state.devices() + ?.mapNotNull { fullInfo -> fullInfo.deviceInfo.deviceId.takeUnless { it == currentDeviceId } } + .orEmpty() + } + + private suspend fun signout(deviceIds: List) = signoutSessionsUseCase.execute(deviceIds, this::onReAuthNeeded) + + private fun onReAuthNeeded(reAuthNeeded: SignoutSessionsReAuthNeeded) { + Timber.d("onReAuthNeeded") + pendingAuthHandler.pendingAuth = DefaultBaseAuth(session = reAuthNeeded.flowResponse.session) + pendingAuthHandler.uiaContinuation = reAuthNeeded.uiaContinuation + _viewEvents.post(DevicesViewEvent.RequestReAuth(reAuthNeeded.flowResponse, reAuthNeeded.errCode)) + } + + private fun setLoading(isLoading: Boolean) { + setState { copy(isLoading = isLoading) } + } + + private fun onSignoutSuccess() { + Timber.d("signout success") + refreshDeviceList() + _viewEvents.post(DevicesViewEvent.SignoutSuccess) + } + + private fun onSignoutFailure(failure: Throwable) { + Timber.e("signout failure", failure) + _viewEvents.post(DevicesViewEvent.SignoutError(failure)) + } + + private fun handleSsoAuthDone() { + pendingAuthHandler.ssoAuthDone() + } + + private fun handlePasswordAuthDone(action: DevicesAction.PasswordAuthDone) { + pendingAuthHandler.passwordAuthDone(action.password) + } + + private fun handleReAuthCancelled() { + pendingAuthHandler.reAuthCancelled() + } } diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/DevicesViewState.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/DevicesViewState.kt index e8bed35e24..e0531c34dc 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/DevicesViewState.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/DevicesViewState.kt @@ -27,4 +27,5 @@ data class DevicesViewState( val unverifiedSessionsCount: Int = 0, val inactiveSessionsCount: Int = 0, val isLoading: Boolean = false, + val isShowingIpAddress: Boolean = false, ) : MavericksState diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/ToggleIpAddressVisibilityUseCase.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/ToggleIpAddressVisibilityUseCase.kt new file mode 100644 index 0000000000..1e1dc19c96 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/ToggleIpAddressVisibilityUseCase.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2022 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 im.vector.app.features.settings.devices.v2 + +import im.vector.app.features.settings.VectorPreferences +import javax.inject.Inject + +class ToggleIpAddressVisibilityUseCase @Inject constructor( + private val vectorPreferences: VectorPreferences, +) { + + fun execute() { + val currentVisibility = vectorPreferences.showIpAddressInSessionManagerScreens() + vectorPreferences.setIpAddressVisibilityInDeviceManagerScreens(!currentVisibility) + } +} diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/VectorSettingsDevicesFragment.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/VectorSettingsDevicesFragment.kt index 1c348af4f9..15375ef679 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/VectorSettingsDevicesFragment.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/VectorSettingsDevicesFragment.kt @@ -16,6 +16,7 @@ package im.vector.app.features.settings.devices.v2 +import android.app.Activity import android.content.Context import android.os.Bundle import android.view.LayoutInflater @@ -30,12 +31,15 @@ import dagger.hilt.android.AndroidEntryPoint import im.vector.app.R import im.vector.app.core.date.VectorDateFormatter import im.vector.app.core.dialogs.ManuallyVerifyDialog +import im.vector.app.core.extensions.registerStartForActivityResult +import im.vector.app.core.extensions.setTextColor import im.vector.app.core.platform.VectorBaseFragment import im.vector.app.core.resources.ColorProvider import im.vector.app.core.resources.DrawableProvider import im.vector.app.core.resources.StringProvider import im.vector.app.databinding.FragmentSettingsDevicesBinding import im.vector.app.features.VectorFeatures +import im.vector.app.features.auth.ReAuthActivity import im.vector.app.features.crypto.recover.SetupMode import im.vector.app.features.crypto.verification.VerificationBottomSheet import im.vector.app.features.login.qr.QrCodeLoginArgs @@ -47,6 +51,10 @@ import im.vector.app.features.settings.devices.v2.list.SESSION_IS_MARKED_AS_INAC import im.vector.app.features.settings.devices.v2.list.SecurityRecommendationView import im.vector.app.features.settings.devices.v2.list.SecurityRecommendationViewState import im.vector.app.features.settings.devices.v2.list.SessionInfoViewState +import im.vector.app.features.settings.devices.v2.signout.BuildConfirmSignoutDialogUseCase +import im.vector.app.features.workers.signout.SignOutUiWorker +import org.matrix.android.sdk.api.auth.data.LoginFlowTypes +import org.matrix.android.sdk.api.extensions.orFalse import org.matrix.android.sdk.api.session.crypto.model.RoomEncryptionTrustLevel import javax.inject.Inject @@ -70,6 +78,8 @@ class VectorSettingsDevicesFragment : @Inject lateinit var stringProvider: StringProvider + @Inject lateinit var buildConfirmSignoutDialogUseCase: BuildConfirmSignoutDialogUseCase + private val viewModel: DevicesViewModel by fragmentViewModel() override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentSettingsDevicesBinding { @@ -91,6 +101,9 @@ class VectorSettingsDevicesFragment : super.onViewCreated(view, savedInstanceState) initWaitingView() + initCurrentSessionHeaderView() + initCurrentSessionView() + initOtherSessionsHeaderView() initOtherSessionsView() initSecurityRecommendationsView() initQrLoginView() @@ -100,10 +113,7 @@ class VectorSettingsDevicesFragment : private fun observeViewEvents() { viewModel.observeViewEvents { when (it) { - is DevicesViewEvent.Loading -> showLoading(it.message) - is DevicesViewEvent.Failure -> showFailure(it.throwable) - is DevicesViewEvent.RequestReAuth -> Unit // TODO. Next PR - is DevicesViewEvent.PromptRenameDevice -> Unit // TODO. Next PR + is DevicesViewEvent.RequestReAuth -> askForReAuthentication(it) is DevicesViewEvent.ShowVerifyDevice -> { VerificationBottomSheet.withArgs( roomId = null, @@ -122,6 +132,8 @@ class VectorSettingsDevicesFragment : is DevicesViewEvent.PromptResetSecrets -> { navigator.open4SSetup(requireActivity(), SetupMode.PASSPHRASE_AND_NEEDED_SECRETS_RESET) } + is DevicesViewEvent.SignoutError -> showFailure(it.error) + is DevicesViewEvent.SignoutSuccess -> Unit // do nothing } } } @@ -131,6 +143,77 @@ class VectorSettingsDevicesFragment : views.waitingView.waitingStatusText.isVisible = true } + private fun initCurrentSessionHeaderView() { + views.deviceListHeaderCurrentSession.setOnMenuItemClickListener { menuItem -> + when (menuItem.itemId) { + R.id.currentSessionHeaderRename -> { + navigateToRenameCurrentSession() + true + } + R.id.currentSessionHeaderSignout -> { + confirmSignoutCurrentSession() + true + } + R.id.currentSessionHeaderSignoutOtherSessions -> { + confirmMultiSignoutOtherSessions() + true + } + else -> false + } + } + } + + private fun navigateToRenameCurrentSession() = withState(viewModel) { state -> + val currentDeviceId = state.currentSessionCrossSigningInfo.deviceId + if (currentDeviceId.isNotEmpty()) { + viewNavigator.navigateToRenameSession( + context = requireActivity(), + deviceId = currentDeviceId, + ) + } + } + + private fun confirmSignoutCurrentSession() { + activity?.let { SignOutUiWorker(it).perform() } + } + + private fun initCurrentSessionView() { + views.deviceListCurrentSession.viewVerifyButton.debouncedClicks { + viewModel.handle(DevicesAction.VerifyCurrentSession) + } + } + + private fun initOtherSessionsHeaderView() { + views.deviceListHeaderOtherSessions.setOnMenuItemClickListener { menuItem -> + when (menuItem.itemId) { + R.id.otherSessionsHeaderMultiSignout -> { + confirmMultiSignoutOtherSessions() + true + } + R.id.otherSessionsHeaderToggleIpAddress -> { + handleToggleIpAddressVisibility() + true + } + else -> false + } + } + } + + private fun handleToggleIpAddressVisibility() { + viewModel.handle(DevicesAction.ToggleIpAddressVisibility) + } + + private fun confirmMultiSignoutOtherSessions() { + activity?.let { + buildConfirmSignoutDialogUseCase.execute(it, this::multiSignoutOtherSessions) + .show() + } + } + + private fun multiSignoutOtherSessions() { + viewModel.handle(DevicesAction.MultiSignoutOtherSessions) + } + private fun initOtherSessionsView() { views.deviceListOtherSessions.callback = this } @@ -140,9 +223,8 @@ class VectorSettingsDevicesFragment : override fun onViewAllClicked() { viewNavigator.navigateToOtherSessions( requireActivity(), - R.string.device_manager_header_section_security_recommendations_title, DeviceManagerFilterType.UNVERIFIED, - excludeCurrentDevice = false + excludeCurrentDevice = true ) } } @@ -150,9 +232,8 @@ class VectorSettingsDevicesFragment : override fun onViewAllClicked() { viewNavigator.navigateToOtherSessions( requireActivity(), - R.string.device_manager_header_section_security_recommendations_title, DeviceManagerFilterType.INACTIVE, - excludeCurrentDevice = false + excludeCurrentDevice = true ) } } @@ -208,8 +289,8 @@ class VectorSettingsDevicesFragment : val otherDevices = devices?.filter { it.deviceInfo.deviceId != currentDeviceId } renderSecurityRecommendations(state.inactiveSessionsCount, state.unverifiedSessionsCount, isCurrentSessionVerified) - renderCurrentDevice(currentDeviceInfo) - renderOtherSessionsView(otherDevices) + renderCurrentSessionView(currentDeviceInfo, hasOtherDevices = otherDevices?.isNotEmpty().orFalse()) + renderOtherSessionsView(otherDevices, state.isShowingIpAddress) } else { hideSecurityRecommendations() hideCurrentSessionView() @@ -266,17 +347,28 @@ class VectorSettingsDevicesFragment : hideInactiveSessionsRecommendation() } - private fun renderOtherSessionsView(otherDevices: List?) { + private fun renderOtherSessionsView(otherDevices: List?, isShowingIpAddress: Boolean) { if (otherDevices.isNullOrEmpty()) { hideOtherSessionsView() } else { views.deviceListHeaderOtherSessions.isVisible = true + val colorDestructive = colorProvider.getColorFromAttribute(R.attr.colorError) + val multiSignoutItem = views.deviceListHeaderOtherSessions.menu.findItem(R.id.otherSessionsHeaderMultiSignout) + val nbDevices = otherDevices.size + multiSignoutItem.title = stringProvider.getQuantityString(R.plurals.device_manager_other_sessions_multi_signout_all, nbDevices, nbDevices) + multiSignoutItem.setTextColor(colorDestructive) views.deviceListOtherSessions.isVisible = true + val devices = if (isShowingIpAddress) otherDevices else otherDevices.map { it.copy(deviceInfo = it.deviceInfo.copy(lastSeenIp = null)) } views.deviceListOtherSessions.render( - devices = otherDevices.take(NUMBER_OF_OTHER_DEVICES_TO_RENDER), - totalNumberOfDevices = otherDevices.size, - showViewAll = otherDevices.size > NUMBER_OF_OTHER_DEVICES_TO_RENDER + devices = devices.take(NUMBER_OF_OTHER_DEVICES_TO_RENDER), + totalNumberOfDevices = devices.size, + showViewAll = devices.size > NUMBER_OF_OTHER_DEVICES_TO_RENDER ) + views.deviceListHeaderOtherSessions.menu.findItem(R.id.otherSessionsHeaderToggleIpAddress).title = if (isShowingIpAddress) { + stringProvider.getString(R.string.device_manager_other_sessions_hide_ip_address) + } else { + stringProvider.getString(R.string.device_manager_other_sessions_show_ip_address) + } } } @@ -285,29 +377,40 @@ class VectorSettingsDevicesFragment : views.deviceListOtherSessions.isVisible = false } - private fun renderCurrentDevice(currentDeviceInfo: DeviceFullInfo?) { + private fun renderCurrentSessionView(currentDeviceInfo: DeviceFullInfo?, hasOtherDevices: Boolean) { currentDeviceInfo?.let { - views.deviceListHeaderCurrentSession.isVisible = true - views.deviceListCurrentSession.isVisible = true - val viewState = SessionInfoViewState( - isCurrentSession = true, - deviceFullInfo = it - ) - views.deviceListCurrentSession.render(viewState, dateFormatter, drawableProvider, colorProvider, stringProvider) - views.deviceListCurrentSession.debouncedClicks { - currentDeviceInfo.deviceInfo.deviceId?.let { deviceId -> navigateToSessionOverview(deviceId) } - } - views.deviceListCurrentSession.viewDetailsButton.debouncedClicks { - currentDeviceInfo.deviceInfo.deviceId?.let { deviceId -> navigateToSessionOverview(deviceId) } - } - views.deviceListCurrentSession.viewVerifyButton.debouncedClicks { - viewModel.handle(DevicesAction.VerifyCurrentSession) - } + renderCurrentSessionHeaderView(hasOtherDevices) + renderCurrentSessionListView(it) } ?: run { hideCurrentSessionView() } } + private fun renderCurrentSessionHeaderView(hasOtherDevices: Boolean) { + views.deviceListHeaderCurrentSession.isVisible = true + val colorDestructive = colorProvider.getColorFromAttribute(R.attr.colorError) + val signoutSessionItem = views.deviceListHeaderCurrentSession.menu.findItem(R.id.currentSessionHeaderSignout) + signoutSessionItem.setTextColor(colorDestructive) + val signoutOtherSessionsItem = views.deviceListHeaderCurrentSession.menu.findItem(R.id.currentSessionHeaderSignoutOtherSessions) + signoutOtherSessionsItem.setTextColor(colorDestructive) + signoutOtherSessionsItem.isVisible = hasOtherDevices + } + + private fun renderCurrentSessionListView(currentDeviceInfo: DeviceFullInfo) { + views.deviceListCurrentSession.isVisible = true + val viewState = SessionInfoViewState( + isCurrentSession = true, + deviceFullInfo = currentDeviceInfo + ) + views.deviceListCurrentSession.render(viewState, dateFormatter, drawableProvider, colorProvider, stringProvider) + views.deviceListCurrentSession.debouncedClicks { + currentDeviceInfo.deviceInfo.deviceId?.let { deviceId -> navigateToSessionOverview(deviceId) } + } + views.deviceListCurrentSession.viewDetailsButton.debouncedClicks { + currentDeviceInfo.deviceInfo.deviceId?.let { deviceId -> navigateToSessionOverview(deviceId) } + } + } + private fun navigateToSessionOverview(deviceId: String) { viewNavigator.navigateToSessionOverview( context = requireActivity(), @@ -342,9 +445,41 @@ class VectorSettingsDevicesFragment : override fun onViewAllOtherSessionsClicked() { viewNavigator.navigateToOtherSessions( context = requireActivity(), - titleResourceId = R.string.device_manager_sessions_other_title, defaultFilter = DeviceManagerFilterType.ALL_SESSIONS, excludeCurrentDevice = true ) } + + private val reAuthActivityResultLauncher = registerStartForActivityResult { activityResult -> + if (activityResult.resultCode == Activity.RESULT_OK) { + when (activityResult.data?.extras?.getString(ReAuthActivity.RESULT_FLOW_TYPE)) { + LoginFlowTypes.SSO -> { + viewModel.handle(DevicesAction.SsoAuthDone) + } + LoginFlowTypes.PASSWORD -> { + val password = activityResult.data?.extras?.getString(ReAuthActivity.RESULT_VALUE) ?: "" + viewModel.handle(DevicesAction.PasswordAuthDone(password)) + } + else -> { + viewModel.handle(DevicesAction.ReAuthCancelled) + } + } + } else { + viewModel.handle(DevicesAction.ReAuthCancelled) + } + } + + /** + * Launch the re auth activity to get credentials. + */ + private fun askForReAuthentication(reAuthReq: DevicesViewEvent.RequestReAuth) { + ReAuthActivity.newIntent( + requireContext(), + reAuthReq.registrationFlowResponse, + reAuthReq.lastErrorCode, + getString(R.string.devices_delete_dialog_title) + ).let { intent -> + reAuthActivityResultLauncher.launch(intent) + } + } } diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/VectorSettingsDevicesViewNavigator.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/VectorSettingsDevicesViewNavigator.kt index 47e697822b..bcfa1c30db 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/VectorSettingsDevicesViewNavigator.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/VectorSettingsDevicesViewNavigator.kt @@ -20,6 +20,7 @@ import android.content.Context import im.vector.app.features.settings.devices.v2.filter.DeviceManagerFilterType import im.vector.app.features.settings.devices.v2.othersessions.OtherSessionsActivity import im.vector.app.features.settings.devices.v2.overview.SessionOverviewActivity +import im.vector.app.features.settings.devices.v2.rename.RenameSessionActivity import javax.inject.Inject class VectorSettingsDevicesViewNavigator @Inject constructor() { @@ -30,12 +31,15 @@ class VectorSettingsDevicesViewNavigator @Inject constructor() { fun navigateToOtherSessions( context: Context, - titleResourceId: Int, defaultFilter: DeviceManagerFilterType, excludeCurrentDevice: Boolean, ) { context.startActivity( - OtherSessionsActivity.newIntent(context, titleResourceId, defaultFilter, excludeCurrentDevice) + OtherSessionsActivity.newIntent(context, defaultFilter, excludeCurrentDevice) ) } + + fun navigateToRenameSession(context: Context, deviceId: String) { + context.startActivity(RenameSessionActivity.newIntent(context, deviceId)) + } } diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/details/SessionDetailsHeaderItem.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/details/SessionDetailsHeaderItem.kt index ff6ce3faad..f76c21da8e 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/details/SessionDetailsHeaderItem.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/details/SessionDetailsHeaderItem.kt @@ -27,7 +27,7 @@ import im.vector.app.core.epoxy.VectorEpoxyHolder import im.vector.app.core.epoxy.VectorEpoxyModel import im.vector.app.core.utils.DimensionConverter -private const val EXTRA_TOP_MARGIN_DP = 48 +private const val EXTRA_TOP_MARGIN_DP = 32 @EpoxyModelClass abstract class SessionDetailsHeaderItem : VectorEpoxyModel(R.layout.item_session_details_header) { diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/list/OtherSessionItem.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/list/OtherSessionItem.kt index de1cd33d35..68cae344cd 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/list/OtherSessionItem.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/list/OtherSessionItem.kt @@ -29,6 +29,7 @@ import im.vector.app.core.epoxy.ClickListener import im.vector.app.core.epoxy.VectorEpoxyHolder import im.vector.app.core.epoxy.VectorEpoxyModel import im.vector.app.core.epoxy.onClick +import im.vector.app.core.extensions.setTextOrHide import im.vector.app.core.resources.ColorProvider import im.vector.app.core.resources.DrawableProvider import im.vector.app.core.resources.StringProvider @@ -69,6 +70,9 @@ abstract class OtherSessionItem : VectorEpoxyModel(R.la @EpoxyAttribute var selected: Boolean = false + @EpoxyAttribute + var ipAddress: String? = null + @EpoxyAttribute(EpoxyAttribute.Option.DoNotHash) var clickListener: ClickListener? = null @@ -93,13 +97,14 @@ abstract class OtherSessionItem : VectorEpoxyModel(R.la } else { setDeviceTypeIconUseCase.execute(deviceType, holder.otherSessionDeviceTypeImageView, stringProvider) } - holder.otherSessionVerificationStatusImageView.render(roomEncryptionTrustLevel) + holder.otherSessionVerificationStatusImageView.renderDeviceShield(roomEncryptionTrustLevel) holder.otherSessionNameTextView.text = sessionName holder.otherSessionDescriptionTextView.text = sessionDescription sessionDescriptionColor?.let { holder.otherSessionDescriptionTextView.setTextColor(it) } holder.otherSessionDescriptionTextView.setCompoundDrawablesWithIntrinsicBounds(sessionDescriptionDrawable, null, null, null) + holder.otherSessionIpAddressTextView.setTextOrHide(ipAddress) holder.otherSessionItemBackgroundView.isSelected = selected } @@ -108,6 +113,7 @@ abstract class OtherSessionItem : VectorEpoxyModel(R.la val otherSessionVerificationStatusImageView by bind(R.id.otherSessionVerificationStatusImageView) val otherSessionNameTextView by bind(R.id.otherSessionNameTextView) val otherSessionDescriptionTextView by bind(R.id.otherSessionDescriptionTextView) + val otherSessionIpAddressTextView by bind(R.id.otherSessionIpAddressTextView) val otherSessionItemBackgroundView by bind(R.id.otherSessionItemBackground) } } diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/list/OtherSessionsController.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/list/OtherSessionsController.kt index 8d70552101..5e2549f42a 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/list/OtherSessionsController.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/list/OtherSessionsController.kt @@ -72,6 +72,7 @@ class OtherSessionsController @Inject constructor( sessionDescription(description) sessionDescriptionDrawable(descriptionDrawable) sessionDescriptionColor(descriptionColor) + ipAddress(device.deviceInfo.lastSeenIp) stringProvider(host.stringProvider) colorProvider(host.colorProvider) drawableProvider(host.drawableProvider) diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/list/SecurityRecommendationView.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/list/SecurityRecommendationView.kt index 07202274ad..2a43a9aade 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/list/SecurityRecommendationView.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/list/SecurityRecommendationView.kt @@ -53,6 +53,9 @@ class SecurityRecommendationView @JvmOverloads constructor( setImage(it) } + setOnClickListener { + callback?.onViewAllClicked() + } views.recommendationViewAllButton.setOnClickListener { callback?.onViewAllClicked() } diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/list/SessionInfoView.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/list/SessionInfoView.kt index 3d9c3a8f37..5d2daf2941 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/list/SessionInfoView.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/list/SessionInfoView.kt @@ -75,7 +75,8 @@ class SessionInfoView @JvmOverloads constructor( renderDeviceLastSeenDetails( sessionInfoViewState.deviceFullInfo.isInactive, sessionInfoViewState.deviceFullInfo.deviceInfo, - sessionInfoViewState.isLastSeenDetailsVisible, + sessionInfoViewState.isLastActivityVisible, + sessionInfoViewState.isShowingIpAddress, dateFormatter, drawableProvider, colorProvider, @@ -84,13 +85,14 @@ class SessionInfoView @JvmOverloads constructor( } private fun renderVerificationStatus( - encryptionTrustLevel: RoomEncryptionTrustLevel, + encryptionTrustLevel: RoomEncryptionTrustLevel?, isCurrentSession: Boolean, hasLearnMoreLink: Boolean, isVerifyButtonVisible: Boolean, ) { - views.sessionInfoVerificationStatusImageView.render(encryptionTrustLevel) + views.sessionInfoVerificationStatusImageView.renderDeviceShield(encryptionTrustLevel) when { + encryptionTrustLevel == null -> renderCrossSigningEncryptionNotSupported() encryptionTrustLevel == RoomEncryptionTrustLevel.Trusted -> renderCrossSigningVerified(isCurrentSession) encryptionTrustLevel == RoomEncryptionTrustLevel.Default && !isCurrentSession -> renderCrossSigningUnknown() else -> renderCrossSigningUnverified(isCurrentSession, isVerifyButtonVisible) @@ -148,6 +150,14 @@ class SessionInfoView @JvmOverloads constructor( views.sessionInfoVerifySessionButton.isVisible = false } + private fun renderCrossSigningEncryptionNotSupported() { + views.sessionInfoVerificationStatusTextView.text = context.getString(R.string.device_manager_verification_status_unverified) + views.sessionInfoVerificationStatusTextView.setTextColor(ThemeUtils.getColor(context, R.attr.colorError)) + views.sessionInfoVerificationStatusDetailTextView.text = + context.getString(R.string.device_manager_verification_status_detail_session_encryption_not_supported) + views.sessionInfoVerifySessionButton.isVisible = false + } + private fun renderDeviceInfo(sessionName: String, deviceType: DeviceType, stringProvider: StringProvider) { setDeviceTypeIconUseCase.execute(deviceType, views.sessionInfoDeviceTypeImageView, stringProvider) views.sessionInfoNameTextView.text = sessionName @@ -157,6 +167,7 @@ class SessionInfoView @JvmOverloads constructor( isInactive: Boolean, deviceInfo: DeviceInfo, isLastSeenDetailsVisible: Boolean, + isShowingIpAddress: Boolean, dateFormatter: VectorDateFormatter, drawableProvider: DrawableProvider, colorProvider: ColorProvider, @@ -186,7 +197,7 @@ class SessionInfoView @JvmOverloads constructor( } else { views.sessionInfoLastActivityTextView.isGone = true } - views.sessionInfoLastIPAddressTextView.setTextOrHide(deviceInfo.lastSeenIp?.takeIf { isLastSeenDetailsVisible }) + views.sessionInfoLastIPAddressTextView.setTextOrHide(deviceInfo.lastSeenIp?.takeIf { isShowingIpAddress }) } private fun renderDetailsButton(isDetailsButtonVisible: Boolean) { diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/list/SessionInfoViewState.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/list/SessionInfoViewState.kt index 287bb956f5..6c7ca809ea 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/list/SessionInfoViewState.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/list/SessionInfoViewState.kt @@ -24,5 +24,6 @@ data class SessionInfoViewState( val isVerifyButtonVisible: Boolean = true, val isDetailsButtonVisible: Boolean = true, val isLearnMoreLinkVisible: Boolean = false, - val isLastSeenDetailsVisible: Boolean = false, + val isLastActivityVisible: Boolean = false, + val isShowingIpAddress: Boolean = false, ) diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/list/SessionsListHeaderView.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/list/SessionsListHeaderView.kt index 0660e7d642..f74d88790c 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/list/SessionsListHeaderView.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/list/SessionsListHeaderView.kt @@ -20,6 +20,10 @@ import android.content.Context import android.content.res.TypedArray import android.util.AttributeSet import android.view.LayoutInflater +import android.view.Menu +import android.view.MenuInflater +import androidx.appcompat.view.menu.MenuBuilder +import androidx.appcompat.widget.ActionMenuView.OnMenuItemClickListener import androidx.constraintlayout.widget.ConstraintLayout import androidx.core.content.res.use import androidx.core.view.isVisible @@ -39,6 +43,7 @@ class SessionsListHeaderView @JvmOverloads constructor( this ) + val menu: Menu = binding.sessionsListHeaderMenu.menu var onLearnMoreClickListener: (() -> Unit)? = null init { @@ -50,6 +55,7 @@ class SessionsListHeaderView @JvmOverloads constructor( ).use { setTitle(it) setDescription(it) + setMenu(it) } } @@ -90,4 +96,19 @@ class SessionsListHeaderView @JvmOverloads constructor( onLearnMoreClickListener?.invoke() } } + + private fun setMenu(typedArray: TypedArray) { + val menuResId = typedArray.getResourceId(R.styleable.SessionsListHeaderView_sessionsListHeaderMenu, -1) + if (menuResId == -1) { + binding.sessionsListHeaderMenu.isVisible = false + } else { + binding.sessionsListHeaderMenu.showOverflowMenu() + val menuBuilder = binding.sessionsListHeaderMenu.menu as? MenuBuilder + menuBuilder?.let { MenuInflater(context).inflate(menuResId, it) } + } + } + + fun setOnMenuItemClickListener(listener: OnMenuItemClickListener) { + binding.sessionsListHeaderMenu.setOnMenuItemClickListener(listener) + } } diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/more/SessionLearnMoreBottomSheet.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/more/SessionLearnMoreBottomSheet.kt index 22ca06eb1e..502d9abca3 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/more/SessionLearnMoreBottomSheet.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/more/SessionLearnMoreBottomSheet.kt @@ -16,6 +16,7 @@ package im.vector.app.features.settings.devices.v2.more +import android.content.DialogInterface import android.os.Bundle import android.os.Parcelable import android.view.LayoutInflater @@ -42,6 +43,8 @@ class SessionLearnMoreBottomSheet : VectorBaseBottomSheetDialogFragment Unit)? = null + override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): BottomSheetSessionLearnMoreBinding { return BottomSheetSessionLearnMoreBinding.inflate(inflater, container, false) } @@ -57,6 +60,11 @@ class SessionLearnMoreBottomSheet : VectorBaseBottomSheetDialogFragment super.invalidate() views.bottomSheetSessionLearnMoreTitle.text = viewState.title @@ -65,11 +73,12 @@ class SessionLearnMoreBottomSheet : VectorBaseBottomSheetDialogFragment { + return getNotificationSettingsAccountDataUpdatesUseCase.execute(session, deviceId) + .map { it?.isSilenced != null } + } +} diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/notification/CanToggleNotificationsViaPusherUseCase.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/notification/CanToggleNotificationsViaPusherUseCase.kt new file mode 100644 index 0000000000..96521ec78c --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/notification/CanToggleNotificationsViaPusherUseCase.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2022 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 im.vector.app.features.settings.devices.v2.notification + +import androidx.lifecycle.asFlow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map +import org.matrix.android.sdk.api.session.Session +import org.matrix.android.sdk.flow.unwrap +import javax.inject.Inject + +class CanToggleNotificationsViaPusherUseCase @Inject constructor() { + + fun execute(session: Session): Flow { + return session + .homeServerCapabilitiesService() + .getHomeServerCapabilitiesLive() + .asFlow() + .unwrap() + .map { it.canRemotelyTogglePushNotificationsOfDevices } + .distinctUntilChanged() + } +} diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/notification/CheckIfCanToggleNotificationsViaAccountDataUseCase.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/notification/CheckIfCanToggleNotificationsViaAccountDataUseCase.kt new file mode 100644 index 0000000000..58289495a4 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/notification/CheckIfCanToggleNotificationsViaAccountDataUseCase.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2022 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 im.vector.app.features.settings.devices.v2.notification + +import org.matrix.android.sdk.api.session.Session +import javax.inject.Inject + +class CheckIfCanToggleNotificationsViaAccountDataUseCase @Inject constructor( + private val getNotificationSettingsAccountDataUseCase: GetNotificationSettingsAccountDataUseCase, +) { + + fun execute(session: Session, deviceId: String): Boolean { + return getNotificationSettingsAccountDataUseCase.execute(session, deviceId)?.isSilenced != null + } +} diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/notification/CheckIfCanToggleNotificationsViaPusherUseCase.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/notification/CheckIfCanToggleNotificationsViaPusherUseCase.kt new file mode 100644 index 0000000000..1dc186be7c --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/notification/CheckIfCanToggleNotificationsViaPusherUseCase.kt @@ -0,0 +1,30 @@ +/* + * Copyright (c) 2022 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 im.vector.app.features.settings.devices.v2.notification + +import org.matrix.android.sdk.api.session.Session +import javax.inject.Inject + +class CheckIfCanToggleNotificationsViaPusherUseCase @Inject constructor() { + + fun execute(session: Session): Boolean { + return session + .homeServerCapabilitiesService() + .getHomeServerCapabilities() + .canRemotelyTogglePushNotificationsOfDevices + } +} diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/notification/DeleteNotificationSettingsAccountDataUseCase.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/notification/DeleteNotificationSettingsAccountDataUseCase.kt new file mode 100644 index 0000000000..3c086fe111 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/notification/DeleteNotificationSettingsAccountDataUseCase.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2022 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 im.vector.app.features.settings.devices.v2.notification + +import org.matrix.android.sdk.api.account.LocalNotificationSettingsContent +import org.matrix.android.sdk.api.session.Session +import javax.inject.Inject + +/** + * Delete the content of any associated notification settings to the current session. + */ +class DeleteNotificationSettingsAccountDataUseCase @Inject constructor( + private val getNotificationSettingsAccountDataUseCase: GetNotificationSettingsAccountDataUseCase, + private val setNotificationSettingsAccountDataUseCase: SetNotificationSettingsAccountDataUseCase, +) { + + suspend fun execute(session: Session) { + val deviceId = session.sessionParams.deviceId ?: return + if (getNotificationSettingsAccountDataUseCase.execute(session, deviceId)?.isSilenced != null) { + val emptyNotificationSettingsContent = LocalNotificationSettingsContent( + isSilenced = null + ) + setNotificationSettingsAccountDataUseCase.execute(session, deviceId, emptyNotificationSettingsContent) + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/notification/GetNotificationSettingsAccountDataUpdatesUseCase.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/notification/GetNotificationSettingsAccountDataUpdatesUseCase.kt new file mode 100644 index 0000000000..308aeec5f2 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/notification/GetNotificationSettingsAccountDataUpdatesUseCase.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2022 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 im.vector.app.features.settings.devices.v2.notification + +import androidx.lifecycle.asFlow +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import org.matrix.android.sdk.api.account.LocalNotificationSettingsContent +import org.matrix.android.sdk.api.session.Session +import org.matrix.android.sdk.api.session.accountdata.UserAccountDataTypes +import org.matrix.android.sdk.api.session.events.model.toModel +import javax.inject.Inject + +class GetNotificationSettingsAccountDataUpdatesUseCase @Inject constructor() { + + fun execute(session: Session, deviceId: String): Flow { + return session + .accountDataService() + .getLiveUserAccountDataEvent(UserAccountDataTypes.TYPE_LOCAL_NOTIFICATION_SETTINGS + deviceId) + .asFlow() + .map { it.getOrNull()?.content?.toModel() } + } +} diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/notification/GetNotificationSettingsAccountDataUseCase.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/notification/GetNotificationSettingsAccountDataUseCase.kt new file mode 100644 index 0000000000..5517fa0978 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/notification/GetNotificationSettingsAccountDataUseCase.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2022 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 im.vector.app.features.settings.devices.v2.notification + +import org.matrix.android.sdk.api.account.LocalNotificationSettingsContent +import org.matrix.android.sdk.api.session.Session +import org.matrix.android.sdk.api.session.accountdata.UserAccountDataTypes +import org.matrix.android.sdk.api.session.events.model.toModel +import javax.inject.Inject + +class GetNotificationSettingsAccountDataUseCase @Inject constructor() { + + fun execute(session: Session, deviceId: String): LocalNotificationSettingsContent? { + return session + .accountDataService() + .getUserAccountDataEvent(UserAccountDataTypes.TYPE_LOCAL_NOTIFICATION_SETTINGS + deviceId) + ?.content + .toModel() + } +} diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/notification/GetNotificationsStatusUseCase.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/notification/GetNotificationsStatusUseCase.kt new file mode 100644 index 0000000000..8cf684975e --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/notification/GetNotificationsStatusUseCase.kt @@ -0,0 +1,76 @@ +/* + * Copyright (c) 2022 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 im.vector.app.features.settings.devices.v2.notification + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import org.matrix.android.sdk.api.account.LocalNotificationSettingsContent +import org.matrix.android.sdk.api.session.Session +import org.matrix.android.sdk.api.session.accountdata.UserAccountDataTypes +import org.matrix.android.sdk.api.session.events.model.toModel +import org.matrix.android.sdk.flow.flow +import org.matrix.android.sdk.flow.unwrap +import javax.inject.Inject + +class GetNotificationsStatusUseCase @Inject constructor( + private val canToggleNotificationsViaPusherUseCase: CanToggleNotificationsViaPusherUseCase, + private val canToggleNotificationsViaAccountDataUseCase: CanToggleNotificationsViaAccountDataUseCase, +) { + + fun execute(session: Session, deviceId: String): Flow { + return canToggleNotificationsViaAccountDataUseCase.execute(session, deviceId) + .flatMapLatest { canToggle -> + if (canToggle) { + notificationStatusFromAccountData(session, deviceId) + } else { + notificationStatusFromPusher(session, deviceId) + } + } + .distinctUntilChanged() + } + + private fun notificationStatusFromAccountData(session: Session, deviceId: String) = + session.flow() + .liveUserAccountData(UserAccountDataTypes.TYPE_LOCAL_NOTIFICATION_SETTINGS + deviceId) + .unwrap() + .map { it.content.toModel()?.isSilenced?.not() } + .map { if (it == true) NotificationsStatus.ENABLED else NotificationsStatus.DISABLED } + + private fun notificationStatusFromPusher(session: Session, deviceId: String) = + canToggleNotificationsViaPusherUseCase.execute(session) + .flatMapLatest { canToggle -> + if (canToggle) { + session.flow() + .livePushers() + .map { it.filter { pusher -> pusher.deviceId == deviceId } } + .map { it.takeIf { it.isNotEmpty() }?.any { pusher -> pusher.enabled } } + .map { + when (it) { + true -> NotificationsStatus.ENABLED + false -> NotificationsStatus.DISABLED + else -> NotificationsStatus.NOT_SUPPORTED + } + } + .distinctUntilChanged() + } else { + flowOf(NotificationsStatus.NOT_SUPPORTED) + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/notification/NotificationsStatus.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/notification/NotificationsStatus.kt new file mode 100644 index 0000000000..7ff1f04381 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/notification/NotificationsStatus.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2022 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 im.vector.app.features.settings.devices.v2.notification + +enum class NotificationsStatus { + ENABLED, + DISABLED, + NOT_SUPPORTED, +} diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/notification/SetNotificationSettingsAccountDataUseCase.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/notification/SetNotificationSettingsAccountDataUseCase.kt new file mode 100644 index 0000000000..7306794f16 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/notification/SetNotificationSettingsAccountDataUseCase.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2022 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 im.vector.app.features.settings.devices.v2.notification + +import org.matrix.android.sdk.api.account.LocalNotificationSettingsContent +import org.matrix.android.sdk.api.session.Session +import org.matrix.android.sdk.api.session.accountdata.UserAccountDataTypes +import org.matrix.android.sdk.api.session.events.model.toContent +import javax.inject.Inject + +class SetNotificationSettingsAccountDataUseCase @Inject constructor() { + + suspend fun execute(session: Session, deviceId: String, localNotificationSettingsContent: LocalNotificationSettingsContent) { + session.accountDataService().updateUserAccountData( + UserAccountDataTypes.TYPE_LOCAL_NOTIFICATION_SETTINGS + deviceId, + localNotificationSettingsContent.toContent(), + ) + } +} diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/overview/TogglePushNotificationUseCase.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/notification/ToggleNotificationsUseCase.kt similarity index 52% rename from vector/src/main/java/im/vector/app/features/settings/devices/v2/overview/TogglePushNotificationUseCase.kt rename to vector/src/main/java/im/vector/app/features/settings/devices/v2/notification/ToggleNotificationsUseCase.kt index 45c234aaef..77195ea950 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/overview/TogglePushNotificationUseCase.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/notification/ToggleNotificationsUseCase.kt @@ -14,32 +14,32 @@ * limitations under the License. */ -package im.vector.app.features.settings.devices.v2.overview +package im.vector.app.features.settings.devices.v2.notification import im.vector.app.core.di.ActiveSessionHolder import org.matrix.android.sdk.api.account.LocalNotificationSettingsContent -import org.matrix.android.sdk.api.session.accountdata.UserAccountDataTypes -import org.matrix.android.sdk.api.session.events.model.toContent import javax.inject.Inject -class TogglePushNotificationUseCase @Inject constructor( +class ToggleNotificationsUseCase @Inject constructor( private val activeSessionHolder: ActiveSessionHolder, + private val checkIfCanToggleNotificationsViaPusherUseCase: CheckIfCanToggleNotificationsViaPusherUseCase, + private val checkIfCanToggleNotificationsViaAccountDataUseCase: CheckIfCanToggleNotificationsViaAccountDataUseCase, + private val setNotificationSettingsAccountDataUseCase: SetNotificationSettingsAccountDataUseCase, ) { suspend fun execute(deviceId: String, enabled: Boolean) { val session = activeSessionHolder.getSafeActiveSession() ?: return - val devicePusher = session.pushersService().getPushers().firstOrNull { it.deviceId == deviceId } - devicePusher?.let { pusher -> - session.pushersService().togglePusher(pusher, enabled) + + if (checkIfCanToggleNotificationsViaPusherUseCase.execute(session)) { + val devicePusher = session.pushersService().getPushers().firstOrNull { it.deviceId == deviceId } + devicePusher?.let { pusher -> + session.pushersService().togglePusher(pusher, enabled) + } } - val accountData = session.accountDataService().getUserAccountDataEvent(UserAccountDataTypes.TYPE_LOCAL_NOTIFICATION_SETTINGS + deviceId) - if (accountData != null) { + if (checkIfCanToggleNotificationsViaAccountDataUseCase.execute(session, deviceId)) { val newNotificationSettingsContent = LocalNotificationSettingsContent(isSilenced = !enabled) - session.accountDataService().updateUserAccountData( - UserAccountDataTypes.TYPE_LOCAL_NOTIFICATION_SETTINGS + deviceId, - newNotificationSettingsContent.toContent(), - ) + setNotificationSettingsAccountDataUseCase.execute(session, deviceId, newNotificationSettingsContent) } } } diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/notification/UpdateNotificationSettingsAccountDataUseCase.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/notification/UpdateNotificationSettingsAccountDataUseCase.kt new file mode 100644 index 0000000000..9296bcd912 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/notification/UpdateNotificationSettingsAccountDataUseCase.kt @@ -0,0 +1,60 @@ +/* + * Copyright (c) 2022 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 im.vector.app.features.settings.devices.v2.notification + +import im.vector.app.core.pushers.UnifiedPushHelper +import im.vector.app.features.settings.VectorPreferences +import org.matrix.android.sdk.api.account.LocalNotificationSettingsContent +import org.matrix.android.sdk.api.session.Session +import javax.inject.Inject + +/** + * Update the notification settings account data for the current session depending on whether + * the background sync is enabled or not. + */ +class UpdateNotificationSettingsAccountDataUseCase @Inject constructor( + private val vectorPreferences: VectorPreferences, + private val unifiedPushHelper: UnifiedPushHelper, + private val getNotificationSettingsAccountDataUseCase: GetNotificationSettingsAccountDataUseCase, + private val setNotificationSettingsAccountDataUseCase: SetNotificationSettingsAccountDataUseCase, + private val deleteNotificationSettingsAccountDataUseCase: DeleteNotificationSettingsAccountDataUseCase, +) { + + suspend fun execute(session: Session) { + if (unifiedPushHelper.isBackgroundSync()) { + setCurrentNotificationStatus(session) + } else { + deleteCurrentNotificationStatus(session) + } + } + + private suspend fun setCurrentNotificationStatus(session: Session) { + val deviceId = session.sessionParams.deviceId ?: return + val areNotificationsSilenced = !vectorPreferences.areNotificationEnabledForDevice() + val isSilencedAccountData = getNotificationSettingsAccountDataUseCase.execute(session, deviceId)?.isSilenced + if (areNotificationsSilenced != isSilencedAccountData) { + val notificationSettingsContent = LocalNotificationSettingsContent( + isSilenced = areNotificationsSilenced + ) + setNotificationSettingsAccountDataUseCase.execute(session, deviceId, notificationSettingsContent) + } + } + + private suspend fun deleteCurrentNotificationStatus(session: Session) { + deleteNotificationSettingsAccountDataUseCase.execute(session) + } +} diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsAction.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsAction.kt index 1978708ebf..bdad65ca43 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsAction.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsAction.kt @@ -20,10 +20,18 @@ import im.vector.app.core.platform.VectorViewModelAction import im.vector.app.features.settings.devices.v2.filter.DeviceManagerFilterType sealed class OtherSessionsAction : VectorViewModelAction { + // ReAuth + object SsoAuthDone : OtherSessionsAction() + data class PasswordAuthDone(val password: String) : OtherSessionsAction() + object ReAuthCancelled : OtherSessionsAction() + + // Others data class FilterDevices(val filterType: DeviceManagerFilterType) : OtherSessionsAction() data class EnableSelectMode(val deviceId: String?) : OtherSessionsAction() object DisableSelectMode : OtherSessionsAction() data class ToggleSelectionForDevice(val deviceId: String) : OtherSessionsAction() object SelectAll : OtherSessionsAction() object DeselectAll : OtherSessionsAction() + object MultiSignout : OtherSessionsAction() + object ToggleIpAddressVisibility : OtherSessionsAction() } diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsActivity.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsActivity.kt index 81e38e0e9d..75c5752e35 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsActivity.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsActivity.kt @@ -20,7 +20,6 @@ import android.content.Context import android.content.Intent import android.os.Bundle import android.view.View -import androidx.annotation.StringRes import com.airbnb.mvrx.Mavericks import dagger.hilt.android.AndroidEntryPoint import im.vector.app.core.extensions.addFragment @@ -48,13 +47,11 @@ class OtherSessionsActivity : SimpleFragmentActivity() { companion object { fun newIntent( context: Context, - @StringRes - titleResourceId: Int, defaultFilter: DeviceManagerFilterType, excludeCurrentDevice: Boolean, ): Intent { return Intent(context, OtherSessionsActivity::class.java).apply { - putExtra(Mavericks.KEY_ARG, OtherSessionsArgs(titleResourceId, defaultFilter, excludeCurrentDevice)) + putExtra(Mavericks.KEY_ARG, OtherSessionsArgs(defaultFilter, excludeCurrentDevice)) } } } diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsArgs.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsArgs.kt index 61f89eaffa..e478959153 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsArgs.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsArgs.kt @@ -17,14 +17,11 @@ package im.vector.app.features.settings.devices.v2.othersessions import android.os.Parcelable -import androidx.annotation.StringRes import im.vector.app.features.settings.devices.v2.filter.DeviceManagerFilterType import kotlinx.parcelize.Parcelize @Parcelize data class OtherSessionsArgs( - @StringRes - val titleResourceId: Int, val defaultFilter: DeviceManagerFilterType, val excludeCurrentDevice: Boolean, ) : Parcelable diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsFragment.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsFragment.kt index 4f1c8353f5..cee90986cd 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsFragment.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsFragment.kt @@ -16,6 +16,7 @@ package im.vector.app.features.settings.devices.v2.othersessions +import android.app.Activity import android.os.Bundle import android.view.LayoutInflater import android.view.Menu @@ -32,6 +33,8 @@ import com.airbnb.mvrx.fragmentViewModel import com.airbnb.mvrx.withState import dagger.hilt.android.AndroidEntryPoint import im.vector.app.R +import im.vector.app.core.extensions.registerStartForActivityResult +import im.vector.app.core.extensions.setTextColor import im.vector.app.core.platform.VectorBaseBottomSheetDialogFragment import im.vector.app.core.platform.VectorBaseBottomSheetDialogFragment.ResultListener.Companion.RESULT_OK import im.vector.app.core.platform.VectorBaseFragment @@ -39,13 +42,16 @@ import im.vector.app.core.platform.VectorMenuProvider import im.vector.app.core.resources.ColorProvider import im.vector.app.core.resources.StringProvider import im.vector.app.databinding.FragmentOtherSessionsBinding +import im.vector.app.features.auth.ReAuthActivity import im.vector.app.features.settings.devices.v2.DeviceFullInfo import im.vector.app.features.settings.devices.v2.filter.DeviceManagerFilterBottomSheet import im.vector.app.features.settings.devices.v2.filter.DeviceManagerFilterType import im.vector.app.features.settings.devices.v2.list.OtherSessionsView import im.vector.app.features.settings.devices.v2.list.SESSION_IS_MARKED_AS_INACTIVE_AFTER_DAYS import im.vector.app.features.settings.devices.v2.more.SessionLearnMoreBottomSheet +import im.vector.app.features.settings.devices.v2.signout.BuildConfirmSignoutDialogUseCase import im.vector.app.features.themes.ThemeUtils +import org.matrix.android.sdk.api.auth.data.LoginFlowTypes import org.matrix.android.sdk.api.extensions.orFalse import javax.inject.Inject @@ -65,6 +71,8 @@ class OtherSessionsFragment : @Inject lateinit var viewNavigator: OtherSessionsViewNavigator + @Inject lateinit var buildConfirmSignoutDialogUseCase: BuildConfirmSignoutDialogUseCase + override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentOtherSessionsBinding { return FragmentOtherSessionsBinding.inflate(layoutInflater, container, false) } @@ -77,7 +85,37 @@ class OtherSessionsFragment : menu.findItem(R.id.otherSessionsSelectAll).isVisible = isSelectModeEnabled menu.findItem(R.id.otherSessionsDeselectAll).isVisible = isSelectModeEnabled menu.findItem(R.id.otherSessionsSelect).isVisible = !isSelectModeEnabled && state.devices()?.isNotEmpty().orFalse() + menu.findItem(R.id.otherSessionsToggleIpAddress).isVisible = !isSelectModeEnabled + menu.findItem(R.id.otherSessionsToggleIpAddress).title = if (state.isShowingIpAddress) { + getString(R.string.device_manager_other_sessions_hide_ip_address) + } else { + getString(R.string.device_manager_other_sessions_show_ip_address) + } + updateMultiSignoutMenuItem(menu, state) + } + } + + private fun updateMultiSignoutMenuItem(menu: Menu, viewState: OtherSessionsViewState) { + val multiSignoutItem = menu.findItem(R.id.otherSessionsMultiSignout) + multiSignoutItem.title = if (viewState.isSelectModeEnabled) { + getString(R.string.device_manager_other_sessions_multi_signout_selection).uppercase() + } else { + val nbDevices = viewState.devices()?.size ?: 0 + stringProvider.getQuantityString(R.plurals.device_manager_other_sessions_multi_signout_all, nbDevices, nbDevices) + } + multiSignoutItem.isVisible = if (viewState.isSelectModeEnabled) { + viewState.devices.invoke()?.any { it.isSelected }.orFalse() + } else { + viewState.devices.invoke()?.isNotEmpty().orFalse() } + val showAsActionFlag = if (viewState.isSelectModeEnabled) MenuItem.SHOW_AS_ACTION_IF_ROOM else MenuItem.SHOW_AS_ACTION_NEVER + multiSignoutItem.setShowAsAction(showAsActionFlag or MenuItem.SHOW_AS_ACTION_WITH_TEXT) + changeTextColorOfDestructiveAction(multiSignoutItem) + } + + private fun changeTextColorOfDestructiveAction(menuItem: MenuItem) { + val titleColor = colorProvider.getColorFromAttribute(R.attr.colorError) + menuItem.setTextColor(titleColor) } override fun handleMenuItemSelected(item: MenuItem): Boolean { @@ -94,10 +132,33 @@ class OtherSessionsFragment : viewModel.handle(OtherSessionsAction.DeselectAll) true } + R.id.otherSessionsMultiSignout -> { + confirmMultiSignout() + true + } + R.id.otherSessionsToggleIpAddress -> { + toggleIpAddressVisibility() + true + } else -> false } } + private fun toggleIpAddressVisibility() { + viewModel.handle(OtherSessionsAction.ToggleIpAddressVisibility) + } + + private fun confirmMultiSignout() { + activity?.let { + buildConfirmSignoutDialogUseCase.execute(it, this::multiSignout) + .show() + } + } + + private fun multiSignout() { + viewModel.handle(OtherSessionsAction.MultiSignout) + } + private fun enableSelectMode(isEnabled: Boolean, deviceId: String? = null) { val action = if (isEnabled) OtherSessionsAction.EnableSelectMode(deviceId) else OtherSessionsAction.DisableSelectMode viewModel.handle(action) @@ -121,7 +182,9 @@ class OtherSessionsFragment : override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) - setupToolbar(views.otherSessionsToolbar).setTitle(args.titleResourceId).allowBack() + setupToolbar(views.otherSessionsToolbar) + .setTitle(R.string.device_manager_sessions_other_title) + .allowBack() observeViewEvents() initFilterView() } @@ -129,8 +192,9 @@ class OtherSessionsFragment : private fun observeViewEvents() { viewModel.observeViewEvents { when (it) { - is OtherSessionsViewEvents.Loading -> showLoading(it.message) - is OtherSessionsViewEvents.Failure -> showFailure(it.throwable) + is OtherSessionsViewEvents.SignoutError -> showFailure(it.error) + is OtherSessionsViewEvents.RequestReAuth -> askForReAuthentication(it) + OtherSessionsViewEvents.SignoutSuccess -> enableSelectMode(false) } } } @@ -162,25 +226,39 @@ class OtherSessionsFragment : } override fun invalidate() = withState(viewModel) { state -> + updateLoading(state.isLoading) + updateFilterView(state.isSelectModeEnabled) if (state.devices is Success) { val devices = state.devices.invoke() - renderDevices(devices, state.currentFilter) + renderDevices(devices, state.currentFilter, state.isShowingIpAddress) updateToolbar(devices, state.isSelectModeEnabled) } } + private fun updateLoading(isLoading: Boolean) { + if (isLoading) { + showLoading(null) + } else { + dismissLoadingDialog() + } + } + + private fun updateFilterView(isSelectModeEnabled: Boolean) { + views.otherSessionsFilterFrameLayout.isVisible = isSelectModeEnabled.not() + } + private fun updateToolbar(devices: List, isSelectModeEnabled: Boolean) { invalidateOptionsMenu() val title = if (isSelectModeEnabled) { val selection = devices.count { it.isSelected } stringProvider.getQuantityString(R.plurals.x_selected, selection, selection) } else { - getString(args.titleResourceId) + getString(R.string.device_manager_sessions_other_title) } toolbar?.title = title } - private fun renderDevices(devices: List, currentFilter: DeviceManagerFilterType) { + private fun renderDevices(devices: List, currentFilter: DeviceManagerFilterType, isShowingIpAddress: Boolean) { views.otherSessionsFilterBadgeImageView.isVisible = currentFilter != DeviceManagerFilterType.ALL_SESSIONS views.otherSessionsSecurityRecommendationView.isVisible = currentFilter != DeviceManagerFilterType.ALL_SESSIONS views.deviceListHeaderOtherSessions.isVisible = currentFilter == DeviceManagerFilterType.ALL_SESSIONS @@ -196,7 +274,10 @@ class OtherSessionsFragment : ) ) views.otherSessionsNotFoundTextView.text = getString(R.string.device_manager_other_sessions_no_verified_sessions_found) - updateSecurityLearnMoreButton(R.string.device_manager_learn_more_sessions_verified_title, R.string.device_manager_learn_more_sessions_verified) + updateSecurityLearnMoreButton( + R.string.device_manager_learn_more_sessions_verified_title, + R.string.device_manager_learn_more_sessions_verified_description + ) } DeviceManagerFilterType.UNVERIFIED -> { views.otherSessionsSecurityRecommendationView.render( @@ -239,7 +320,8 @@ class OtherSessionsFragment : } else { views.deviceListOtherSessions.isVisible = true views.otherSessionsNotFoundLayout.isVisible = false - views.deviceListOtherSessions.render(devices = devices, totalNumberOfDevices = devices.size, showViewAll = false) + val mappedDevices = if (isShowingIpAddress) devices else devices.map { it.copy(deviceInfo = it.deviceInfo.copy(lastSeenIp = null)) } + views.deviceListOtherSessions.render(devices = mappedDevices, totalNumberOfDevices = mappedDevices.size, showViewAll = false) } } @@ -266,6 +348,8 @@ class OtherSessionsFragment : override fun onOtherSessionLongClicked(deviceId: String) = withState(viewModel) { state -> if (!state.isSelectModeEnabled) { enableSelectMode(true, deviceId) + } else { + viewModel.handle(OtherSessionsAction.ToggleSelectionForDevice(deviceId)) } } @@ -283,4 +367,37 @@ class OtherSessionsFragment : override fun onViewAllOtherSessionsClicked() { // NOOP. We don't have this button in this screen } + + private val reAuthActivityResultLauncher = registerStartForActivityResult { activityResult -> + if (activityResult.resultCode == Activity.RESULT_OK) { + when (activityResult.data?.extras?.getString(ReAuthActivity.RESULT_FLOW_TYPE)) { + LoginFlowTypes.SSO -> { + viewModel.handle(OtherSessionsAction.SsoAuthDone) + } + LoginFlowTypes.PASSWORD -> { + val password = activityResult.data?.extras?.getString(ReAuthActivity.RESULT_VALUE) ?: "" + viewModel.handle(OtherSessionsAction.PasswordAuthDone(password)) + } + else -> { + viewModel.handle(OtherSessionsAction.ReAuthCancelled) + } + } + } else { + viewModel.handle(OtherSessionsAction.ReAuthCancelled) + } + } + + /** + * Launch the re auth activity to get credentials. + */ + private fun askForReAuthentication(reAuthReq: OtherSessionsViewEvents.RequestReAuth) { + ReAuthActivity.newIntent( + requireContext(), + reAuthReq.registrationFlowResponse, + reAuthReq.lastErrorCode, + getString(R.string.devices_delete_dialog_title) + ).let { intent -> + reAuthActivityResultLauncher.launch(intent) + } + } } diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsViewEvents.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsViewEvents.kt index 95f9c72b33..55753e35be 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsViewEvents.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsViewEvents.kt @@ -17,8 +17,14 @@ package im.vector.app.features.settings.devices.v2.othersessions import im.vector.app.core.platform.VectorViewEvents +import org.matrix.android.sdk.api.auth.registration.RegistrationFlowResponse sealed class OtherSessionsViewEvents : VectorViewEvents { - data class Loading(val message: CharSequence? = null) : OtherSessionsViewEvents() - data class Failure(val throwable: Throwable) : OtherSessionsViewEvents() + data class RequestReAuth( + val registrationFlowResponse: RegistrationFlowResponse, + val lastErrorCode: String? + ) : OtherSessionsViewEvents() + + object SignoutSuccess : OtherSessionsViewEvents() + data class SignoutError(val error: Throwable) : OtherSessionsViewEvents() } diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsViewModel.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsViewModel.kt index 2cd0c6af66..a5282e7ba2 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsViewModel.kt @@ -16,6 +16,7 @@ package im.vector.app.features.settings.devices.v2.othersessions +import android.content.SharedPreferences import com.airbnb.mvrx.MavericksViewModelFactory import com.airbnb.mvrx.Success import dagger.assisted.Assisted @@ -24,20 +25,32 @@ import dagger.assisted.AssistedInject import im.vector.app.core.di.ActiveSessionHolder import im.vector.app.core.di.MavericksAssistedViewModelFactory import im.vector.app.core.di.hiltMavericksViewModelFactory +import im.vector.app.features.auth.PendingAuthHandler +import im.vector.app.features.settings.VectorPreferences import im.vector.app.features.settings.devices.v2.GetDeviceFullInfoListUseCase import im.vector.app.features.settings.devices.v2.RefreshDevicesUseCase +import im.vector.app.features.settings.devices.v2.ToggleIpAddressVisibilityUseCase import im.vector.app.features.settings.devices.v2.VectorSessionsListViewModel import im.vector.app.features.settings.devices.v2.filter.DeviceManagerFilterType +import im.vector.app.features.settings.devices.v2.signout.SignoutSessionsReAuthNeeded +import im.vector.app.features.settings.devices.v2.signout.SignoutSessionsUseCase import kotlinx.coroutines.Job +import kotlinx.coroutines.launch +import org.matrix.android.sdk.api.session.uia.DefaultBaseAuth +import timber.log.Timber class OtherSessionsViewModel @AssistedInject constructor( @Assisted private val initialState: OtherSessionsViewState, activeSessionHolder: ActiveSessionHolder, private val getDeviceFullInfoListUseCase: GetDeviceFullInfoListUseCase, - refreshDevicesUseCase: RefreshDevicesUseCase + private val signoutSessionsUseCase: SignoutSessionsUseCase, + private val pendingAuthHandler: PendingAuthHandler, + refreshDevicesUseCase: RefreshDevicesUseCase, + private val vectorPreferences: VectorPreferences, + private val toggleIpAddressVisibilityUseCase: ToggleIpAddressVisibilityUseCase, ) : VectorSessionsListViewModel( initialState, activeSessionHolder, refreshDevicesUseCase -) { +), SharedPreferences.OnSharedPreferenceChangeListener { @AssistedFactory interface Factory : MavericksAssistedViewModelFactory { @@ -50,6 +63,28 @@ class OtherSessionsViewModel @AssistedInject constructor( init { observeDevices(initialState.currentFilter) + refreshIpAddressVisibility() + observePreferences() + } + + override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { + refreshIpAddressVisibility() + } + + private fun observePreferences() { + vectorPreferences.subscribeToChanges(this) + } + + override fun onCleared() { + vectorPreferences.unsubscribeToChanges(this) + super.onCleared() + } + + private fun refreshIpAddressVisibility() { + val shouldShowIpAddress = vectorPreferences.showIpAddressInSessionManagerScreens() + setState { + copy(isShowingIpAddress = shouldShowIpAddress) + } } private fun observeDevices(currentFilter: DeviceManagerFilterType) { @@ -67,15 +102,24 @@ class OtherSessionsViewModel @AssistedInject constructor( override fun handle(action: OtherSessionsAction) { when (action) { + is OtherSessionsAction.PasswordAuthDone -> handlePasswordAuthDone(action) + OtherSessionsAction.ReAuthCancelled -> handleReAuthCancelled() + OtherSessionsAction.SsoAuthDone -> handleSsoAuthDone() is OtherSessionsAction.FilterDevices -> handleFilterDevices(action) OtherSessionsAction.DisableSelectMode -> handleDisableSelectMode() is OtherSessionsAction.EnableSelectMode -> handleEnableSelectMode(action.deviceId) is OtherSessionsAction.ToggleSelectionForDevice -> handleToggleSelectionForDevice(action.deviceId) OtherSessionsAction.DeselectAll -> handleDeselectAll() OtherSessionsAction.SelectAll -> handleSelectAll() + OtherSessionsAction.MultiSignout -> handleMultiSignout() + OtherSessionsAction.ToggleIpAddressVisibility -> handleToggleIpAddressVisibility() } } + private fun handleToggleIpAddressVisibility() { + toggleIpAddressVisibilityUseCase.execute() + } + private fun handleFilterDevices(action: OtherSessionsAction.FilterDevices) { setState { copy( @@ -142,4 +186,67 @@ class OtherSessionsViewModel @AssistedInject constructor( ) } } + + private fun handleMultiSignout() = withState { state -> + viewModelScope.launch { + setLoading(true) + val deviceIds = getDeviceIdsToSignout(state) + if (deviceIds.isEmpty()) { + return@launch + } + val result = signout(deviceIds) + setLoading(false) + + val error = result.exceptionOrNull() + if (error == null) { + onSignoutSuccess() + } else { + onSignoutFailure(error) + } + } + } + + private fun getDeviceIdsToSignout(state: OtherSessionsViewState): List { + return if (state.isSelectModeEnabled) { + state.devices()?.filter { it.isSelected }.orEmpty() + } else { + state.devices().orEmpty() + }.mapNotNull { it.deviceInfo.deviceId } + } + + private suspend fun signout(deviceIds: List) = signoutSessionsUseCase.execute(deviceIds, this::onReAuthNeeded) + + private fun onReAuthNeeded(reAuthNeeded: SignoutSessionsReAuthNeeded) { + Timber.d("onReAuthNeeded") + pendingAuthHandler.pendingAuth = DefaultBaseAuth(session = reAuthNeeded.flowResponse.session) + pendingAuthHandler.uiaContinuation = reAuthNeeded.uiaContinuation + _viewEvents.post(OtherSessionsViewEvents.RequestReAuth(reAuthNeeded.flowResponse, reAuthNeeded.errCode)) + } + + private fun setLoading(isLoading: Boolean) { + setState { copy(isLoading = isLoading) } + } + + private fun onSignoutSuccess() { + Timber.d("signout success") + refreshDeviceList() + _viewEvents.post(OtherSessionsViewEvents.SignoutSuccess) + } + + private fun onSignoutFailure(failure: Throwable) { + Timber.e("signout failure", failure) + _viewEvents.post(OtherSessionsViewEvents.SignoutError(failure)) + } + + private fun handleSsoAuthDone() { + pendingAuthHandler.ssoAuthDone() + } + + private fun handlePasswordAuthDone(action: OtherSessionsAction.PasswordAuthDone) { + pendingAuthHandler.passwordAuthDone(action.password) + } + + private fun handleReAuthCancelled() { + pendingAuthHandler.reAuthCancelled() + } } diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsViewState.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsViewState.kt index 0db3c8cd0e..f4dd3640ee 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsViewState.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsViewState.kt @@ -27,6 +27,8 @@ data class OtherSessionsViewState( val currentFilter: DeviceManagerFilterType = DeviceManagerFilterType.ALL_SESSIONS, val excludeCurrentDevice: Boolean = false, val isSelectModeEnabled: Boolean = false, + val isLoading: Boolean = false, + val isShowingIpAddress: Boolean = false, ) : MavericksState { constructor(args: OtherSessionsArgs) : this(excludeCurrentDevice = args.excludeCurrentDevice) diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewAction.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewAction.kt index 9a92d5b629..2b6c40eead 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewAction.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewAction.kt @@ -29,4 +29,5 @@ sealed class SessionOverviewAction : VectorViewModelAction { val deviceId: String, val enabled: Boolean, ) : SessionOverviewAction() + object ToggleIpAddressVisibility : SessionOverviewAction() } diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewFragment.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewFragment.kt index a1cd7ea586..399f99201b 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewFragment.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewFragment.kt @@ -19,16 +19,17 @@ package im.vector.app.features.settings.devices.v2.overview import android.app.Activity import android.os.Bundle import android.view.LayoutInflater +import android.view.Menu import android.view.MenuItem import android.view.View import android.view.ViewGroup import android.widget.Toast import androidx.appcompat.app.AppCompatActivity +import androidx.core.view.isGone import androidx.core.view.isVisible import com.airbnb.mvrx.Success import com.airbnb.mvrx.fragmentViewModel import com.airbnb.mvrx.withState -import com.google.android.material.dialog.MaterialAlertDialogBuilder import dagger.hilt.android.AndroidEntryPoint import im.vector.app.R import im.vector.app.core.date.VectorDateFormatter @@ -43,6 +44,8 @@ import im.vector.app.features.auth.ReAuthActivity import im.vector.app.features.crypto.recover.SetupMode import im.vector.app.features.settings.devices.v2.list.SessionInfoViewState import im.vector.app.features.settings.devices.v2.more.SessionLearnMoreBottomSheet +import im.vector.app.features.settings.devices.v2.notification.NotificationsStatus +import im.vector.app.features.settings.devices.v2.signout.BuildConfirmSignoutDialogUseCase import im.vector.app.features.workers.signout.SignOutUiWorker import org.matrix.android.sdk.api.auth.data.LoginFlowTypes import org.matrix.android.sdk.api.extensions.orFalse @@ -67,6 +70,8 @@ class SessionOverviewFragment : @Inject lateinit var stringProvider: StringProvider + @Inject lateinit var buildConfirmSignoutDialogUseCase: BuildConfirmSignoutDialogUseCase + private val viewModel: SessionOverviewViewModel by fragmentViewModel() override fun getBinding(inflater: LayoutInflater, container: ViewGroup?): FragmentSessionOverviewBinding { @@ -132,13 +137,7 @@ class SessionOverviewFragment : private fun confirmSignoutOtherSession() { activity?.let { - MaterialAlertDialogBuilder(it) - .setTitle(R.string.action_sign_out) - .setMessage(R.string.action_sign_out_confirmation_simple) - .setPositiveButton(R.string.action_sign_out) { _, _ -> - signoutSession() - } - .setNegativeButton(R.string.action_cancel, null) + buildConfirmSignoutDialogUseCase.execute(it, this::signoutSession) .show() } } @@ -158,16 +157,34 @@ class SessionOverviewFragment : override fun getMenuRes() = R.menu.menu_session_overview + override fun handlePrepareMenu(menu: Menu) { + withState(viewModel) { state -> + menu.findItem(R.id.sessionOverviewToggleIpAddress).title = if (state.isShowingIpAddress) { + getString(R.string.device_manager_other_sessions_hide_ip_address) + } else { + getString(R.string.device_manager_other_sessions_show_ip_address) + } + } + } + override fun handleMenuItemSelected(item: MenuItem): Boolean { return when (item.itemId) { R.id.sessionOverviewRename -> { goToRenameSession() true } + R.id.sessionOverviewToggleIpAddress -> { + toggleIpAddressVisibility() + true + } else -> false } } + private fun toggleIpAddressVisibility() { + viewModel.handle(SessionOverviewAction.ToggleIpAddressVisibility) + } + private fun goToRenameSession() = withState(viewModel) { state -> viewNavigator.goToRenameSession(requireContext(), state.deviceId) } @@ -177,7 +194,7 @@ class SessionOverviewFragment : updateEntryDetails(state.deviceId) updateSessionInfo(state) updateLoading(state.isLoading) - updatePushNotificationToggle(state.deviceId, state.notificationsEnabled) + updatePushNotificationToggle(state.deviceId, state.notificationsStatus) } private fun updateToolbar(viewState: SessionOverviewViewState) { @@ -207,26 +224,31 @@ class SessionOverviewFragment : isVerifyButtonVisible = isCurrentSession || viewState.isCurrentSessionTrusted, isDetailsButtonVisible = false, isLearnMoreLinkVisible = deviceInfo.roomEncryptionTrustLevel != RoomEncryptionTrustLevel.Default, - isLastSeenDetailsVisible = !isCurrentSession, + isLastActivityVisible = !isCurrentSession, + isShowingIpAddress = viewState.isShowingIpAddress, ) views.sessionOverviewInfo.render(infoViewState, dateFormatter, drawableProvider, colorProvider, stringProvider) views.sessionOverviewInfo.onLearnMoreClickListener = { - showLearnMoreInfoVerificationStatus(deviceInfo.roomEncryptionTrustLevel == RoomEncryptionTrustLevel.Trusted) + showLearnMoreInfoVerificationStatus(deviceInfo.roomEncryptionTrustLevel) } } else { views.sessionOverviewInfo.isVisible = false } } - private fun updatePushNotificationToggle(deviceId: String, enabled: Boolean) { - views.sessionOverviewPushNotifications.apply { - setOnCheckedChangeListener(null) - setChecked(enabled) - post { - setOnCheckedChangeListener { _, isChecked -> - viewModel.handle(SessionOverviewAction.TogglePushNotifications(deviceId, isChecked)) + private fun updatePushNotificationToggle(deviceId: String, notificationsStatus: NotificationsStatus) { + views.sessionOverviewPushNotifications.isGone = notificationsStatus == NotificationsStatus.NOT_SUPPORTED + when (notificationsStatus) { + NotificationsStatus.ENABLED, NotificationsStatus.DISABLED -> { + views.sessionOverviewPushNotifications.setOnCheckedChangeListener(null) + views.sessionOverviewPushNotifications.setChecked(notificationsStatus == NotificationsStatus.ENABLED) + views.sessionOverviewPushNotifications.post { + views.sessionOverviewPushNotifications.setOnCheckedChangeListener { _, isChecked -> + viewModel.handle(SessionOverviewAction.TogglePushNotifications(deviceId, isChecked)) + } } } + else -> Unit } } @@ -271,21 +293,28 @@ class SessionOverviewFragment : } } - private fun showLearnMoreInfoVerificationStatus(isVerified: Boolean) { - val titleResId = if (isVerified) { - R.string.device_manager_verification_status_verified - } else { - R.string.device_manager_verification_status_unverified - } - val descriptionResId = if (isVerified) { - R.string.device_manager_learn_more_sessions_verified - } else { - R.string.device_manager_learn_more_sessions_unverified + private fun showLearnMoreInfoVerificationStatus(roomEncryptionTrustLevel: RoomEncryptionTrustLevel?) { + val args = when (roomEncryptionTrustLevel) { + null -> { + // encryption not supported + SessionLearnMoreBottomSheet.Args( + title = getString(R.string.device_manager_verification_status_unverified), + description = getString(R.string.device_manager_learn_more_sessions_encryption_not_supported), + ) + } + RoomEncryptionTrustLevel.Trusted -> { + SessionLearnMoreBottomSheet.Args( + title = getString(R.string.device_manager_verification_status_verified), + description = getString(R.string.device_manager_learn_more_sessions_verified_description), + ) + } + else -> { + SessionLearnMoreBottomSheet.Args( + title = getString(R.string.device_manager_verification_status_unverified), + description = getString(R.string.device_manager_learn_more_sessions_unverified), + ) + } } - val args = SessionLearnMoreBottomSheet.Args( - title = getString(titleResId), - description = getString(descriptionResId), - ) SessionLearnMoreBottomSheet.show(childFragmentManager, args) } } diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewViewModel.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewViewModel.kt index 21054270f8..55866cb8c4 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewViewModel.kt @@ -16,59 +16,50 @@ package im.vector.app.features.settings.devices.v2.overview +import android.content.SharedPreferences import com.airbnb.mvrx.MavericksViewModelFactory import com.airbnb.mvrx.Success import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject -import im.vector.app.R import im.vector.app.core.di.ActiveSessionHolder import im.vector.app.core.di.MavericksAssistedViewModelFactory import im.vector.app.core.di.hiltMavericksViewModelFactory -import im.vector.app.core.resources.StringProvider import im.vector.app.features.auth.PendingAuthHandler +import im.vector.app.features.settings.VectorPreferences import im.vector.app.features.settings.devices.v2.RefreshDevicesUseCase +import im.vector.app.features.settings.devices.v2.ToggleIpAddressVisibilityUseCase import im.vector.app.features.settings.devices.v2.VectorSessionsListViewModel -import im.vector.app.features.settings.devices.v2.signout.InterceptSignoutFlowResponseUseCase -import im.vector.app.features.settings.devices.v2.signout.SignoutSessionResult -import im.vector.app.features.settings.devices.v2.signout.SignoutSessionUseCase +import im.vector.app.features.settings.devices.v2.notification.GetNotificationsStatusUseCase +import im.vector.app.features.settings.devices.v2.notification.ToggleNotificationsUseCase +import im.vector.app.features.settings.devices.v2.signout.SignoutSessionsReAuthNeeded +import im.vector.app.features.settings.devices.v2.signout.SignoutSessionsUseCase import im.vector.app.features.settings.devices.v2.verification.CheckIfCurrentSessionCanBeVerifiedUseCase import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch -import org.matrix.android.sdk.api.account.LocalNotificationSettingsContent -import org.matrix.android.sdk.api.auth.UIABaseAuth -import org.matrix.android.sdk.api.auth.UserInteractiveAuthInterceptor -import org.matrix.android.sdk.api.auth.registration.RegistrationFlowResponse import org.matrix.android.sdk.api.extensions.orFalse -import org.matrix.android.sdk.api.failure.Failure -import org.matrix.android.sdk.api.session.accountdata.UserAccountDataTypes.TYPE_LOCAL_NOTIFICATION_SETTINGS import org.matrix.android.sdk.api.session.crypto.model.RoomEncryptionTrustLevel -import org.matrix.android.sdk.api.session.events.model.toModel import org.matrix.android.sdk.api.session.uia.DefaultBaseAuth -import org.matrix.android.sdk.flow.flow -import org.matrix.android.sdk.flow.unwrap import timber.log.Timber -import javax.net.ssl.HttpsURLConnection -import kotlin.coroutines.Continuation class SessionOverviewViewModel @AssistedInject constructor( @Assisted val initialState: SessionOverviewViewState, - private val stringProvider: StringProvider, private val getDeviceFullInfoUseCase: GetDeviceFullInfoUseCase, private val checkIfCurrentSessionCanBeVerifiedUseCase: CheckIfCurrentSessionCanBeVerifiedUseCase, - private val signoutSessionUseCase: SignoutSessionUseCase, - private val interceptSignoutFlowResponseUseCase: InterceptSignoutFlowResponseUseCase, + private val signoutSessionsUseCase: SignoutSessionsUseCase, private val pendingAuthHandler: PendingAuthHandler, private val activeSessionHolder: ActiveSessionHolder, - private val togglePushNotificationUseCase: TogglePushNotificationUseCase, + private val toggleNotificationsUseCase: ToggleNotificationsUseCase, + private val getNotificationsStatusUseCase: GetNotificationsStatusUseCase, refreshDevicesUseCase: RefreshDevicesUseCase, + private val vectorPreferences: VectorPreferences, + private val toggleIpAddressVisibilityUseCase: ToggleIpAddressVisibilityUseCase, ) : VectorSessionsListViewModel( initialState, activeSessionHolder, refreshDevicesUseCase -) { +), SharedPreferences.OnSharedPreferenceChangeListener { companion object : MavericksViewModelFactory by hiltMavericksViewModelFactory() @@ -81,7 +72,28 @@ class SessionOverviewViewModel @AssistedInject constructor( refreshPushers() observeSessionInfo(initialState.deviceId) observeCurrentSessionInfo() - observePushers(initialState.deviceId) + observeNotificationsStatus(initialState.deviceId) + refreshIpAddressVisibility() + observePreferences() + } + + override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences?, key: String?) { + refreshIpAddressVisibility() + } + + private fun observePreferences() { + vectorPreferences.subscribeToChanges(this) + } + + override fun onCleared() { + vectorPreferences.unsubscribeToChanges(this) + super.onCleared() + } + private fun refreshIpAddressVisibility() { + val shouldShowIpAddress = vectorPreferences.showIpAddressInSessionManagerScreens() + setState { + copy(isShowingIpAddress = shouldShowIpAddress) + } } private fun refreshPushers() { @@ -107,21 +119,12 @@ class SessionOverviewViewModel @AssistedInject constructor( } } - private fun observePushers(deviceId: String) { - val session = activeSessionHolder.getSafeActiveSession() ?: return - val pusherFlow = session.flow() - .livePushers() - .map { it.filter { pusher -> pusher.deviceId == deviceId } } - .map { it.takeIf { it.isNotEmpty() }?.any { pusher -> pusher.enabled } } - - val accountDataFlow = session.flow() - .liveUserAccountData(TYPE_LOCAL_NOTIFICATION_SETTINGS + deviceId) - .unwrap() - .map { it.content.toModel()?.isSilenced?.not() } - - merge(pusherFlow, accountDataFlow) - .onEach { it?.let { setState { copy(notificationsEnabled = it) } } } - .launchIn(viewModelScope) + private fun observeNotificationsStatus(deviceId: String) { + activeSessionHolder.getSafeActiveSession()?.let { session -> + getNotificationsStatusUseCase.execute(session, deviceId) + .onEach { setState { copy(notificationsStatus = it) } } + .launchIn(viewModelScope) + } } override fun handle(action: SessionOverviewAction) { @@ -132,9 +135,14 @@ class SessionOverviewViewModel @AssistedInject constructor( is SessionOverviewAction.PasswordAuthDone -> handlePasswordAuthDone(action) SessionOverviewAction.ReAuthCancelled -> handleReAuthCancelled() is SessionOverviewAction.TogglePushNotifications -> handleTogglePusherAction(action) + SessionOverviewAction.ToggleIpAddressVisibility -> handleToggleIpAddressVisibility() } } + private fun handleToggleIpAddressVisibility() { + toggleIpAddressVisibilityUseCase.execute() + } + private fun handleVerifySessionAction() = withState { viewState -> if (viewState.deviceInfo.invoke()?.isCurrentDevice.orFalse()) { handleVerifyCurrentSession() @@ -168,30 +176,21 @@ class SessionOverviewViewModel @AssistedInject constructor( private fun handleSignoutOtherSession(deviceId: String) { viewModelScope.launch { setLoading(true) - val signoutResult = signout(deviceId) + val result = signout(deviceId) setLoading(false) - if (signoutResult.isSuccess) { + val error = result.exceptionOrNull() + if (error == null) { onSignoutSuccess() } else { - when (val failure = signoutResult.exceptionOrNull()) { - null -> onSignoutSuccess() - else -> onSignoutFailure(failure) - } + onSignoutFailure(error) } } } - private suspend fun signout(deviceId: String) = signoutSessionUseCase.execute(deviceId, object : UserInteractiveAuthInterceptor { - override fun performStage(flowResponse: RegistrationFlowResponse, errCode: String?, promise: Continuation) { - when (val result = interceptSignoutFlowResponseUseCase.execute(flowResponse, errCode, promise)) { - is SignoutSessionResult.ReAuthNeeded -> onReAuthNeeded(result) - is SignoutSessionResult.Completed -> Unit - } - } - }) + private suspend fun signout(deviceId: String) = signoutSessionsUseCase.execute(listOf(deviceId), this::onReAuthNeeded) - private fun onReAuthNeeded(reAuthNeeded: SignoutSessionResult.ReAuthNeeded) { + private fun onReAuthNeeded(reAuthNeeded: SignoutSessionsReAuthNeeded) { Timber.d("onReAuthNeeded") pendingAuthHandler.pendingAuth = DefaultBaseAuth(session = reAuthNeeded.flowResponse.session) pendingAuthHandler.uiaContinuation = reAuthNeeded.uiaContinuation @@ -210,12 +209,7 @@ class SessionOverviewViewModel @AssistedInject constructor( private fun onSignoutFailure(failure: Throwable) { Timber.e("signout failure", failure) - val failureMessage = if (failure is Failure.OtherServerError && failure.httpCode == HttpsURLConnection.HTTP_UNAUTHORIZED) { - stringProvider.getString(R.string.authentication_error) - } else { - stringProvider.getString(R.string.matrix_error) - } - _viewEvents.post(SessionOverviewViewEvent.SignoutError(Exception(failureMessage))) + _viewEvents.post(SessionOverviewViewEvent.SignoutError(failure)) } private fun handleSsoAuthDone() { @@ -232,8 +226,7 @@ class SessionOverviewViewModel @AssistedInject constructor( private fun handleTogglePusherAction(action: SessionOverviewAction.TogglePushNotifications) { viewModelScope.launch { - togglePushNotificationUseCase.execute(action.deviceId, action.enabled) - setState { copy(notificationsEnabled = action.enabled) } + toggleNotificationsUseCase.execute(action.deviceId, action.enabled) } } } diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewViewState.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewViewState.kt index 440805bad6..0f66605f98 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewViewState.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewViewState.kt @@ -20,13 +20,15 @@ import com.airbnb.mvrx.Async import com.airbnb.mvrx.MavericksState import com.airbnb.mvrx.Uninitialized import im.vector.app.features.settings.devices.v2.DeviceFullInfo +import im.vector.app.features.settings.devices.v2.notification.NotificationsStatus data class SessionOverviewViewState( val deviceId: String, val isCurrentSessionTrusted: Boolean = false, val deviceInfo: Async = Uninitialized, val isLoading: Boolean = false, - val notificationsEnabled: Boolean = false, + val notificationsStatus: NotificationsStatus = NotificationsStatus.NOT_SUPPORTED, + val isShowingIpAddress: Boolean = false, ) : MavericksState { constructor(args: SessionOverviewArgs) : this( deviceId = args.deviceId diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/rename/RenameSessionFragment.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/rename/RenameSessionFragment.kt index 2f671492e3..d2cbbbdee5 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/rename/RenameSessionFragment.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/rename/RenameSessionFragment.kt @@ -20,6 +20,7 @@ import android.os.Bundle import android.view.LayoutInflater import android.view.View import android.view.ViewGroup +import android.view.ViewTreeObserver import androidx.core.widget.doOnTextChanged import com.airbnb.mvrx.fragmentViewModel import com.airbnb.mvrx.withState @@ -62,12 +63,24 @@ class RenameSessionFragment : } private fun initEditText() { - views.renameSessionEditText.showKeyboard(andRequestFocus = true) + showKeyboard() views.renameSessionEditText.doOnTextChanged { text, _, _, _ -> viewModel.handle(RenameSessionAction.EditLocally(text.toString())) } } + private fun showKeyboard() { + val focusChangeListener = object : ViewTreeObserver.OnWindowFocusChangeListener { + override fun onWindowFocusChanged(hasFocus: Boolean) { + if (hasFocus) { + views.renameSessionEditText.showKeyboard(andRequestFocus = true) + } + views.renameSessionEditText.viewTreeObserver.removeOnWindowFocusChangeListener(this) + } + } + views.renameSessionEditText.viewTreeObserver.addOnWindowFocusChangeListener(focusChangeListener) + } + private fun initSaveButton() { views.renameSessionSave.debouncedClicks { viewModel.handle(RenameSessionAction.SaveModifications) @@ -89,7 +102,9 @@ class RenameSessionFragment : title = getString(R.string.device_manager_learn_more_session_rename_title), description = getString(R.string.device_manager_learn_more_session_rename), ) - SessionLearnMoreBottomSheet.show(childFragmentManager, args) + SessionLearnMoreBottomSheet + .show(childFragmentManager, args) + .onDismiss = { showKeyboard() } } private fun observeViewEvents() { diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/signout/BuildConfirmSignoutDialogUseCase.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/signout/BuildConfirmSignoutDialogUseCase.kt new file mode 100644 index 0000000000..4edfc2febe --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/signout/BuildConfirmSignoutDialogUseCase.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2022 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 im.vector.app.features.settings.devices.v2.signout + +import android.content.Context +import com.google.android.material.dialog.MaterialAlertDialogBuilder +import im.vector.app.R +import javax.inject.Inject + +class BuildConfirmSignoutDialogUseCase @Inject constructor() { + + fun execute(context: Context, onConfirm: () -> Unit) = + MaterialAlertDialogBuilder(context) + .setTitle(R.string.action_sign_out) + .setMessage(R.string.action_sign_out_confirmation_simple) + .setPositiveButton(R.string.action_sign_out) { _, _ -> + onConfirm() + } + .setNegativeButton(R.string.action_cancel, null) + .create() +} diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/signout/InterceptSignoutFlowResponseUseCase.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/signout/InterceptSignoutFlowResponseUseCase.kt index 4316995272..42ebd7782e 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/signout/InterceptSignoutFlowResponseUseCase.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/signout/InterceptSignoutFlowResponseUseCase.kt @@ -37,17 +37,16 @@ class InterceptSignoutFlowResponseUseCase @Inject constructor( flowResponse: RegistrationFlowResponse, errCode: String?, promise: Continuation - ): SignoutSessionResult { + ): SignoutSessionsReAuthNeeded? { return if (flowResponse.nextUncompletedStage() == LoginFlowTypes.PASSWORD && reAuthHelper.data != null && errCode == null) { UserPasswordAuth( session = null, user = activeSessionHolder.getActiveSession().myUserId, password = reAuthHelper.data ).let { promise.resume(it) } - - SignoutSessionResult.Completed + null } else { - SignoutSessionResult.ReAuthNeeded( + SignoutSessionsReAuthNeeded( pendingAuth = DefaultBaseAuth(session = flowResponse.session), uiaContinuation = promise, flowResponse = flowResponse, diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/signout/SignoutSessionUseCase.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/signout/SignoutSessionUseCase.kt deleted file mode 100644 index 60ca8e91c6..0000000000 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/signout/SignoutSessionUseCase.kt +++ /dev/null @@ -1,39 +0,0 @@ -/* - * Copyright (c) 2022 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 im.vector.app.features.settings.devices.v2.signout - -import im.vector.app.core.di.ActiveSessionHolder -import org.matrix.android.sdk.api.auth.UserInteractiveAuthInterceptor -import org.matrix.android.sdk.api.util.awaitCallback -import javax.inject.Inject - -class SignoutSessionUseCase @Inject constructor( - private val activeSessionHolder: ActiveSessionHolder, -) { - - suspend fun execute(deviceId: String, userInteractiveAuthInterceptor: UserInteractiveAuthInterceptor): Result { - return deleteDevice(deviceId, userInteractiveAuthInterceptor) - } - - private suspend fun deleteDevice(deviceId: String, userInteractiveAuthInterceptor: UserInteractiveAuthInterceptor) = runCatching { - awaitCallback { matrixCallback -> - activeSessionHolder.getActiveSession() - .cryptoService() - .deleteDevice(deviceId, userInteractiveAuthInterceptor, matrixCallback) - } - } -} diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/signout/SignoutSessionResult.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/signout/SignoutSessionsReAuthNeeded.kt similarity index 71% rename from vector/src/main/java/im/vector/app/features/settings/devices/v2/signout/SignoutSessionResult.kt rename to vector/src/main/java/im/vector/app/features/settings/devices/v2/signout/SignoutSessionsReAuthNeeded.kt index fa1fb31b66..56e3d17686 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/signout/SignoutSessionResult.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/signout/SignoutSessionsReAuthNeeded.kt @@ -20,13 +20,9 @@ import org.matrix.android.sdk.api.auth.UIABaseAuth import org.matrix.android.sdk.api.auth.registration.RegistrationFlowResponse import kotlin.coroutines.Continuation -sealed class SignoutSessionResult { - data class ReAuthNeeded( - val pendingAuth: UIABaseAuth, - val uiaContinuation: Continuation, - val flowResponse: RegistrationFlowResponse, - val errCode: String? - ) : SignoutSessionResult() - - object Completed : SignoutSessionResult() -} +data class SignoutSessionsReAuthNeeded( + val pendingAuth: UIABaseAuth, + val uiaContinuation: Continuation, + val flowResponse: RegistrationFlowResponse, + val errCode: String? +) diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/signout/SignoutSessionsUseCase.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/signout/SignoutSessionsUseCase.kt new file mode 100644 index 0000000000..1cf713a711 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/signout/SignoutSessionsUseCase.kt @@ -0,0 +1,57 @@ +/* + * Copyright (c) 2022 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 im.vector.app.features.settings.devices.v2.signout + +import androidx.annotation.Size +import im.vector.app.core.di.ActiveSessionHolder +import org.matrix.android.sdk.api.auth.UIABaseAuth +import org.matrix.android.sdk.api.auth.UserInteractiveAuthInterceptor +import org.matrix.android.sdk.api.auth.registration.RegistrationFlowResponse +import org.matrix.android.sdk.api.util.awaitCallback +import timber.log.Timber +import javax.inject.Inject +import kotlin.coroutines.Continuation + +class SignoutSessionsUseCase @Inject constructor( + private val activeSessionHolder: ActiveSessionHolder, + private val interceptSignoutFlowResponseUseCase: InterceptSignoutFlowResponseUseCase, +) { + + suspend fun execute( + @Size(min = 1) deviceIds: List, + onReAuthNeeded: (SignoutSessionsReAuthNeeded) -> Unit, + ): Result = runCatching { + Timber.d("start execute with ${deviceIds.size} deviceIds") + + val authInterceptor = object : UserInteractiveAuthInterceptor { + override fun performStage(flowResponse: RegistrationFlowResponse, errCode: String?, promise: Continuation) { + val result = interceptSignoutFlowResponseUseCase.execute(flowResponse, errCode, promise) + result?.let(onReAuthNeeded) + } + } + + deleteDevices(deviceIds, authInterceptor) + Timber.d("end execute") + } + + private suspend fun deleteDevices(deviceIds: List, userInteractiveAuthInterceptor: UserInteractiveAuthInterceptor) = + awaitCallback { matrixCallback -> + activeSessionHolder.getActiveSession() + .cryptoService() + .deleteDevices(deviceIds, userInteractiveAuthInterceptor, matrixCallback) + } +} diff --git a/vector/src/main/java/im/vector/app/features/settings/devices/v2/verification/GetEncryptionTrustLevelForDeviceUseCase.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/verification/GetEncryptionTrustLevelForDeviceUseCase.kt index ba9a380ade..268ae86601 100644 --- a/vector/src/main/java/im/vector/app/features/settings/devices/v2/verification/GetEncryptionTrustLevelForDeviceUseCase.kt +++ b/vector/src/main/java/im/vector/app/features/settings/devices/v2/verification/GetEncryptionTrustLevelForDeviceUseCase.kt @@ -25,11 +25,15 @@ class GetEncryptionTrustLevelForDeviceUseCase @Inject constructor( private val getEncryptionTrustLevelForOtherDeviceUseCase: GetEncryptionTrustLevelForOtherDeviceUseCase, ) { - fun execute(currentSessionCrossSigningInfo: CurrentSessionCrossSigningInfo, cryptoDeviceInfo: CryptoDeviceInfo?): RoomEncryptionTrustLevel { + fun execute(currentSessionCrossSigningInfo: CurrentSessionCrossSigningInfo, cryptoDeviceInfo: CryptoDeviceInfo?): RoomEncryptionTrustLevel? { + if (cryptoDeviceInfo == null) { + return null + } + val legacyMode = !currentSessionCrossSigningInfo.isCrossSigningInitialized val trustMSK = currentSessionCrossSigningInfo.isCrossSigningVerified - val isCurrentDevice = !cryptoDeviceInfo?.deviceId.isNullOrEmpty() && cryptoDeviceInfo?.deviceId == currentSessionCrossSigningInfo.deviceId - val deviceTrustLevel = cryptoDeviceInfo?.trustLevel + val isCurrentDevice = !cryptoDeviceInfo.deviceId.isNullOrEmpty() && cryptoDeviceInfo.deviceId == currentSessionCrossSigningInfo.deviceId + val deviceTrustLevel = cryptoDeviceInfo.trustLevel return when { isCurrentDevice -> getEncryptionTrustLevelForCurrentDeviceUseCase.execute(trustMSK, legacyMode) diff --git a/vector/src/main/java/im/vector/app/features/settings/labs/VectorSettingsLabsFragment.kt b/vector/src/main/java/im/vector/app/features/settings/labs/VectorSettingsLabsFragment.kt index c10411301f..189d55d990 100644 --- a/vector/src/main/java/im/vector/app/features/settings/labs/VectorSettingsLabsFragment.kt +++ b/vector/src/main/java/im/vector/app/features/settings/labs/VectorSettingsLabsFragment.kt @@ -141,6 +141,7 @@ class VectorSettingsLabsFragment : */ private fun onThreadsPreferenceClicked() { // We should migrate threads only if threads are disabled + vectorPreferences.setThreadFlagChangedManually() vectorPreferences.setShouldMigrateThreads(!vectorPreferences.areThreadMessagesEnabled()) lightweightSettingsStorage.setThreadMessagesEnabled(vectorPreferences.areThreadMessagesEnabled()) displayLoadingView() diff --git a/vector/src/main/java/im/vector/app/features/settings/notifications/DisableNotificationsForCurrentSessionUseCase.kt b/vector/src/main/java/im/vector/app/features/settings/notifications/DisableNotificationsForCurrentSessionUseCase.kt new file mode 100644 index 0000000000..0c50a296f3 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/settings/notifications/DisableNotificationsForCurrentSessionUseCase.kt @@ -0,0 +1,33 @@ +/* + * Copyright (c) 2022 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 im.vector.app.features.settings.notifications + +import im.vector.app.core.pushers.PushersManager +import im.vector.app.core.pushers.UnregisterUnifiedPushUseCase +import javax.inject.Inject + +class DisableNotificationsForCurrentSessionUseCase @Inject constructor( + private val pushersManager: PushersManager, + private val toggleNotificationsForCurrentSessionUseCase: ToggleNotificationsForCurrentSessionUseCase, + private val unregisterUnifiedPushUseCase: UnregisterUnifiedPushUseCase, +) { + + suspend fun execute() { + toggleNotificationsForCurrentSessionUseCase.execute(enabled = false) + unregisterUnifiedPushUseCase.execute(pushersManager) + } +} diff --git a/vector/src/main/java/im/vector/app/features/settings/notifications/EnableNotificationsForCurrentSessionUseCase.kt b/vector/src/main/java/im/vector/app/features/settings/notifications/EnableNotificationsForCurrentSessionUseCase.kt new file mode 100644 index 0000000000..daf3890e33 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/settings/notifications/EnableNotificationsForCurrentSessionUseCase.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2022 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 im.vector.app.features.settings.notifications + +import im.vector.app.core.pushers.EnsureFcmTokenIsRetrievedUseCase +import im.vector.app.core.pushers.PushersManager +import im.vector.app.core.pushers.RegisterUnifiedPushUseCase +import javax.inject.Inject + +class EnableNotificationsForCurrentSessionUseCase @Inject constructor( + private val pushersManager: PushersManager, + private val toggleNotificationsForCurrentSessionUseCase: ToggleNotificationsForCurrentSessionUseCase, + private val registerUnifiedPushUseCase: RegisterUnifiedPushUseCase, + private val ensureFcmTokenIsRetrievedUseCase: EnsureFcmTokenIsRetrievedUseCase, +) { + + sealed interface EnableNotificationsResult { + object Success : EnableNotificationsResult + object NeedToAskUserForDistributor : EnableNotificationsResult + } + + suspend fun execute(distributor: String = ""): EnableNotificationsResult { + val pusherForCurrentSession = pushersManager.getPusherForCurrentSession() + if (pusherForCurrentSession == null) { + when (registerUnifiedPushUseCase.execute(distributor)) { + is RegisterUnifiedPushUseCase.RegisterUnifiedPushResult.NeedToAskUserForDistributor -> { + return EnableNotificationsResult.NeedToAskUserForDistributor + } + RegisterUnifiedPushUseCase.RegisterUnifiedPushResult.Success -> { + ensureFcmTokenIsRetrievedUseCase.execute(pushersManager, registerPusher = true) + } + } + } + + toggleNotificationsForCurrentSessionUseCase.execute(enabled = true) + + return EnableNotificationsResult.Success + } +} diff --git a/vector/src/main/java/im/vector/app/features/settings/notifications/ToggleNotificationsForCurrentSessionUseCase.kt b/vector/src/main/java/im/vector/app/features/settings/notifications/ToggleNotificationsForCurrentSessionUseCase.kt new file mode 100644 index 0000000000..3dc73f0a31 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/settings/notifications/ToggleNotificationsForCurrentSessionUseCase.kt @@ -0,0 +1,56 @@ +/* + * Copyright (c) 2022 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 im.vector.app.features.settings.notifications + +import im.vector.app.core.di.ActiveSessionHolder +import im.vector.app.core.pushers.UnifiedPushHelper +import im.vector.app.features.settings.devices.v2.notification.CheckIfCanToggleNotificationsViaPusherUseCase +import im.vector.app.features.settings.devices.v2.notification.DeleteNotificationSettingsAccountDataUseCase +import im.vector.app.features.settings.devices.v2.notification.SetNotificationSettingsAccountDataUseCase +import org.matrix.android.sdk.api.account.LocalNotificationSettingsContent +import timber.log.Timber +import javax.inject.Inject + +class ToggleNotificationsForCurrentSessionUseCase @Inject constructor( + private val activeSessionHolder: ActiveSessionHolder, + private val unifiedPushHelper: UnifiedPushHelper, + private val checkIfCanToggleNotificationsViaPusherUseCase: CheckIfCanToggleNotificationsViaPusherUseCase, + private val setNotificationSettingsAccountDataUseCase: SetNotificationSettingsAccountDataUseCase, + private val deleteNotificationSettingsAccountDataUseCase: DeleteNotificationSettingsAccountDataUseCase, +) { + + suspend fun execute(enabled: Boolean) { + val session = activeSessionHolder.getSafeActiveSession() ?: return + val deviceId = session.sessionParams.deviceId ?: return + + if (unifiedPushHelper.isBackgroundSync()) { + Timber.d("background sync is enabled, setting account data event") + val newNotificationSettingsContent = LocalNotificationSettingsContent(isSilenced = !enabled) + setNotificationSettingsAccountDataUseCase.execute(session, deviceId, newNotificationSettingsContent) + } else { + Timber.d("push notif is enabled, deleting any account data and updating pusher") + deleteNotificationSettingsAccountDataUseCase.execute(session) + + if (checkIfCanToggleNotificationsViaPusherUseCase.execute(session)) { + val devicePusher = session.pushersService().getPushers().firstOrNull { it.deviceId == deviceId } + devicePusher?.let { pusher -> + session.pushersService().togglePusher(pusher, enabled) + } + } + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/settings/notifications/VectorSettingsNotificationPreferenceFragment.kt b/vector/src/main/java/im/vector/app/features/settings/notifications/VectorSettingsNotificationPreferenceFragment.kt index faf7c7cff9..004f09a1b8 100644 --- a/vector/src/main/java/im/vector/app/features/settings/notifications/VectorSettingsNotificationPreferenceFragment.kt +++ b/vector/src/main/java/im/vector/app/features/settings/notifications/VectorSettingsNotificationPreferenceFragment.kt @@ -22,6 +22,7 @@ import android.content.Intent import android.media.RingtoneManager import android.net.Uri import android.os.Bundle +import android.view.View import android.widget.Toast import androidx.lifecycle.LiveData import androidx.lifecycle.distinctUntilChanged @@ -29,6 +30,7 @@ import androidx.lifecycle.lifecycleScope import androidx.lifecycle.map import androidx.preference.Preference import androidx.preference.SwitchPreference +import com.airbnb.mvrx.fragmentViewModel import dagger.hilt.android.AndroidEntryPoint import im.vector.app.R import im.vector.app.core.di.ActiveSessionHolder @@ -37,6 +39,7 @@ import im.vector.app.core.preference.VectorEditTextPreference import im.vector.app.core.preference.VectorPreference import im.vector.app.core.preference.VectorPreferenceCategory import im.vector.app.core.preference.VectorSwitchPreference +import im.vector.app.core.pushers.EnsureFcmTokenIsRetrievedUseCase import im.vector.app.core.pushers.FcmHelper import im.vector.app.core.pushers.PushersManager import im.vector.app.core.pushers.UnifiedPushHelper @@ -57,7 +60,6 @@ import im.vector.app.features.settings.VectorSettingsBaseFragment import im.vector.app.features.settings.VectorSettingsFragmentInteractionListener import im.vector.lib.core.utils.compat.getParcelableExtraCompat import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import org.matrix.android.sdk.api.extensions.tryOrNull import org.matrix.android.sdk.api.session.Session @@ -81,12 +83,15 @@ class VectorSettingsNotificationPreferenceFragment : @Inject lateinit var guardServiceStarter: GuardServiceStarter @Inject lateinit var vectorFeatures: VectorFeatures @Inject lateinit var notificationPermissionManager: NotificationPermissionManager + @Inject lateinit var ensureFcmTokenIsRetrievedUseCase: EnsureFcmTokenIsRetrievedUseCase override var titleRes: Int = R.string.settings_notifications override val preferenceXmlRes = R.xml.vector_settings_notifications private var interactionListener: VectorSettingsFragmentInteractionListener? = null + private val viewModel: VectorSettingsNotificationPreferenceViewModel by fragmentViewModel() + private val notificationStartForActivityResult = registerStartForActivityResult { _ -> // No op } @@ -103,6 +108,22 @@ class VectorSettingsNotificationPreferenceFragment : analyticsScreenName = MobileScreen.ScreenName.SettingsNotifications } + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { + super.onViewCreated(view, savedInstanceState) + observeViewEvents() + } + + private fun observeViewEvents() { + viewModel.observeViewEvents { + when (it) { + VectorSettingsNotificationPreferenceViewEvent.NotificationsForDeviceEnabled -> onNotificationsForDeviceEnabled() + VectorSettingsNotificationPreferenceViewEvent.NotificationsForDeviceDisabled -> onNotificationsForDeviceDisabled() + is VectorSettingsNotificationPreferenceViewEvent.AskUserForPushDistributor -> askUserToSelectPushDistributor() + VectorSettingsNotificationPreferenceViewEvent.NotificationMethodChanged -> onNotificationMethodChanged() + } + } + } + override fun bindPref() { findPreference(VectorPreferences.SETTINGS_ENABLE_ALL_NOTIF_PREFERENCE_KEY)!!.let { pref -> val pushRuleService = session.pushRuleService() @@ -119,48 +140,17 @@ class VectorSettingsNotificationPreferenceFragment : (pref as SwitchPreference).isChecked = areNotifEnabledAtAccountLevel } - findPreference(VectorPreferences.SETTINGS_ENABLE_THIS_DEVICE_PREFERENCE_KEY)?.let { - pushersManager.getPusherForCurrentSession()?.let { pusher -> - it.isChecked = pusher.enabled - } - - it.setTransactionalSwitchChangeListener(lifecycleScope) { isChecked -> - if (isChecked) { - unifiedPushHelper.register(requireActivity()) { - // Update the summary - if (unifiedPushHelper.isEmbeddedDistributor()) { - fcmHelper.ensureFcmTokenIsRetrieved( - requireActivity(), - pushersManager, - vectorPreferences.areNotificationEnabledForDevice() - ) - } - findPreference(VectorPreferences.SETTINGS_NOTIFICATION_METHOD_KEY) - ?.summary = unifiedPushHelper.getCurrentDistributorName() - lifecycleScope.launch { - val result = runCatching { - pushersManager.togglePusherForCurrentSession(true) - } - - result.exceptionOrNull()?.let { _ -> - Toast.makeText(context, R.string.error_check_network, Toast.LENGTH_SHORT).show() - it.isChecked = false - } - } + findPreference(VectorPreferences.SETTINGS_ENABLE_THIS_DEVICE_PREFERENCE_KEY) + ?.setOnPreferenceChangeListener { _, isChecked -> + val action = if (isChecked as Boolean) { + VectorSettingsNotificationPreferenceViewAction.EnableNotificationsForDevice(pushDistributor = "") + } else { + VectorSettingsNotificationPreferenceViewAction.DisableNotificationsForDevice } - notificationPermissionManager.eventuallyRequestPermission( - requireActivity(), - postPermissionLauncher, - showRationale = false, - ignorePreference = true - ) - } else { - unifiedPushHelper.unregister(pushersManager) - session.pushersService().refreshPushers() - notificationPermissionManager.eventuallyRevokePermission(requireActivity()) + viewModel.handle(action) + // preference will be updated on ViewEvent reception + false } - } - } findPreference(VectorPreferences.SETTINGS_FDROID_BACKGROUND_SYNC_MODE)?.let { it.onPreferenceClickListener = Preference.OnPreferenceClickListener { @@ -204,18 +194,7 @@ class VectorSettingsNotificationPreferenceFragment : if (vectorFeatures.allowExternalUnifiedPushDistributors()) { it.summary = unifiedPushHelper.getCurrentDistributorName() it.onPreferenceClickListener = Preference.OnPreferenceClickListener { - unifiedPushHelper.forceRegister(requireActivity(), pushersManager) { - if (unifiedPushHelper.isEmbeddedDistributor()) { - fcmHelper.ensureFcmTokenIsRetrieved( - requireActivity(), - pushersManager, - vectorPreferences.areNotificationEnabledForDevice() - ) - } - it.summary = unifiedPushHelper.getCurrentDistributorName() - session.pushersService().refreshPushers() - refreshBackgroundSyncPrefs() - } + askUserToSelectPushDistributor(withUnregister = true) true } } else { @@ -229,6 +208,42 @@ class VectorSettingsNotificationPreferenceFragment : handleSystemPreference() } + private fun onNotificationsForDeviceEnabled() { + findPreference(VectorPreferences.SETTINGS_ENABLE_THIS_DEVICE_PREFERENCE_KEY) + ?.isChecked = true + findPreference(VectorPreferences.SETTINGS_NOTIFICATION_METHOD_KEY) + ?.summary = unifiedPushHelper.getCurrentDistributorName() + + notificationPermissionManager.eventuallyRequestPermission( + requireActivity(), + postPermissionLauncher, + showRationale = false, + ignorePreference = true + ) + } + + private fun onNotificationsForDeviceDisabled() { + findPreference(VectorPreferences.SETTINGS_ENABLE_THIS_DEVICE_PREFERENCE_KEY) + ?.isChecked = false + notificationPermissionManager.eventuallyRevokePermission(requireActivity()) + } + + private fun askUserToSelectPushDistributor(withUnregister: Boolean = false) { + unifiedPushHelper.showSelectDistributorDialog(requireContext()) { selection -> + if (withUnregister) { + viewModel.handle(VectorSettingsNotificationPreferenceViewAction.RegisterPushDistributor(selection)) + } else { + viewModel.handle(VectorSettingsNotificationPreferenceViewAction.EnableNotificationsForDevice(selection)) + } + } + } + + private fun onNotificationMethodChanged() { + findPreference(VectorPreferences.SETTINGS_NOTIFICATION_METHOD_KEY)?.summary = unifiedPushHelper.getCurrentDistributorName() + session.pushersService().refreshPushers() + refreshBackgroundSyncPrefs() + } + private fun bindEmailNotifications() { val initialEmails = session.getEmailsWithPushInformation() bindEmailNotificationCategory(initialEmails) diff --git a/vector/src/main/java/im/vector/app/features/settings/notifications/VectorSettingsNotificationPreferenceViewAction.kt b/vector/src/main/java/im/vector/app/features/settings/notifications/VectorSettingsNotificationPreferenceViewAction.kt new file mode 100644 index 0000000000..949dc99993 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/settings/notifications/VectorSettingsNotificationPreferenceViewAction.kt @@ -0,0 +1,25 @@ +/* + * Copyright (c) 2022 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 im.vector.app.features.settings.notifications + +import im.vector.app.core.platform.VectorViewModelAction + +sealed interface VectorSettingsNotificationPreferenceViewAction : VectorViewModelAction { + data class EnableNotificationsForDevice(val pushDistributor: String) : VectorSettingsNotificationPreferenceViewAction + object DisableNotificationsForDevice : VectorSettingsNotificationPreferenceViewAction + data class RegisterPushDistributor(val pushDistributor: String) : VectorSettingsNotificationPreferenceViewAction +} diff --git a/vector/src/main/java/im/vector/app/features/settings/notifications/VectorSettingsNotificationPreferenceViewEvent.kt b/vector/src/main/java/im/vector/app/features/settings/notifications/VectorSettingsNotificationPreferenceViewEvent.kt new file mode 100644 index 0000000000..b0ee107769 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/settings/notifications/VectorSettingsNotificationPreferenceViewEvent.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2022 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 im.vector.app.features.settings.notifications + +import im.vector.app.core.platform.VectorViewEvents + +sealed interface VectorSettingsNotificationPreferenceViewEvent : VectorViewEvents { + object NotificationsForDeviceEnabled : VectorSettingsNotificationPreferenceViewEvent + object NotificationsForDeviceDisabled : VectorSettingsNotificationPreferenceViewEvent + object AskUserForPushDistributor : VectorSettingsNotificationPreferenceViewEvent + object NotificationMethodChanged : VectorSettingsNotificationPreferenceViewEvent +} diff --git a/vector/src/main/java/im/vector/app/features/settings/notifications/VectorSettingsNotificationPreferenceViewModel.kt b/vector/src/main/java/im/vector/app/features/settings/notifications/VectorSettingsNotificationPreferenceViewModel.kt new file mode 100644 index 0000000000..9530be599e --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/settings/notifications/VectorSettingsNotificationPreferenceViewModel.kt @@ -0,0 +1,124 @@ +/* + * Copyright (c) 2022 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 im.vector.app.features.settings.notifications + +import android.content.SharedPreferences +import androidx.annotation.VisibleForTesting +import com.airbnb.mvrx.MavericksViewModelFactory +import dagger.assisted.Assisted +import dagger.assisted.AssistedFactory +import dagger.assisted.AssistedInject +import im.vector.app.core.di.MavericksAssistedViewModelFactory +import im.vector.app.core.di.hiltMavericksViewModelFactory +import im.vector.app.core.platform.VectorDummyViewState +import im.vector.app.core.platform.VectorViewModel +import im.vector.app.core.pushers.EnsureFcmTokenIsRetrievedUseCase +import im.vector.app.core.pushers.PushersManager +import im.vector.app.core.pushers.RegisterUnifiedPushUseCase +import im.vector.app.core.pushers.UnregisterUnifiedPushUseCase +import im.vector.app.features.settings.VectorPreferences +import kotlinx.coroutines.launch + +class VectorSettingsNotificationPreferenceViewModel @AssistedInject constructor( + @Assisted initialState: VectorDummyViewState, + private val pushersManager: PushersManager, + private val vectorPreferences: VectorPreferences, + private val enableNotificationsForCurrentSessionUseCase: EnableNotificationsForCurrentSessionUseCase, + private val disableNotificationsForCurrentSessionUseCase: DisableNotificationsForCurrentSessionUseCase, + private val unregisterUnifiedPushUseCase: UnregisterUnifiedPushUseCase, + private val registerUnifiedPushUseCase: RegisterUnifiedPushUseCase, + private val ensureFcmTokenIsRetrievedUseCase: EnsureFcmTokenIsRetrievedUseCase, + private val toggleNotificationsForCurrentSessionUseCase: ToggleNotificationsForCurrentSessionUseCase, +) : VectorViewModel(initialState) { + + @AssistedFactory + interface Factory : MavericksAssistedViewModelFactory { + override fun create(initialState: VectorDummyViewState): VectorSettingsNotificationPreferenceViewModel + } + + companion object : MavericksViewModelFactory by hiltMavericksViewModelFactory() + + @VisibleForTesting + val notificationsPreferenceListener: SharedPreferences.OnSharedPreferenceChangeListener = + SharedPreferences.OnSharedPreferenceChangeListener { _, key -> + if (key == VectorPreferences.SETTINGS_ENABLE_THIS_DEVICE_PREFERENCE_KEY) { + if (vectorPreferences.areNotificationEnabledForDevice()) { + _viewEvents.post(VectorSettingsNotificationPreferenceViewEvent.NotificationsForDeviceEnabled) + } else { + _viewEvents.post(VectorSettingsNotificationPreferenceViewEvent.NotificationsForDeviceDisabled) + } + } + } + + init { + observeNotificationsEnabledPreference() + } + + private fun observeNotificationsEnabledPreference() { + vectorPreferences.subscribeToChanges(notificationsPreferenceListener) + } + + override fun onCleared() { + vectorPreferences.unsubscribeToChanges(notificationsPreferenceListener) + super.onCleared() + } + + override fun handle(action: VectorSettingsNotificationPreferenceViewAction) { + when (action) { + VectorSettingsNotificationPreferenceViewAction.DisableNotificationsForDevice -> handleDisableNotificationsForDevice() + is VectorSettingsNotificationPreferenceViewAction.EnableNotificationsForDevice -> handleEnableNotificationsForDevice(action.pushDistributor) + is VectorSettingsNotificationPreferenceViewAction.RegisterPushDistributor -> handleRegisterPushDistributor(action.pushDistributor) + } + } + + private fun handleDisableNotificationsForDevice() { + viewModelScope.launch { + disableNotificationsForCurrentSessionUseCase.execute() + _viewEvents.post(VectorSettingsNotificationPreferenceViewEvent.NotificationsForDeviceDisabled) + } + } + + private fun handleEnableNotificationsForDevice(distributor: String) { + viewModelScope.launch { + when (enableNotificationsForCurrentSessionUseCase.execute(distributor)) { + is EnableNotificationsForCurrentSessionUseCase.EnableNotificationsResult.NeedToAskUserForDistributor -> { + _viewEvents.post(VectorSettingsNotificationPreferenceViewEvent.AskUserForPushDistributor) + } + EnableNotificationsForCurrentSessionUseCase.EnableNotificationsResult.Success -> { + _viewEvents.post(VectorSettingsNotificationPreferenceViewEvent.NotificationsForDeviceEnabled) + } + } + } + } + + private fun handleRegisterPushDistributor(distributor: String) { + viewModelScope.launch { + unregisterUnifiedPushUseCase.execute(pushersManager) + when (registerUnifiedPushUseCase.execute(distributor)) { + RegisterUnifiedPushUseCase.RegisterUnifiedPushResult.NeedToAskUserForDistributor -> { + _viewEvents.post(VectorSettingsNotificationPreferenceViewEvent.AskUserForPushDistributor) + } + RegisterUnifiedPushUseCase.RegisterUnifiedPushResult.Success -> { + val areNotificationsEnabled = vectorPreferences.areNotificationEnabledForDevice() + ensureFcmTokenIsRetrievedUseCase.execute(pushersManager, registerPusher = areNotificationsEnabled) + toggleNotificationsForCurrentSessionUseCase.execute(enabled = areNotificationsEnabled) + _viewEvents.post(VectorSettingsNotificationPreferenceViewEvent.NotificationMethodChanged) + } + } + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/settings/troubleshoot/TestEndpointAsTokenRegistration.kt b/vector/src/main/java/im/vector/app/features/settings/troubleshoot/TestEndpointAsTokenRegistration.kt index 3bbff0f2fe..b355b55903 100644 --- a/vector/src/main/java/im/vector/app/features/settings/troubleshoot/TestEndpointAsTokenRegistration.kt +++ b/vector/src/main/java/im/vector/app/features/settings/troubleshoot/TestEndpointAsTokenRegistration.kt @@ -17,14 +17,17 @@ package im.vector.app.features.settings.troubleshoot import androidx.fragment.app.FragmentActivity -import androidx.lifecycle.Observer import androidx.work.WorkInfo import androidx.work.WorkManager import im.vector.app.R import im.vector.app.core.di.ActiveSessionHolder import im.vector.app.core.pushers.PushersManager +import im.vector.app.core.pushers.RegisterUnifiedPushUseCase import im.vector.app.core.pushers.UnifiedPushHelper +import im.vector.app.core.pushers.UnregisterUnifiedPushUseCase import im.vector.app.core.resources.StringProvider +import im.vector.app.features.session.coroutineScope +import kotlinx.coroutines.launch import org.matrix.android.sdk.api.session.pushers.PusherState import javax.inject.Inject @@ -34,6 +37,8 @@ class TestEndpointAsTokenRegistration @Inject constructor( private val pushersManager: PushersManager, private val activeSessionHolder: ActiveSessionHolder, private val unifiedPushHelper: UnifiedPushHelper, + private val registerUnifiedPushUseCase: RegisterUnifiedPushUseCase, + private val unregisterUnifiedPushUseCase: UnregisterUnifiedPushUseCase, ) : TroubleshootTest(R.string.settings_troubleshoot_test_endpoint_registration_title) { override fun perform(testParameters: TestParameters) { @@ -56,27 +61,52 @@ class TestEndpointAsTokenRegistration @Inject constructor( ) quickFix = object : TroubleshootQuickFix(R.string.settings_troubleshoot_test_endpoint_registration_quick_fix) { override fun doFix() { - unifiedPushHelper.forceRegister( - context, - pushersManager - ) - val workId = pushersManager.enqueueRegisterPusherWithFcmKey(endpoint) - WorkManager.getInstance(context).getWorkInfoByIdLiveData(workId).observe(context, Observer { workInfo -> - if (workInfo != null) { - if (workInfo.state == WorkInfo.State.SUCCEEDED) { - manager?.retry(testParameters) - } else if (workInfo.state == WorkInfo.State.FAILED) { - manager?.retry(testParameters) - } - } - }) + unregisterThenRegister(testParameters, endpoint) } } - status = TestStatus.FAILED } else { description = stringProvider.getString(R.string.settings_troubleshoot_test_endpoint_registration_success) status = TestStatus.SUCCESS } } + + private fun unregisterThenRegister(testParameters: TestParameters, pushKey: String) { + activeSessionHolder.getSafeActiveSession()?.coroutineScope?.launch { + unregisterUnifiedPushUseCase.execute(pushersManager) + registerUnifiedPush(distributor = "", testParameters, pushKey) + } + } + + private fun registerUnifiedPush( + distributor: String, + testParameters: TestParameters, + pushKey: String, + ) { + when (registerUnifiedPushUseCase.execute(distributor)) { + is RegisterUnifiedPushUseCase.RegisterUnifiedPushResult.NeedToAskUserForDistributor -> + askUserForDistributor(testParameters, pushKey) + RegisterUnifiedPushUseCase.RegisterUnifiedPushResult.Success -> { + val workId = pushersManager.enqueueRegisterPusherWithFcmKey(pushKey) + WorkManager.getInstance(context).getWorkInfoByIdLiveData(workId).observe(context) { workInfo -> + if (workInfo != null) { + if (workInfo.state == WorkInfo.State.SUCCEEDED) { + manager?.retry(testParameters) + } else if (workInfo.state == WorkInfo.State.FAILED) { + manager?.retry(testParameters) + } + } + } + } + } + } + + private fun askUserForDistributor( + testParameters: TestParameters, + pushKey: String, + ) { + unifiedPushHelper.showSelectDistributorDialog(context) { selection -> + registerUnifiedPush(distributor = selection, testParameters, pushKey) + } + } } diff --git a/vector/src/main/java/im/vector/app/features/start/StartAppAndroidService.kt b/vector/src/main/java/im/vector/app/features/start/StartAppAndroidService.kt index e8e0eac863..a14967a931 100644 --- a/vector/src/main/java/im/vector/app/features/start/StartAppAndroidService.kt +++ b/vector/src/main/java/im/vector/app/features/start/StartAppAndroidService.kt @@ -20,6 +20,7 @@ import android.content.Intent import dagger.hilt.android.AndroidEntryPoint import im.vector.app.core.di.ActiveSessionHolder import im.vector.app.core.di.NamedGlobalScope +import im.vector.app.core.extensions.startForegroundCompat import im.vector.app.core.services.VectorAndroidService import im.vector.app.features.notifications.NotificationUtils import kotlinx.coroutines.CoroutineScope @@ -58,6 +59,6 @@ class StartAppAndroidService : VectorAndroidService() { private fun showStickyNotification() { val notificationId = Random.nextInt() val notification = notificationUtils.buildStartAppNotification() - startForeground(notificationId, notification) + startForegroundCompat(notificationId, notification) } } diff --git a/vector/src/main/java/im/vector/app/features/start/StartAppViewModel.kt b/vector/src/main/java/im/vector/app/features/start/StartAppViewModel.kt index 4b2e7bef98..6eb8bf2e80 100644 --- a/vector/src/main/java/im/vector/app/features/start/StartAppViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/start/StartAppViewModel.kt @@ -63,7 +63,7 @@ class StartAppViewModel @AssistedInject constructor( } private suspend fun eagerlyInitializeSession() { - sessionHolder.getOrInitializeSession(startSync = true) + sessionHolder.getOrInitializeSession() } private fun handleLongProcessing() { diff --git a/vector/src/main/java/im/vector/app/features/themes/ThemeUtils.kt b/vector/src/main/java/im/vector/app/features/themes/ThemeUtils.kt index b5c7b162d8..3c902d162e 100644 --- a/vector/src/main/java/im/vector/app/features/themes/ThemeUtils.kt +++ b/vector/src/main/java/im/vector/app/features/themes/ThemeUtils.kt @@ -24,6 +24,7 @@ import android.graphics.drawable.Drawable import android.util.TypedValue import androidx.annotation.AttrRes import androidx.annotation.ColorInt +import androidx.annotation.StyleRes import androidx.core.content.ContextCompat import androidx.core.content.edit import androidx.core.graphics.drawable.DrawableCompat @@ -113,19 +114,16 @@ object ThemeUtils { */ fun setApplicationTheme(context: Context, aTheme: String) { currentTheme.set(aTheme) - context.setTheme( - when (aTheme) { - SYSTEM_THEME_VALUE -> if (isSystemDarkTheme(context.resources)) R.style.Theme_Vector_Dark else R.style.Theme_Vector_Light - THEME_DARK_VALUE -> R.style.Theme_Vector_Dark - THEME_BLACK_VALUE -> R.style.Theme_Vector_Black - else -> R.style.Theme_Vector_Light - } - ) + context.setTheme(themeToRes(context, aTheme)) // Clear the cache mColorByAttr.clear() } + @StyleRes + fun getApplicationThemeRes(context: Context) = + themeToRes(context, currentTheme.get()) + /** * Set the activity theme according to the selected one. Default is Light, so if this is the current * theme, the theme is not changed. @@ -200,4 +198,13 @@ object ThemeUtils { DrawableCompat.setTint(tinted, color) return tinted } + + @StyleRes + private fun themeToRes(context: Context, theme: String): Int = + when (theme) { + SYSTEM_THEME_VALUE -> if (isSystemDarkTheme(context.resources)) R.style.Theme_Vector_Dark else R.style.Theme_Vector_Light + THEME_DARK_VALUE -> R.style.Theme_Vector_Dark + THEME_BLACK_VALUE -> R.style.Theme_Vector_Black + else -> R.style.Theme_Vector_Light + } } diff --git a/vector/src/main/java/im/vector/app/features/userdirectory/InviteByEmailItem.kt b/vector/src/main/java/im/vector/app/features/userdirectory/InviteByEmailItem.kt index eaeec35791..da1d76e86a 100644 --- a/vector/src/main/java/im/vector/app/features/userdirectory/InviteByEmailItem.kt +++ b/vector/src/main/java/im/vector/app/features/userdirectory/InviteByEmailItem.kt @@ -25,12 +25,10 @@ import im.vector.app.R import im.vector.app.core.epoxy.ClickListener import im.vector.app.core.epoxy.VectorEpoxyHolder import im.vector.app.core.epoxy.VectorEpoxyModel -import im.vector.app.features.home.AvatarRenderer @EpoxyModelClass abstract class InviteByEmailItem : VectorEpoxyModel(R.layout.item_invite_by_mail) { - @EpoxyAttribute lateinit var avatarRenderer: AvatarRenderer @EpoxyAttribute lateinit var foundItem: ThreePidUser @EpoxyAttribute(EpoxyAttribute.Option.DoNotHash) var clickListener: ClickListener? = null @EpoxyAttribute var selected: Boolean = false diff --git a/vector/src/main/java/im/vector/app/features/version/VersionProvider.kt b/vector/src/main/java/im/vector/app/features/version/VersionProvider.kt index 4c8188dc8b..2b7406813f 100644 --- a/vector/src/main/java/im/vector/app/features/version/VersionProvider.kt +++ b/vector/src/main/java/im/vector/app/features/version/VersionProvider.kt @@ -25,7 +25,7 @@ class VersionProvider @Inject constructor( private val buildMeta: BuildMeta, ) { - fun getVersion(longFormat: Boolean, useBuildNumber: Boolean): String { + fun getVersion(longFormat: Boolean): String { var result = "${buildMeta.versionName} [${versionCodeProvider.getVersionCode()}]" var flavor = buildMeta.flavorShortDescription @@ -34,19 +34,10 @@ class VersionProvider @Inject constructor( flavor += "-" } - var gitVersion = buildMeta.gitRevision + val gitVersion = buildMeta.gitRevision val gitRevisionDate = buildMeta.gitRevisionDate - val buildNumber = buildMeta.buildNumber - var useLongFormat = longFormat - - if (useBuildNumber && buildNumber != "0") { - // It's a build from CI - gitVersion = "b$buildNumber" - useLongFormat = false - } - - result += if (useLongFormat) { + result += if (longFormat) { " ($flavor$gitVersion-$gitRevisionDate)" } else { " ($flavor$gitVersion)" diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastConstants.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastConstants.kt index 551eaa4dac..11b4f50d2f 100644 --- a/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastConstants.kt +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastConstants.kt @@ -28,4 +28,7 @@ object VoiceBroadcastConstants { /** Default voice broadcast chunk duration, in seconds. */ const val DEFAULT_CHUNK_LENGTH_IN_SECONDS = 120 + + /** Maximum length of the voice broadcast in seconds. */ + const val MAX_VOICE_BROADCAST_LENGTH_IN_SECONDS = 14_400 // 4 hours } diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastExtensions.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastExtensions.kt index f9da2e76b1..6faec5a262 100644 --- a/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastExtensions.kt +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastExtensions.kt @@ -16,7 +16,11 @@ package im.vector.app.features.voicebroadcast +import im.vector.app.features.voicebroadcast.model.MessageVoiceBroadcastInfoContent import im.vector.app.features.voicebroadcast.model.VoiceBroadcastChunk +import im.vector.app.features.voicebroadcast.model.VoiceBroadcastEvent +import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState +import org.matrix.android.sdk.api.extensions.orFalse import org.matrix.android.sdk.api.session.events.model.Content import org.matrix.android.sdk.api.session.events.model.getRelationContent import org.matrix.android.sdk.api.session.events.model.toModel @@ -32,3 +36,14 @@ fun MessageAudioEvent.getVoiceBroadcastChunk(): VoiceBroadcastChunk? { } val MessageAudioEvent.sequence: Int? get() = getVoiceBroadcastChunk()?.sequence + +val MessageAudioEvent.duration get() = content.audioInfo?.duration ?: content.audioWaveformInfo?.duration ?: 0 + +val VoiceBroadcastEvent.voiceBroadcastId + get() = reference?.eventId + +val VoiceBroadcastEvent.isLive + get() = content?.isLive.orFalse() + +val MessageVoiceBroadcastInfoContent.isLive + get() = voiceBroadcastState != null && voiceBroadcastState != VoiceBroadcastState.STOPPED diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/sync/FilterService.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastFailure.kt similarity index 59% rename from matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/sync/FilterService.kt rename to vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastFailure.kt index bc592df474..76b50c78ab 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/sync/FilterService.kt +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastFailure.kt @@ -1,5 +1,5 @@ /* - * Copyright 2020 The Matrix.org Foundation C.I.C. + * Copyright (c) 2022 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,21 +14,12 @@ * limitations under the License. */ -package org.matrix.android.sdk.api.session.sync +package im.vector.app.features.voicebroadcast -interface FilterService { - - enum class FilterPreset { - NoFilter, - - /** - * Filter for Element, will include only known event type. - */ - ElementFilter +sealed class VoiceBroadcastFailure : Throwable() { + sealed class RecordingError : VoiceBroadcastFailure() { + object NoPermission : RecordingError() + object BlockedBySomeoneElse : RecordingError() + object UserAlreadyBroadcasting : RecordingError() } - - /** - * Configure the filter for the sync. - */ - fun setFilter(filterPreset: FilterPreset) } diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastHelper.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastHelper.kt index 58e7de7f32..38fb157748 100644 --- a/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastHelper.kt +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastHelper.kt @@ -16,10 +16,12 @@ package im.vector.app.features.voicebroadcast -import im.vector.app.features.voicebroadcast.usecase.PauseVoiceBroadcastUseCase -import im.vector.app.features.voicebroadcast.usecase.ResumeVoiceBroadcastUseCase -import im.vector.app.features.voicebroadcast.usecase.StartVoiceBroadcastUseCase -import im.vector.app.features.voicebroadcast.usecase.StopVoiceBroadcastUseCase +import im.vector.app.features.voicebroadcast.listening.VoiceBroadcastPlayer +import im.vector.app.features.voicebroadcast.model.VoiceBroadcast +import im.vector.app.features.voicebroadcast.recording.usecase.PauseVoiceBroadcastUseCase +import im.vector.app.features.voicebroadcast.recording.usecase.ResumeVoiceBroadcastUseCase +import im.vector.app.features.voicebroadcast.recording.usecase.StartVoiceBroadcastUseCase +import im.vector.app.features.voicebroadcast.recording.usecase.StopVoiceBroadcastUseCase import javax.inject.Inject /** @@ -40,9 +42,13 @@ class VoiceBroadcastHelper @Inject constructor( suspend fun stopVoiceBroadcast(roomId: String) = stopVoiceBroadcastUseCase.execute(roomId) - fun playOrResumePlayback(roomId: String, eventId: String) = voiceBroadcastPlayer.playOrResume(roomId, eventId) + fun playOrResumePlayback(voiceBroadcast: VoiceBroadcast) = voiceBroadcastPlayer.playOrResume(voiceBroadcast) fun pausePlayback() = voiceBroadcastPlayer.pause() fun stopPlayback() = voiceBroadcastPlayer.stop() + + fun seekTo(voiceBroadcast: VoiceBroadcast, positionMillis: Int, duration: Int) { + voiceBroadcastPlayer.seekTo(voiceBroadcast, positionMillis, duration) + } } diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastPlayer.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastPlayer.kt deleted file mode 100644 index 2c892c8306..0000000000 --- a/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastPlayer.kt +++ /dev/null @@ -1,338 +0,0 @@ -/* - * Copyright (c) 2022 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 im.vector.app.features.voicebroadcast - -import android.media.AudioAttributes -import android.media.MediaPlayer -import androidx.annotation.MainThread -import im.vector.app.core.di.ActiveSessionHolder -import im.vector.app.features.home.room.detail.timeline.helper.AudioMessagePlaybackTracker -import im.vector.app.features.voice.VoiceFailure -import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState -import im.vector.app.features.voicebroadcast.model.asVoiceBroadcastEvent -import im.vector.app.features.voicebroadcast.usecase.GetVoiceBroadcastUseCase -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import org.matrix.android.sdk.api.session.events.model.RelationType -import org.matrix.android.sdk.api.session.getRoom -import org.matrix.android.sdk.api.session.room.Room -import org.matrix.android.sdk.api.session.room.model.message.MessageAudioContent -import org.matrix.android.sdk.api.session.room.model.message.MessageAudioEvent -import org.matrix.android.sdk.api.session.room.model.message.asMessageAudioEvent -import org.matrix.android.sdk.api.session.room.timeline.Timeline -import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent -import org.matrix.android.sdk.api.session.room.timeline.TimelineSettings -import timber.log.Timber -import java.util.concurrent.CopyOnWriteArrayList -import javax.inject.Inject -import javax.inject.Singleton - -@Singleton -class VoiceBroadcastPlayer @Inject constructor( - private val sessionHolder: ActiveSessionHolder, - private val playbackTracker: AudioMessagePlaybackTracker, - private val getVoiceBroadcastUseCase: GetVoiceBroadcastUseCase, -) { - private val session - get() = sessionHolder.getActiveSession() - - private val coroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) - private var voiceBroadcastStateJob: Job? = null - private var currentTimeline: Timeline? = null - set(value) { - field?.removeAllListeners() - field?.dispose() - field = value - } - - private val mediaPlayerListener = MediaPlayerListener() - private var timelineListener: TimelineListener? = null - - private var currentMediaPlayer: MediaPlayer? = null - private var nextMediaPlayer: MediaPlayer? = null - set(value) { - field = value - currentMediaPlayer?.setNextMediaPlayer(value) - } - private var currentSequence: Int? = null - - private var playlist = emptyList() - var currentVoiceBroadcastId: String? = null - - private var state: State = State.IDLE - @MainThread - set(value) { - Timber.w("## VoiceBroadcastPlayer state: $field -> $value") - field = value - listeners.forEach { it.onStateChanged(value) } - } - private var currentRoomId: String? = null - private var listeners = CopyOnWriteArrayList() - - fun playOrResume(roomId: String, eventId: String) { - val hasChanged = currentVoiceBroadcastId != eventId - when { - hasChanged -> startPlayback(roomId, eventId) - state == State.PAUSED -> resumePlayback() - else -> Unit - } - } - - fun pause() { - currentMediaPlayer?.pause() - currentVoiceBroadcastId?.let { playbackTracker.pausePlayback(it) } - state = State.PAUSED - } - - fun stop() { - // Stop playback - currentMediaPlayer?.stop() - currentVoiceBroadcastId?.let { playbackTracker.stopPlayback(it) } - - // Release current player - release(currentMediaPlayer) - currentMediaPlayer = null - - // Release next player - release(nextMediaPlayer) - nextMediaPlayer = null - - // Do not observe anymore voice broadcast state changes - voiceBroadcastStateJob?.cancel() - voiceBroadcastStateJob = null - - // In case of live broadcast, stop observing new chunks - currentTimeline = null - timelineListener = null - - // Update state - state = State.IDLE - - // Clear playlist - playlist = emptyList() - currentSequence = null - currentRoomId = null - currentVoiceBroadcastId = null - } - - fun addListener(listener: Listener) { - listeners.add(listener) - listener.onStateChanged(state) - } - - fun removeListener(listener: Listener) { - listeners.remove(listener) - } - - private fun startPlayback(roomId: String, eventId: String) { - val room = session.getRoom(roomId) ?: error("Unknown roomId: $roomId") - // Stop listening previous voice broadcast if any - if (state != State.IDLE) stop() - - currentRoomId = roomId - currentVoiceBroadcastId = eventId - - state = State.BUFFERING - - val voiceBroadcastState = getVoiceBroadcastUseCase.execute(roomId, eventId)?.content?.voiceBroadcastState - if (voiceBroadcastState == VoiceBroadcastState.STOPPED) { - // Get static playlist - updatePlaylist(getExistingChunks(room, eventId)) - startPlayback(false) - } else { - playLiveVoiceBroadcast(room, eventId) - } - } - - private fun startPlayback(isLive: Boolean) { - val event = if (isLive) playlist.lastOrNull() else playlist.firstOrNull() - val content = event?.content ?: run { Timber.w("## VoiceBroadcastPlayer: No content to play"); return } - val sequence = event.getVoiceBroadcastChunk()?.sequence - coroutineScope.launch { - try { - currentMediaPlayer = prepareMediaPlayer(content) - currentMediaPlayer?.start() - currentVoiceBroadcastId?.let { playbackTracker.startPlayback(it) } - currentSequence = sequence - withContext(Dispatchers.Main) { state = State.PLAYING } - nextMediaPlayer = prepareNextMediaPlayer() - } catch (failure: Throwable) { - Timber.e(failure, "Unable to start playback") - throw VoiceFailure.UnableToPlay(failure) - } - } - } - - private fun playLiveVoiceBroadcast(room: Room, eventId: String) { - room.timelineService().getTimelineEvent(eventId)?.root?.asVoiceBroadcastEvent() ?: error("Cannot retrieve voice broadcast $eventId") - updatePlaylist(getExistingChunks(room, eventId)) - startPlayback(true) - observeIncomingEvents(room, eventId) - } - - private fun getExistingChunks(room: Room, eventId: String): List { - return room.timelineService().getTimelineEventsRelatedTo(RelationType.REFERENCE, eventId) - .mapNotNull { it.root.asMessageAudioEvent() } - .filter { it.isVoiceBroadcast() } - } - - private fun observeIncomingEvents(room: Room, eventId: String) { - currentTimeline = room.timelineService().createTimeline(null, TimelineSettings(5)).also { timeline -> - timelineListener = TimelineListener(eventId).also { timeline.addListener(it) } - timeline.start() - } - } - - private fun resumePlayback() { - currentMediaPlayer?.start() - currentVoiceBroadcastId?.let { playbackTracker.startPlayback(it) } - state = State.PLAYING - } - - private fun updatePlaylist(playlist: List) { - this.playlist = playlist.sortedBy { it.getVoiceBroadcastChunk()?.sequence?.toLong() ?: it.root.originServerTs } - } - - private fun getNextAudioContent(): MessageAudioContent? { - val nextSequence = currentSequence?.plus(1) - ?: timelineListener?.let { playlist.lastOrNull()?.sequence } - ?: 1 - return playlist.find { it.getVoiceBroadcastChunk()?.sequence == nextSequence }?.content - } - - private suspend fun prepareNextMediaPlayer(): MediaPlayer? { - val nextContent = getNextAudioContent() ?: return null - return prepareMediaPlayer(nextContent) - } - - private suspend fun prepareMediaPlayer(messageAudioContent: MessageAudioContent): MediaPlayer { - // Download can fail - val audioFile = try { - session.fileService().downloadFile(messageAudioContent) - } catch (failure: Throwable) { - Timber.e(failure, "Unable to start playback") - throw VoiceFailure.UnableToPlay(failure) - } - - return audioFile.inputStream().use { fis -> - MediaPlayer().apply { - setAudioAttributes( - AudioAttributes.Builder() - // Do not use CONTENT_TYPE_SPEECH / USAGE_VOICE_COMMUNICATION because we want to play loud here - .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) - .setUsage(AudioAttributes.USAGE_MEDIA) - .build() - ) - setDataSource(fis.fd) - setOnInfoListener(mediaPlayerListener) - setOnErrorListener(mediaPlayerListener) - setOnCompletionListener(mediaPlayerListener) - prepare() - } - } - } - - private fun release(mp: MediaPlayer?) { - mp?.apply { - release() - setOnInfoListener(null) - setOnCompletionListener(null) - setOnErrorListener(null) - } - } - - private inner class TimelineListener(private val voiceBroadcastId: String) : Timeline.Listener { - override fun onTimelineUpdated(snapshot: List) { - val currentSequences = playlist.map { it.sequence } - val newChunks = snapshot - .mapNotNull { timelineEvent -> - timelineEvent.root.asMessageAudioEvent() - ?.takeIf { it.isVoiceBroadcast() && it.getVoiceBroadcastEventId() == voiceBroadcastId && it.sequence !in currentSequences } - } - if (newChunks.isEmpty()) return - updatePlaylist(playlist + newChunks) - - when (state) { - State.PLAYING -> { - if (nextMediaPlayer == null) { - coroutineScope.launch { nextMediaPlayer = prepareNextMediaPlayer() } - } - } - State.PAUSED -> { - if (nextMediaPlayer == null) { - coroutineScope.launch { nextMediaPlayer = prepareNextMediaPlayer() } - } - } - State.BUFFERING -> { - val newMediaContent = getNextAudioContent() - if (newMediaContent != null) startPlayback(true) - } - State.IDLE -> startPlayback(true) - } - } - } - - private inner class MediaPlayerListener : MediaPlayer.OnInfoListener, MediaPlayer.OnCompletionListener, MediaPlayer.OnErrorListener { - - override fun onInfo(mp: MediaPlayer, what: Int, extra: Int): Boolean { - when (what) { - MediaPlayer.MEDIA_INFO_STARTED_AS_NEXT -> { - release(currentMediaPlayer) - currentMediaPlayer = mp - currentSequence = currentSequence?.plus(1) - coroutineScope.launch { nextMediaPlayer = prepareNextMediaPlayer() } - } - } - return false - } - - override fun onCompletion(mp: MediaPlayer) { - if (nextMediaPlayer != null) return - val roomId = currentRoomId ?: return - val voiceBroadcastId = currentVoiceBroadcastId ?: return - val voiceBroadcastEventContent = getVoiceBroadcastUseCase.execute(roomId, voiceBroadcastId)?.content ?: return - val isLive = voiceBroadcastEventContent.voiceBroadcastState != null && voiceBroadcastEventContent.voiceBroadcastState != VoiceBroadcastState.STOPPED - - if (!isLive && voiceBroadcastEventContent.lastChunkSequence == currentSequence) { - // We'll not receive new chunks anymore so we can stop the live listening - stop() - } else { - state = State.BUFFERING - } - } - - override fun onError(mp: MediaPlayer, what: Int, extra: Int): Boolean { - stop() - return true - } - } - - enum class State { - PLAYING, - PAUSED, - BUFFERING, - IDLE - } - - fun interface Listener { - fun onStateChanged(state: State) - } -} diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastRecorderQ.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastRecorderQ.kt deleted file mode 100644 index 5285dc5e3b..0000000000 --- a/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastRecorderQ.kt +++ /dev/null @@ -1,120 +0,0 @@ -/* - * Copyright (c) 2022 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 im.vector.app.features.voicebroadcast - -import android.content.Context -import android.media.MediaRecorder -import android.os.Build -import androidx.annotation.RequiresApi -import im.vector.app.features.voice.AbstractVoiceRecorderQ -import org.matrix.android.sdk.api.extensions.tryOrNull -import org.matrix.android.sdk.api.session.content.ContentAttachmentData -import java.util.concurrent.CopyOnWriteArrayList - -@RequiresApi(Build.VERSION_CODES.Q) -class VoiceBroadcastRecorderQ( - context: Context, -) : AbstractVoiceRecorderQ(context), VoiceBroadcastRecorder { - - private var maxFileSize = 0L // zero or negative for no limit - private var currentRoomId: String? = null - override var currentSequence = 0 - override var state = VoiceBroadcastRecorder.State.Idle - set(value) { - field = value - listeners.forEach { it.onStateUpdated(value) } - } - - private val listeners = CopyOnWriteArrayList() - - override val outputFormat = MediaRecorder.OutputFormat.MPEG_4 - override val audioEncoder = MediaRecorder.AudioEncoder.HE_AAC - - override val fileNameExt: String = "mp4" - - override fun initializeRecord(roomId: String, attachmentData: ContentAttachmentData?) { - super.initializeRecord(roomId, attachmentData) - mediaRecorder?.setMaxFileSize(maxFileSize) - mediaRecorder?.setOnInfoListener { _, what, _ -> - when (what) { - MediaRecorder.MEDIA_RECORDER_INFO_MAX_FILESIZE_APPROACHING -> onMaxFileSizeApproaching(roomId) - MediaRecorder.MEDIA_RECORDER_INFO_NEXT_OUTPUT_FILE_STARTED -> onNextOutputFileStarted() - else -> Unit // Nothing to do - } - } - } - - override fun startRecord(roomId: String, chunkLength: Int) { - currentRoomId = roomId - maxFileSize = (chunkLength * audioEncodingBitRate / 8).toLong() - currentSequence = 1 - startRecord(roomId) - state = VoiceBroadcastRecorder.State.Recording - } - - override fun pauseRecord() { - tryOrNull { mediaRecorder?.stop() } - mediaRecorder?.reset() - notifyOutputFileCreated() - state = VoiceBroadcastRecorder.State.Paused - } - - override fun resumeRecord() { - currentSequence++ - currentRoomId?.let { startRecord(it) } - state = VoiceBroadcastRecorder.State.Recording - } - - override fun stopRecord() { - super.stopRecord() - notifyOutputFileCreated() - listeners.clear() - currentSequence = 0 - state = VoiceBroadcastRecorder.State.Idle - } - - override fun release() { - mediaRecorder?.setOnInfoListener(null) - super.release() - } - - override fun addListener(listener: VoiceBroadcastRecorder.Listener) { - listeners.add(listener) - listener.onStateUpdated(state) - } - - override fun removeListener(listener: VoiceBroadcastRecorder.Listener) { - listeners.remove(listener) - } - - private fun onMaxFileSizeApproaching(roomId: String) { - setNextOutputFile(roomId) - } - - private fun onNextOutputFileStarted() { - notifyOutputFileCreated() - currentSequence++ - } - - private fun notifyOutputFileCreated() { - outputFile?.let { file -> - listeners.forEach { it.onVoiceMessageCreated(file, currentSequence) } - outputFile = nextOutputFile - nextOutputFile = null - } - } -} diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/listening/VoiceBroadcastPlayer.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/listening/VoiceBroadcastPlayer.kt new file mode 100644 index 0000000000..0de88e9992 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/listening/VoiceBroadcastPlayer.kt @@ -0,0 +1,92 @@ +/* + * Copyright (c) 2022 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 im.vector.app.features.voicebroadcast.listening + +import im.vector.app.features.voicebroadcast.model.VoiceBroadcast + +interface VoiceBroadcastPlayer { + + /** + * The current playing voice broadcast, if any. + */ + val currentVoiceBroadcast: VoiceBroadcast? + + /** + * The current playing [State], [State.IDLE] by default. + */ + val playingState: State + + /** + * Tells whether the player is listening a live voice broadcast in "live" position. + */ + val isLiveListening: Boolean + + /** + * Start playback of the given voice broadcast. + */ + fun playOrResume(voiceBroadcast: VoiceBroadcast) + + /** + * Pause playback of the current voice broadcast, if any. + */ + fun pause() + + /** + * Stop playback of the current voice broadcast, if any, and reset the player state. + */ + fun stop() + + /** + * Seek the given voice broadcast playback to the given position, is milliseconds. + */ + fun seekTo(voiceBroadcast: VoiceBroadcast, positionMillis: Int, duration: Int) + + /** + * Add a [Listener] to the given voice broadcast. + */ + fun addListener(voiceBroadcast: VoiceBroadcast, listener: Listener) + + /** + * Remove a [Listener] from the given voice broadcast. + */ + fun removeListener(voiceBroadcast: VoiceBroadcast, listener: Listener) + + /** + * Player states. + */ + enum class State { + PLAYING, + PAUSED, + BUFFERING, + IDLE + } + + /** + * Listener related to [VoiceBroadcastPlayer]. + */ + interface Listener { + /** + * Notify about [VoiceBroadcastPlayer.playingState] changes. + */ + fun onPlayingStateChanged(state: State) = Unit + + /** + * Notify about [VoiceBroadcastPlayer.isLiveListening] changes. + */ + fun onLiveModeChanged(isLive: Boolean) = Unit + } +} diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/listening/VoiceBroadcastPlayerImpl.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/listening/VoiceBroadcastPlayerImpl.kt new file mode 100644 index 0000000000..d56f4ad715 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/listening/VoiceBroadcastPlayerImpl.kt @@ -0,0 +1,484 @@ +/* + * Copyright (c) 2022 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 im.vector.app.features.voicebroadcast.listening + +import android.media.AudioAttributes +import android.media.MediaPlayer +import android.media.MediaPlayer.OnPreparedListener +import androidx.annotation.MainThread +import im.vector.app.core.di.ActiveSessionHolder +import im.vector.app.core.extensions.onFirst +import im.vector.app.features.home.room.detail.timeline.helper.AudioMessagePlaybackTracker +import im.vector.app.features.session.coroutineScope +import im.vector.app.features.voice.VoiceFailure +import im.vector.app.features.voicebroadcast.isLive +import im.vector.app.features.voicebroadcast.listening.VoiceBroadcastPlayer.Listener +import im.vector.app.features.voicebroadcast.listening.VoiceBroadcastPlayer.State +import im.vector.app.features.voicebroadcast.listening.usecase.GetLiveVoiceBroadcastChunksUseCase +import im.vector.app.features.voicebroadcast.model.VoiceBroadcast +import im.vector.app.features.voicebroadcast.model.VoiceBroadcastEvent +import im.vector.app.features.voicebroadcast.usecase.GetVoiceBroadcastStateEventLiveUseCase +import im.vector.lib.core.utils.timer.CountUpTimer +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import org.matrix.android.sdk.api.extensions.tryOrNull +import org.matrix.android.sdk.api.session.room.model.message.MessageAudioContent +import timber.log.Timber +import java.util.concurrent.CopyOnWriteArrayList +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class VoiceBroadcastPlayerImpl @Inject constructor( + private val sessionHolder: ActiveSessionHolder, + private val playbackTracker: AudioMessagePlaybackTracker, + private val getVoiceBroadcastEventUseCase: GetVoiceBroadcastStateEventLiveUseCase, + private val getLiveVoiceBroadcastChunksUseCase: GetLiveVoiceBroadcastChunksUseCase +) : VoiceBroadcastPlayer { + + private val session get() = sessionHolder.getActiveSession() + private val sessionScope get() = session.coroutineScope + + private val mediaPlayerListener = MediaPlayerListener() + private val playbackTicker = PlaybackTicker() + private val playlist = VoiceBroadcastPlaylist() + + private var fetchPlaylistTask: Job? = null + private var voiceBroadcastStateObserver: Job? = null + + private var currentMediaPlayer: MediaPlayer? = null + private var nextMediaPlayer: MediaPlayer? = null + private var isPreparingNextPlayer: Boolean = false + + private var mostRecentVoiceBroadcastEvent: VoiceBroadcastEvent? = null + + override var currentVoiceBroadcast: VoiceBroadcast? = null + override var isLiveListening: Boolean = false + @MainThread + set(value) { + if (field != value) { + Timber.d("## Voice Broadcast | isLiveListening: $field -> $value") + field = value + onLiveListeningChanged(value) + } + } + + override var playingState = State.IDLE + @MainThread + set(value) { + if (field != value) { + Timber.d("## Voice Broadcast | playingState: $field -> $value") + field = value + onPlayingStateChanged(value) + } + } + + /** Map voiceBroadcastId to listeners. */ + private val listeners: MutableMap> = mutableMapOf() + + override fun playOrResume(voiceBroadcast: VoiceBroadcast) { + val hasChanged = currentVoiceBroadcast != voiceBroadcast + when { + hasChanged -> startPlayback(voiceBroadcast) + playingState == State.PAUSED -> resumePlayback() + else -> Unit + } + } + + override fun pause() { + pausePlayback() + } + + override fun stop() { + // Update state + playingState = State.IDLE + + // Stop and release media players + stopPlayer() + + // Do not observe anymore voice broadcast changes + fetchPlaylistTask?.cancel() + fetchPlaylistTask = null + voiceBroadcastStateObserver?.cancel() + voiceBroadcastStateObserver = null + + // Clear playlist + playlist.reset() + + mostRecentVoiceBroadcastEvent = null + currentVoiceBroadcast = null + } + + override fun addListener(voiceBroadcast: VoiceBroadcast, listener: Listener) { + listeners[voiceBroadcast.voiceBroadcastId]?.add(listener) ?: run { + listeners[voiceBroadcast.voiceBroadcastId] = CopyOnWriteArrayList().apply { add(listener) } + } + listener.onPlayingStateChanged(if (voiceBroadcast == currentVoiceBroadcast) playingState else State.IDLE) + listener.onLiveModeChanged(voiceBroadcast == currentVoiceBroadcast) + } + + override fun removeListener(voiceBroadcast: VoiceBroadcast, listener: Listener) { + listeners[voiceBroadcast.voiceBroadcastId]?.remove(listener) + } + + private fun startPlayback(voiceBroadcast: VoiceBroadcast) { + // Stop listening previous voice broadcast if any + if (playingState != State.IDLE) stop() + + currentVoiceBroadcast = voiceBroadcast + + playingState = State.BUFFERING + + observeVoiceBroadcastStateEvent(voiceBroadcast) + } + + private fun observeVoiceBroadcastStateEvent(voiceBroadcast: VoiceBroadcast) { + voiceBroadcastStateObserver = getVoiceBroadcastEventUseCase.execute(voiceBroadcast) + .onFirst { fetchPlaylistAndStartPlayback(voiceBroadcast) } + .onEach { onVoiceBroadcastStateEventUpdated(it.getOrNull()) } + .launchIn(sessionScope) + } + + private fun onVoiceBroadcastStateEventUpdated(event: VoiceBroadcastEvent?) { + if (event == null) { + stop() + } else { + mostRecentVoiceBroadcastEvent = event + updateLiveListeningMode() + } + } + + private fun fetchPlaylistAndStartPlayback(voiceBroadcast: VoiceBroadcast) { + fetchPlaylistTask = getLiveVoiceBroadcastChunksUseCase.execute(voiceBroadcast) + .onEach { + playlist.setItems(it) + onPlaylistUpdated() + } + .launchIn(sessionScope) + } + + private fun onPlaylistUpdated() { + when (playingState) { + State.PLAYING, + State.PAUSED -> { + if (nextMediaPlayer == null && !isPreparingNextPlayer) { + prepareNextMediaPlayer() + } + } + State.BUFFERING -> { + val nextItem = if (isLiveListening && playlist.currentSequence == null) { + // live listening, jump to the last item if playback has not started + playlist.lastOrNull() + } else { + // not live or playback already started, request next item + playlist.getNextItem() + } + if (nextItem != null) { + startPlayback(nextItem.startTime) + } + } + State.IDLE -> Unit // Should not happen + } + } + + private fun startPlayback(position: Int) { + stopPlayer() + + val playlistItem = playlist.findByPosition(position) + val content = playlistItem?.audioEvent?.content ?: run { Timber.w("## Voice Broadcast | No content to play at position $position"); return } + val sequence = playlistItem.sequence ?: run { Timber.w("## Voice Broadcast | Playlist item has no sequence"); return } + val sequencePosition = position - playlistItem.startTime + sessionScope.launch { + try { + prepareMediaPlayer(content) { mp -> + currentMediaPlayer = mp + playlist.currentSequence = sequence + mp.start() + if (sequencePosition > 0) { + mp.seekTo(sequencePosition) + } + playingState = State.PLAYING + prepareNextMediaPlayer() + } + } catch (failure: Throwable) { + Timber.e(failure, "## Voice Broadcast | Unable to start playback: $failure") + throw VoiceFailure.UnableToPlay(failure) + } + } + } + + private fun pausePlayback() { + playingState = State.PAUSED // This will trigger a playing state update and save the current position + if (currentMediaPlayer != null) { + currentMediaPlayer?.pause() + } else { + stopPlayer() + } + } + + private fun resumePlayback() { + if (currentMediaPlayer != null) { + playingState = State.PLAYING + currentMediaPlayer?.start() + } else { + val savedPosition = currentVoiceBroadcast?.voiceBroadcastId?.let { playbackTracker.getPlaybackTime(it) } ?: 0 + startPlayback(savedPosition) + } + } + + override fun seekTo(voiceBroadcast: VoiceBroadcast, positionMillis: Int, duration: Int) { + when { + voiceBroadcast != currentVoiceBroadcast -> { + playbackTracker.updatePausedAtPlaybackTime(voiceBroadcast.voiceBroadcastId, positionMillis, positionMillis.toFloat() / duration) + } + playingState == State.PLAYING || playingState == State.BUFFERING -> { + updateLiveListeningMode(positionMillis) + startPlayback(positionMillis) + } + playingState == State.IDLE || playingState == State.PAUSED -> { + stopPlayer() + playbackTracker.updatePausedAtPlaybackTime(voiceBroadcast.voiceBroadcastId, positionMillis, positionMillis.toFloat() / duration) + } + } + } + + private fun prepareNextMediaPlayer() { + val nextItem = playlist.getNextItem() + if (nextItem != null) { + isPreparingNextPlayer = true + sessionScope.launch { + prepareMediaPlayer(nextItem.audioEvent.content) { mp -> + isPreparingNextPlayer = false + nextMediaPlayer = mp + when (playingState) { + State.PLAYING, + State.PAUSED -> { + currentMediaPlayer?.setNextMediaPlayer(mp) + } + State.BUFFERING -> { + mp.start() + onNextMediaPlayerStarted(mp) + } + State.IDLE -> stopPlayer() + } + } + } + } + } + + private suspend fun prepareMediaPlayer(messageAudioContent: MessageAudioContent, onPreparedListener: OnPreparedListener): MediaPlayer { + // Download can fail + val audioFile = try { + session.fileService().downloadFile(messageAudioContent) + } catch (failure: Throwable) { + Timber.e(failure, "Voice Broadcast | Download has failed: $failure") + throw VoiceFailure.UnableToPlay(failure) + } + + return audioFile.inputStream().use { fis -> + MediaPlayer().apply { + setAudioAttributes( + AudioAttributes.Builder() + // Do not use CONTENT_TYPE_SPEECH / USAGE_VOICE_COMMUNICATION because we want to play loud here + .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC) + .setUsage(AudioAttributes.USAGE_MEDIA) + .build() + ) + setDataSource(fis.fd) + setOnInfoListener(mediaPlayerListener) + setOnErrorListener(mediaPlayerListener) + setOnPreparedListener(onPreparedListener) + setOnCompletionListener(mediaPlayerListener) + prepare() + } + } + } + + private fun stopPlayer() { + tryOrNull { currentMediaPlayer?.stop() } + currentMediaPlayer?.release() + currentMediaPlayer = null + + nextMediaPlayer?.release() + nextMediaPlayer = null + isPreparingNextPlayer = false + } + + private fun onPlayingStateChanged(playingState: State) { + // Update live playback flag + updateLiveListeningMode() + + currentVoiceBroadcast?.voiceBroadcastId?.let { voiceBroadcastId -> + // Start or stop playback ticker + when (playingState) { + State.PLAYING -> playbackTicker.startPlaybackTicker(voiceBroadcastId) + State.PAUSED, + State.BUFFERING, + State.IDLE -> playbackTicker.stopPlaybackTicker(voiceBroadcastId) + } + // Notify state change to all the listeners attached to the current voice broadcast id + listeners[voiceBroadcastId]?.forEach { listener -> listener.onPlayingStateChanged(playingState) } + } + } + + /** + * Update the live listening state according to: + * - the voice broadcast state (started/paused/resumed/stopped), + * - the playing state (IDLE, PLAYING, PAUSED, BUFFERING), + * - the potential seek position (backward/forward). + */ + private fun updateLiveListeningMode(seekPosition: Int? = null) { + isLiveListening = when { + // the current voice broadcast is not live (ended) + mostRecentVoiceBroadcastEvent?.isLive != true -> false + // the player is stopped or paused + playingState == State.IDLE || playingState == State.PAUSED -> false + seekPosition != null -> { + val seekDirection = seekPosition.compareTo(getCurrentPlaybackPosition() ?: 0) + val newSequence = playlist.findByPosition(seekPosition)?.sequence + // the user has sought forward + if (seekDirection >= 0) { + // stay in live or latest sequence reached + isLiveListening || newSequence == playlist.lastOrNull()?.sequence + } + // the user has sought backward + else { + // was in live and stay in the same sequence + isLiveListening && newSequence == playlist.currentSequence + } + } + // if there is no saved position, go in live + getCurrentPlaybackPosition() == null -> true + // if we reached the latest sequence, go in live + playlist.currentSequence == playlist.lastOrNull()?.sequence -> true + // otherwise, do not change + else -> isLiveListening + } + } + + private fun onLiveListeningChanged(isLiveListening: Boolean) { + // Live has ended and last chunk has been reached, we can stop the playback + if (!isLiveListening && playingState == State.BUFFERING && playlist.currentSequence == mostRecentVoiceBroadcastEvent?.content?.lastChunkSequence) { + stop() + } + } + + private fun onNextMediaPlayerStarted(mp: MediaPlayer) { + playingState = State.PLAYING + playlist.currentSequence = playlist.currentSequence?.inc() + currentMediaPlayer = mp + nextMediaPlayer = null + prepareNextMediaPlayer() + } + + private fun getCurrentPlaybackPosition(): Int? { + val voiceBroadcastId = currentVoiceBroadcast?.voiceBroadcastId ?: return null + val computedPosition = currentMediaPlayer?.currentPosition?.let { playlist.currentItem?.startTime?.plus(it) } + val savedPosition = playbackTracker.getPlaybackTime(voiceBroadcastId) + return computedPosition ?: savedPosition + } + + private fun getCurrentPlaybackPercentage(): Float? { + val playlistPosition = playlist.currentItem?.startTime + val computedPosition = currentMediaPlayer?.currentPosition?.let { playlistPosition?.plus(it) } ?: playlistPosition + val duration = playlist.duration.takeIf { it > 0 } + val computedPercentage = if (computedPosition != null && duration != null) computedPosition.toFloat() / duration else null + val savedPercentage = currentVoiceBroadcast?.voiceBroadcastId?.let { playbackTracker.getPercentage(it) } + return computedPercentage ?: savedPercentage + } + + private inner class MediaPlayerListener : + MediaPlayer.OnInfoListener, + MediaPlayer.OnCompletionListener, + MediaPlayer.OnErrorListener { + + override fun onInfo(mp: MediaPlayer, what: Int, extra: Int): Boolean { + when (what) { + MediaPlayer.MEDIA_INFO_STARTED_AS_NEXT -> onNextMediaPlayerStarted(mp) + } + return false + } + + override fun onCompletion(mp: MediaPlayer) { + // Next media player is already attached to this player and will start playing automatically + if (nextMediaPlayer != null) return + + val hasEnded = !isLiveListening && mostRecentVoiceBroadcastEvent?.content?.lastChunkSequence == playlist.currentSequence + if (hasEnded) { + // We'll not receive new chunks anymore so we can stop the live listening + stop() + } else { + // Enter in buffering mode and release current media player + playingState = State.BUFFERING + currentMediaPlayer?.release() + currentMediaPlayer = null + } + } + + override fun onError(mp: MediaPlayer, what: Int, extra: Int): Boolean { + stop() + return true + } + } + + private inner class PlaybackTicker( + private var playbackTicker: CountUpTimer? = null, + ) { + + fun startPlaybackTicker(id: String) { + playbackTicker?.stop() + playbackTicker = CountUpTimer(50L).apply { + tickListener = CountUpTimer.TickListener { onPlaybackTick(id) } + resume() + } + onPlaybackTick(id) + } + + fun stopPlaybackTicker(id: String) { + playbackTicker?.stop() + playbackTicker = null + onPlaybackTick(id) + } + + private fun onPlaybackTick(id: String) { + val playbackTime = getCurrentPlaybackPosition() + val percentage = getCurrentPlaybackPercentage() + when (playingState) { + State.PLAYING -> { + if (playbackTime != null && percentage != null) { + playbackTracker.updatePlayingAtPlaybackTime(id, playbackTime, percentage) + } + } + State.PAUSED, + State.BUFFERING -> { + if (playbackTime != null && percentage != null) { + playbackTracker.updatePausedAtPlaybackTime(id, playbackTime, percentage) + } + } + State.IDLE -> { + if (playbackTime == null || percentage == null || (playlist.duration - playbackTime) < 50) { + playbackTracker.stopPlayback(id) + } else { + playbackTracker.updatePausedAtPlaybackTime(id, playbackTime, percentage) + } + } + } + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/listening/VoiceBroadcastPlaylist.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/listening/VoiceBroadcastPlaylist.kt new file mode 100644 index 0000000000..36b737f23f --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/listening/VoiceBroadcastPlaylist.kt @@ -0,0 +1,70 @@ +/* + * Copyright (c) 2022 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 im.vector.app.features.voicebroadcast.listening + +import im.vector.app.features.voicebroadcast.duration +import im.vector.app.features.voicebroadcast.sequence +import org.matrix.android.sdk.api.session.room.model.message.MessageAudioEvent + +class VoiceBroadcastPlaylist( + private val items: MutableList = mutableListOf(), +) : List by items { + + var currentSequence: Int? = null + val currentItem get() = currentSequence?.let { findBySequence(it) } + + val duration + get() = items.lastOrNull()?.let { it.startTime + it.audioEvent.duration } ?: 0 + + fun setItems(audioEvents: List) { + items.clear() + val sorted = audioEvents.sortedBy { it.sequence?.toLong() ?: it.root.originServerTs } + val chunkPositions = sorted + .map { it.duration } + .runningFold(0) { acc, i -> acc + i } + .dropLast(1) + val newItems = sorted.mapIndexed { index, messageAudioEvent -> + PlaylistItem( + audioEvent = messageAudioEvent, + startTime = chunkPositions.getOrNull(index) ?: 0 + ) + } + items.addAll(newItems) + } + + fun reset() { + currentSequence = null + items.clear() + } + + fun findByPosition(positionMillis: Int): PlaylistItem? { + return items.lastOrNull { it.startTime <= positionMillis } + } + + fun findBySequence(sequenceNumber: Int): PlaylistItem? { + return items.find { it.sequence == sequenceNumber } + } + + fun getNextItem() = findBySequence(currentSequence?.plus(1) ?: 1) + + fun firstOrNull() = findBySequence(1) +} + +data class PlaylistItem(val audioEvent: MessageAudioEvent, val startTime: Int) { + val sequence: Int? + get() = audioEvent.sequence +} diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/listening/usecase/GetLiveVoiceBroadcastChunksUseCase.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/listening/usecase/GetLiveVoiceBroadcastChunksUseCase.kt new file mode 100644 index 0000000000..b2aebd9932 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/listening/usecase/GetLiveVoiceBroadcastChunksUseCase.kt @@ -0,0 +1,137 @@ +/* + * Copyright (c) 2022 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 im.vector.app.features.voicebroadcast.listening.usecase + +import im.vector.app.core.di.ActiveSessionHolder +import im.vector.app.features.voicebroadcast.getVoiceBroadcastEventId +import im.vector.app.features.voicebroadcast.isVoiceBroadcast +import im.vector.app.features.voicebroadcast.model.VoiceBroadcast +import im.vector.app.features.voicebroadcast.model.VoiceBroadcastEvent +import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState +import im.vector.app.features.voicebroadcast.model.asVoiceBroadcastEvent +import im.vector.app.features.voicebroadcast.sequence +import im.vector.app.features.voicebroadcast.usecase.GetVoiceBroadcastStateEventLiveUseCase +import im.vector.app.features.voicebroadcast.voiceBroadcastId +import kotlinx.coroutines.channels.awaitClose +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.callbackFlow +import kotlinx.coroutines.flow.emptyFlow +import kotlinx.coroutines.flow.firstOrNull +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.runningReduce +import kotlinx.coroutines.runBlocking +import org.matrix.android.sdk.api.session.events.model.RelationType +import org.matrix.android.sdk.api.session.room.model.message.MessageAudioEvent +import org.matrix.android.sdk.api.session.room.model.message.asMessageAudioEvent +import org.matrix.android.sdk.api.session.room.timeline.Timeline +import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent +import org.matrix.android.sdk.api.session.room.timeline.TimelineSettings +import javax.inject.Inject + +/** + * Get a [Flow] of [MessageAudioEvent]s related to the given voice broadcast. + */ +class GetLiveVoiceBroadcastChunksUseCase @Inject constructor( + private val activeSessionHolder: ActiveSessionHolder, + private val getVoiceBroadcastEventUseCase: GetVoiceBroadcastStateEventLiveUseCase, +) { + + fun execute(voiceBroadcast: VoiceBroadcast): Flow> { + val session = activeSessionHolder.getSafeActiveSession() ?: return emptyFlow() + val room = session.roomService().getRoom(voiceBroadcast.roomId) ?: return emptyFlow() + val timeline = room.timelineService().createTimeline(null, TimelineSettings(5)) + + // Get initial chunks + val existingChunks = room.timelineService().getTimelineEventsRelatedTo(RelationType.REFERENCE, voiceBroadcast.voiceBroadcastId) + .mapNotNull { timelineEvent -> timelineEvent.root.asMessageAudioEvent().takeIf { it.isVoiceBroadcast() } } + + val voiceBroadcastEvent = runBlocking { getVoiceBroadcastEventUseCase.execute(voiceBroadcast).firstOrNull()?.getOrNull() } + val voiceBroadcastState = voiceBroadcastEvent?.content?.voiceBroadcastState + + return if (voiceBroadcastState == null || voiceBroadcastState == VoiceBroadcastState.STOPPED) { + // Just send the existing chunks if voice broadcast is stopped + flowOf(existingChunks) + } else { + // Observe new timeline events if voice broadcast is ongoing + callbackFlow { + // Init with existing chunks + send(existingChunks) + + // Observe new timeline events + val listener = object : Timeline.Listener { + private var latestEventId: String? = null + private var lastSequence: Int? = null + + override fun onTimelineUpdated(snapshot: List) { + val latestEventIndex = latestEventId?.let { eventId -> snapshot.indexOfFirst { it.eventId == eventId } } + val newEvents = if (latestEventIndex != null) snapshot.subList(0, latestEventIndex) else snapshot + + // Detect a potential stopped voice broadcast state event + val stopEvent = newEvents.findStopEvent(voiceBroadcast) + if (stopEvent != null) { + lastSequence = stopEvent.content?.lastChunkSequence + } + + val newChunks = newEvents.mapToChunkEvents(voiceBroadcast.voiceBroadcastId, voiceBroadcastEvent.root.senderId) + + // Notify about new chunks + if (newChunks.isNotEmpty()) { + trySend(newChunks) + } + + // Automatically stop observing the timeline if the last chunk has been received + if (lastSequence != null && newChunks.any { it.sequence == lastSequence }) { + timeline.removeListener(this) + timeline.dispose() + } + + latestEventId = snapshot.firstOrNull()?.eventId + } + } + + timeline.addListener(listener) + timeline.start() + awaitClose { + timeline.removeListener(listener) + timeline.dispose() + } + } + .runningReduce { accumulator: List, value: List -> accumulator.plus(value) } + .map { events -> events.distinctBy { it.sequence } } + } + } + + /** + * Find a [VoiceBroadcastEvent] with a [VoiceBroadcastState.STOPPED] state. + */ + private fun List.findStopEvent(voiceBroadcast: VoiceBroadcast): VoiceBroadcastEvent? = + this.mapNotNull { timelineEvent -> timelineEvent.root.asVoiceBroadcastEvent()?.takeIf { it.voiceBroadcastId == voiceBroadcast.voiceBroadcastId } } + .find { it.content?.voiceBroadcastState == VoiceBroadcastState.STOPPED } + + /** + * Transform the list of [TimelineEvent] to a mapped list of [MessageAudioEvent] related to a given voice broadcast. + */ + private fun List.mapToChunkEvents(voiceBroadcastId: String, senderId: String?): List = + this.mapNotNull { timelineEvent -> + timelineEvent.root.asMessageAudioEvent() + ?.takeIf { + it.isVoiceBroadcast() && it.getVoiceBroadcastEventId() == voiceBroadcastId && + it.root.senderId == senderId + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/model/VoiceBroadcast.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/model/VoiceBroadcast.kt new file mode 100644 index 0000000000..62207d5b87 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/model/VoiceBroadcast.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2022 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 im.vector.app.features.voicebroadcast.model + +data class VoiceBroadcast( + val voiceBroadcastId: String, + val roomId: String, +) diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastRecorder.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/VoiceBroadcastRecorder.kt similarity index 69% rename from vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastRecorder.kt rename to vector/src/main/java/im/vector/app/features/voicebroadcast/recording/VoiceBroadcastRecorder.kt index 8b69051823..00e4bb17dd 100644 --- a/vector/src/main/java/im/vector/app/features/voicebroadcast/VoiceBroadcastRecorder.kt +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/VoiceBroadcastRecorder.kt @@ -14,24 +14,32 @@ * limitations under the License. */ -package im.vector.app.features.voicebroadcast +package im.vector.app.features.voicebroadcast.recording import androidx.annotation.IntRange import im.vector.app.features.voice.VoiceRecorder +import im.vector.app.features.voicebroadcast.model.VoiceBroadcast import java.io.File interface VoiceBroadcastRecorder : VoiceRecorder { + /** The current chunk number. */ val currentSequence: Int - val state: State - fun startRecord(roomId: String, chunkLength: Int) + /** Current state of the recorder. */ + val recordingState: State + + /** Current remaining time of recording, in seconds, if any. */ + val currentRemainingTime: Long? + + fun startRecordVoiceBroadcast(voiceBroadcast: VoiceBroadcast, chunkLength: Int, maxLength: Int) fun addListener(listener: Listener) fun removeListener(listener: Listener) interface Listener { fun onVoiceMessageCreated(file: File, @IntRange(from = 1) sequence: Int) = Unit fun onStateUpdated(state: State) = Unit + fun onRemainingTimeUpdated(remainingTime: Long?) = Unit } enum class State { diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/VoiceBroadcastRecorderQ.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/VoiceBroadcastRecorderQ.kt new file mode 100644 index 0000000000..2da807293f --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/VoiceBroadcastRecorderQ.kt @@ -0,0 +1,238 @@ +/* + * Copyright (c) 2022 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 im.vector.app.features.voicebroadcast.recording + +import android.content.Context +import android.media.MediaRecorder +import android.os.Build +import androidx.annotation.RequiresApi +import im.vector.app.core.di.ActiveSessionHolder +import im.vector.app.features.session.coroutineScope +import im.vector.app.features.voice.AbstractVoiceRecorderQ +import im.vector.app.features.voicebroadcast.model.VoiceBroadcast +import im.vector.app.features.voicebroadcast.model.VoiceBroadcastEvent +import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState +import im.vector.app.features.voicebroadcast.usecase.GetVoiceBroadcastStateEventLiveUseCase +import im.vector.lib.core.utils.timer.CountUpTimer +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import org.matrix.android.sdk.api.extensions.tryOrNull +import org.matrix.android.sdk.api.session.content.ContentAttachmentData +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.TimeUnit + +@RequiresApi(Build.VERSION_CODES.Q) +class VoiceBroadcastRecorderQ( + context: Context, + private val sessionHolder: ActiveSessionHolder, + private val getVoiceBroadcastEventUseCase: GetVoiceBroadcastStateEventLiveUseCase +) : AbstractVoiceRecorderQ(context), VoiceBroadcastRecorder { + + private val session get() = sessionHolder.getActiveSession() + private val sessionScope get() = session.coroutineScope + + private var voiceBroadcastStateObserver: Job? = null + + private var maxFileSize = 0L // zero or negative for no limit + private var currentVoiceBroadcast: VoiceBroadcast? = null + private var currentMaxLength: Int = 0 + + override var currentSequence = 0 + override var recordingState = VoiceBroadcastRecorder.State.Idle + set(value) { + field = value + listeners.forEach { it.onStateUpdated(value) } + } + override var currentRemainingTime: Long? = null + set(value) { + field = value + listeners.forEach { it.onRemainingTimeUpdated(value) } + } + + private val recordingTicker = RecordingTicker() + private val listeners = CopyOnWriteArrayList() + + override val outputFormat = MediaRecorder.OutputFormat.MPEG_4 + override val audioEncoder = MediaRecorder.AudioEncoder.HE_AAC + + override val fileNameExt: String = "mp4" + + override fun initializeRecord(roomId: String, attachmentData: ContentAttachmentData?) { + super.initializeRecord(roomId, attachmentData) + mediaRecorder?.setMaxFileSize(maxFileSize) + mediaRecorder?.setOnInfoListener { _, what, _ -> + when (what) { + MediaRecorder.MEDIA_RECORDER_INFO_MAX_FILESIZE_APPROACHING -> onMaxFileSizeApproaching(roomId) + MediaRecorder.MEDIA_RECORDER_INFO_NEXT_OUTPUT_FILE_STARTED -> onNextOutputFileStarted() + else -> Unit // Nothing to do + } + } + } + + override fun startRecordVoiceBroadcast(voiceBroadcast: VoiceBroadcast, chunkLength: Int, maxLength: Int) { + // Stop recording previous voice broadcast if any + if (recordingState != VoiceBroadcastRecorder.State.Idle) stopRecord() + + currentVoiceBroadcast = voiceBroadcast + maxFileSize = (chunkLength * audioEncodingBitRate / 8).toLong() + currentMaxLength = maxLength + currentSequence = 1 + + observeVoiceBroadcastStateEvent(voiceBroadcast) + } + + override fun pauseRecord() { + if (recordingState != VoiceBroadcastRecorder.State.Recording) return + tryOrNull { mediaRecorder?.stop() } + mediaRecorder?.reset() + recordingState = VoiceBroadcastRecorder.State.Paused + recordingTicker.pause() + notifyOutputFileCreated() + } + + override fun resumeRecord() { + if (recordingState != VoiceBroadcastRecorder.State.Paused) return + currentSequence++ + currentVoiceBroadcast?.let { startRecord(it.roomId) } + recordingState = VoiceBroadcastRecorder.State.Recording + recordingTicker.resume() + } + + override fun stopRecord() { + super.stopRecord() + + // Stop recording + recordingState = VoiceBroadcastRecorder.State.Idle + recordingTicker.stop() + notifyOutputFileCreated() + + // Remove listeners + listeners.clear() + + // Do not observe anymore voice broadcast changes + voiceBroadcastStateObserver?.cancel() + voiceBroadcastStateObserver = null + + // Reset data + currentSequence = 0 + currentMaxLength = 0 + currentRemainingTime = null + currentVoiceBroadcast = null + } + + override fun release() { + mediaRecorder?.setOnInfoListener(null) + super.release() + } + + override fun addListener(listener: VoiceBroadcastRecorder.Listener) { + listeners.add(listener) + listener.onStateUpdated(recordingState) + listener.onRemainingTimeUpdated(currentRemainingTime) + } + + override fun removeListener(listener: VoiceBroadcastRecorder.Listener) { + listeners.remove(listener) + } + + private fun observeVoiceBroadcastStateEvent(voiceBroadcast: VoiceBroadcast) { + voiceBroadcastStateObserver = getVoiceBroadcastEventUseCase.execute(voiceBroadcast) + .onEach { onVoiceBroadcastStateEventUpdated(voiceBroadcast, it.getOrNull()) } + .launchIn(sessionScope) + } + + private fun onVoiceBroadcastStateEventUpdated(voiceBroadcast: VoiceBroadcast, event: VoiceBroadcastEvent?) { + when (event?.content?.voiceBroadcastState) { + VoiceBroadcastState.STARTED -> { + startRecord(voiceBroadcast.roomId) + recordingState = VoiceBroadcastRecorder.State.Recording + recordingTicker.start() + } + VoiceBroadcastState.PAUSED -> pauseRecord() + VoiceBroadcastState.RESUMED -> resumeRecord() + VoiceBroadcastState.STOPPED, + null -> stopRecord() + } + } + + private fun onMaxFileSizeApproaching(roomId: String) { + setNextOutputFile(roomId) + } + + private fun onNextOutputFileStarted() { + notifyOutputFileCreated() + currentSequence++ + } + + private fun notifyOutputFileCreated() { + outputFile?.let { file -> + listeners.forEach { it.onVoiceMessageCreated(file, currentSequence) } + outputFile = nextOutputFile + nextOutputFile = null + } + } + + private fun onElapsedTimeUpdated(elapsedTimeMillis: Long) { + currentRemainingTime = if (currentMaxLength > 0 && recordingState != VoiceBroadcastRecorder.State.Idle) { + val currentMaxLengthMillis = TimeUnit.SECONDS.toMillis(currentMaxLength.toLong()) + val remainingTimeMillis = currentMaxLengthMillis - elapsedTimeMillis + TimeUnit.MILLISECONDS.toSeconds(remainingTimeMillis) + } else { + null + } + } + + private inner class RecordingTicker( + private var recordingTicker: CountUpTimer? = null, + ) { + fun start() { + recordingTicker?.stop() + recordingTicker = CountUpTimer().apply { + tickListener = CountUpTimer.TickListener { onTick(elapsedTime()) } + resume() + onTick(elapsedTime()) + } + } + + fun pause() { + recordingTicker?.apply { + pause() + onTick(elapsedTime()) + } + } + + fun resume() { + recordingTicker?.apply { + resume() + onTick(elapsedTime()) + } + } + + fun stop() { + recordingTicker?.apply { + stop() + onTick(elapsedTime()) + recordingTicker = null + } + } + + private fun onTick(elapsedTimeMillis: Long) { + onElapsedTimeUpdated(elapsedTimeMillis) + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/PauseVoiceBroadcastUseCase.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/PauseVoiceBroadcastUseCase.kt similarity index 89% rename from vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/PauseVoiceBroadcastUseCase.kt rename to vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/PauseVoiceBroadcastUseCase.kt index 1430dd8c86..0b22d7adf5 100644 --- a/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/PauseVoiceBroadcastUseCase.kt +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/PauseVoiceBroadcastUseCase.kt @@ -14,13 +14,13 @@ * limitations under the License. */ -package im.vector.app.features.voicebroadcast.usecase +package im.vector.app.features.voicebroadcast.recording.usecase import im.vector.app.features.voicebroadcast.VoiceBroadcastConstants -import im.vector.app.features.voicebroadcast.VoiceBroadcastRecorder import im.vector.app.features.voicebroadcast.model.MessageVoiceBroadcastInfoContent import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState import im.vector.app.features.voicebroadcast.model.asVoiceBroadcastEvent +import im.vector.app.features.voicebroadcast.recording.VoiceBroadcastRecorder import org.matrix.android.sdk.api.query.QueryStringValue import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.events.model.toContent @@ -53,17 +53,20 @@ class PauseVoiceBroadcastUseCase @Inject constructor( private suspend fun pauseVoiceBroadcast(room: Room, reference: RelationDefaultContent?) { Timber.d("## PauseVoiceBroadcastUseCase: Send new voice broadcast info state event") + + // save the last sequence number and immediately pause the recording + val lastSequence = voiceBroadcastRecorder?.currentSequence + pauseRecording() + room.stateService().sendStateEvent( eventType = VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO, stateKey = session.myUserId, body = MessageVoiceBroadcastInfoContent( relatesTo = reference, voiceBroadcastStateStr = VoiceBroadcastState.PAUSED.value, - lastChunkSequence = voiceBroadcastRecorder?.currentSequence, + lastChunkSequence = lastSequence, ).toContent(), ) - - pauseRecording() } private fun pauseRecording() { diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/ResumeVoiceBroadcastUseCase.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/ResumeVoiceBroadcastUseCase.kt similarity index 90% rename from vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/ResumeVoiceBroadcastUseCase.kt rename to vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/ResumeVoiceBroadcastUseCase.kt index 2f03d4194c..5be726c03e 100644 --- a/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/ResumeVoiceBroadcastUseCase.kt +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/ResumeVoiceBroadcastUseCase.kt @@ -14,10 +14,9 @@ * limitations under the License. */ -package im.vector.app.features.voicebroadcast.usecase +package im.vector.app.features.voicebroadcast.recording.usecase import im.vector.app.features.voicebroadcast.VoiceBroadcastConstants -import im.vector.app.features.voicebroadcast.VoiceBroadcastRecorder import im.vector.app.features.voicebroadcast.model.MessageVoiceBroadcastInfoContent import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState import im.vector.app.features.voicebroadcast.model.asVoiceBroadcastEvent @@ -32,7 +31,6 @@ import javax.inject.Inject class ResumeVoiceBroadcastUseCase @Inject constructor( private val session: Session, - private val voiceBroadcastRecorder: VoiceBroadcastRecorder?, ) { suspend fun execute(roomId: String): Result = runCatching { @@ -66,11 +64,5 @@ class ResumeVoiceBroadcastUseCase @Inject constructor( voiceBroadcastStateStr = VoiceBroadcastState.RESUMED.value, ).toContent(), ) - - resumeRecording() - } - - private fun resumeRecording() { - voiceBroadcastRecorder?.resumeRecord() } } diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/StartVoiceBroadcastUseCase.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/StartVoiceBroadcastUseCase.kt similarity index 50% rename from vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/StartVoiceBroadcastUseCase.kt rename to vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/StartVoiceBroadcastUseCase.kt index 7934d18e36..87ea49cece 100644 --- a/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/StartVoiceBroadcastUseCase.kt +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/StartVoiceBroadcastUseCase.kt @@ -14,26 +14,39 @@ * limitations under the License. */ -package im.vector.app.features.voicebroadcast.usecase +package im.vector.app.features.voicebroadcast.recording.usecase import android.content.Context import androidx.core.content.FileProvider import im.vector.app.core.resources.BuildMeta import im.vector.app.features.attachments.toContentAttachmentData +import im.vector.app.features.session.coroutineScope import im.vector.app.features.voicebroadcast.VoiceBroadcastConstants -import im.vector.app.features.voicebroadcast.VoiceBroadcastRecorder +import im.vector.app.features.voicebroadcast.VoiceBroadcastFailure import im.vector.app.features.voicebroadcast.model.MessageVoiceBroadcastInfoContent +import im.vector.app.features.voicebroadcast.model.VoiceBroadcast import im.vector.app.features.voicebroadcast.model.VoiceBroadcastChunk import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState -import im.vector.app.features.voicebroadcast.model.asVoiceBroadcastEvent +import im.vector.app.features.voicebroadcast.recording.VoiceBroadcastRecorder +import im.vector.app.features.voicebroadcast.usecase.GetRoomLiveVoiceBroadcastsUseCase import im.vector.lib.multipicker.utils.toMultiPickerAudioType +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import org.jetbrains.annotations.VisibleForTesting import org.matrix.android.sdk.api.query.QueryStringValue import org.matrix.android.sdk.api.session.Session +import org.matrix.android.sdk.api.session.events.model.EventType import org.matrix.android.sdk.api.session.events.model.RelationType import org.matrix.android.sdk.api.session.events.model.toContent +import org.matrix.android.sdk.api.session.events.model.toModel import org.matrix.android.sdk.api.session.getRoom import org.matrix.android.sdk.api.session.room.Room +import org.matrix.android.sdk.api.session.room.getStateEvent +import org.matrix.android.sdk.api.session.room.model.PowerLevelsContent import org.matrix.android.sdk.api.session.room.model.relation.RelationDefaultContent +import org.matrix.android.sdk.api.session.room.powerlevels.PowerLevelsHelper +import org.matrix.android.sdk.flow.flow +import org.matrix.android.sdk.flow.unwrap import timber.log.Timber import java.io.File import javax.inject.Inject @@ -43,6 +56,8 @@ class StartVoiceBroadcastUseCase @Inject constructor( private val voiceBroadcastRecorder: VoiceBroadcastRecorder?, private val context: Context, private val buildMeta: BuildMeta, + private val getRoomLiveVoiceBroadcastsUseCase: GetRoomLiveVoiceBroadcastsUseCase, + private val stopVoiceBroadcastUseCase: StopVoiceBroadcastUseCase, ) { suspend fun execute(roomId: String): Result = runCatching { @@ -50,23 +65,15 @@ class StartVoiceBroadcastUseCase @Inject constructor( Timber.d("## StartVoiceBroadcastUseCase: Start voice broadcast requested") - val onGoingVoiceBroadcastEvents = room.stateService().getStateEvents( - setOf(VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO), - QueryStringValue.IsNotEmpty - ) - .mapNotNull { it.asVoiceBroadcastEvent() } - .filter { it.content?.voiceBroadcastState != null && it.content?.voiceBroadcastState != VoiceBroadcastState.STOPPED } - - if (onGoingVoiceBroadcastEvents.isEmpty()) { - startVoiceBroadcast(room) - } else { - Timber.d("## StartVoiceBroadcastUseCase: Cannot start voice broadcast: currentVoiceBroadcastEvents=$onGoingVoiceBroadcastEvents") - } + assertCanStartVoiceBroadcast(room) + startVoiceBroadcast(room) + return Result.success(Unit) } private suspend fun startVoiceBroadcast(room: Room) { Timber.d("## StartVoiceBroadcastUseCase: Send new voice broadcast info state event") - val chunkLength = VoiceBroadcastConstants.DEFAULT_CHUNK_LENGTH_IN_SECONDS // Todo Get the length from the room settings + val chunkLength = VoiceBroadcastConstants.DEFAULT_CHUNK_LENGTH_IN_SECONDS // Todo Get the chunk length from the room settings + val maxLength = VoiceBroadcastConstants.MAX_VOICE_BROADCAST_LENGTH_IN_SECONDS // Todo Get the max length from the room settings val eventId = room.stateService().sendStateEvent( eventType = VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO, stateKey = session.myUserId, @@ -77,19 +84,30 @@ class StartVoiceBroadcastUseCase @Inject constructor( ).toContent() ) - startRecording(room, eventId, chunkLength) + val voiceBroadcast = VoiceBroadcast(roomId = room.roomId, voiceBroadcastId = eventId) + + // TODO Update unit test to cover the following line + room.flow().liveTimelineEvent(eventId).unwrap().first() // wait for the event come back from the sync + + startRecording(room, voiceBroadcast, chunkLength, maxLength) } - private fun startRecording(room: Room, eventId: String, chunkLength: Int) { + private fun startRecording(room: Room, voiceBroadcast: VoiceBroadcast, chunkLength: Int, maxLength: Int) { voiceBroadcastRecorder?.addListener(object : VoiceBroadcastRecorder.Listener { override fun onVoiceMessageCreated(file: File, sequence: Int) { - sendVoiceFile(room, file, eventId, sequence) + sendVoiceFile(room, file, voiceBroadcast, sequence) + } + + override fun onRemainingTimeUpdated(remainingTime: Long?) { + if (remainingTime != null && remainingTime <= 0) { + session.coroutineScope.launch { stopVoiceBroadcastUseCase.execute(room.roomId) } + } } }) - voiceBroadcastRecorder?.startRecord(room.roomId, chunkLength) + voiceBroadcastRecorder?.startRecordVoiceBroadcast(voiceBroadcast, chunkLength, maxLength) } - private fun sendVoiceFile(room: Room, voiceMessageFile: File, referenceEventId: String, sequence: Int) { + private fun sendVoiceFile(room: Room, voiceMessageFile: File, voiceBroadcast: VoiceBroadcast, sequence: Int) { val outputFileUri = FileProvider.getUriForFile( context, buildMeta.applicationId + ".fileProvider", @@ -101,10 +119,43 @@ class StartVoiceBroadcastUseCase @Inject constructor( attachment = audioType.toContentAttachmentData(isVoiceMessage = true), compressBeforeSending = false, roomIds = emptySet(), - relatesTo = RelationDefaultContent(RelationType.REFERENCE, referenceEventId), + relatesTo = RelationDefaultContent(RelationType.REFERENCE, voiceBroadcast.voiceBroadcastId), additionalContent = mapOf( VoiceBroadcastConstants.VOICE_BROADCAST_CHUNK_KEY to VoiceBroadcastChunk(sequence = sequence).toContent() ) ) } + + private fun assertCanStartVoiceBroadcast(room: Room) { + assertHasEnoughPowerLevels(room) + assertNoOngoingVoiceBroadcast(room) + } + + @VisibleForTesting + fun assertHasEnoughPowerLevels(room: Room) { + val powerLevelsHelper = room.getStateEvent(EventType.STATE_ROOM_POWER_LEVELS, QueryStringValue.IsEmpty) + ?.content + ?.toModel() + ?.let { PowerLevelsHelper(it) } + + if (powerLevelsHelper?.isUserAllowedToSend(session.myUserId, true, VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO) != true) { + Timber.d("## StartVoiceBroadcastUseCase: Cannot start voice broadcast: no permission") + throw VoiceBroadcastFailure.RecordingError.NoPermission + } + } + + @VisibleForTesting + fun assertNoOngoingVoiceBroadcast(room: Room) { + when { + voiceBroadcastRecorder?.recordingState == VoiceBroadcastRecorder.State.Recording || + voiceBroadcastRecorder?.recordingState == VoiceBroadcastRecorder.State.Paused -> { + Timber.d("## StartVoiceBroadcastUseCase: Cannot start voice broadcast: another voice broadcast") + throw VoiceBroadcastFailure.RecordingError.UserAlreadyBroadcasting + } + getRoomLiveVoiceBroadcastsUseCase.execute(room.roomId).isNotEmpty() -> { + Timber.d("## StartVoiceBroadcastUseCase: Cannot start voice broadcast: user already broadcasting") + throw VoiceBroadcastFailure.RecordingError.BlockedBySomeoneElse + } + } + } } diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/StopOngoingVoiceBroadcastUseCase.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/StopOngoingVoiceBroadcastUseCase.kt new file mode 100644 index 0000000000..fdbf1a067d --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/StopOngoingVoiceBroadcastUseCase.kt @@ -0,0 +1,65 @@ +/* + * Copyright (c) 2022 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 im.vector.app.features.voicebroadcast.recording.usecase + +import im.vector.app.core.di.ActiveSessionHolder +import im.vector.app.features.voicebroadcast.VoiceBroadcastHelper +import im.vector.app.features.voicebroadcast.model.asVoiceBroadcastEvent +import im.vector.app.features.voicebroadcast.usecase.GetRoomLiveVoiceBroadcastsUseCase +import org.matrix.android.sdk.api.query.QueryStringValue +import org.matrix.android.sdk.api.session.getRoom +import org.matrix.android.sdk.api.session.room.model.Membership +import org.matrix.android.sdk.api.session.room.roomSummaryQueryParams +import timber.log.Timber +import javax.inject.Inject + +/** + * Stop ongoing voice broadcast if any. + */ +class StopOngoingVoiceBroadcastUseCase @Inject constructor( + private val activeSessionHolder: ActiveSessionHolder, + private val getRoomLiveVoiceBroadcastsUseCase: GetRoomLiveVoiceBroadcastsUseCase, + private val voiceBroadcastHelper: VoiceBroadcastHelper, +) { + + suspend fun execute() { + Timber.d("## StopOngoingVoiceBroadcastUseCase: Stop ongoing voice broadcast requested") + + val session = activeSessionHolder.getSafeActiveSession() ?: run { + Timber.w("## StopOngoingVoiceBroadcastUseCase: no active session") + return + } + // FIXME Iterate only on recent rooms for the moment, improve this + val recentRooms = session.roomService() + .getBreadcrumbs(roomSummaryQueryParams { + displayName = QueryStringValue.NoCondition + memberships = listOf(Membership.JOIN) + }) + .mapNotNull { session.getRoom(it.roomId) } + + recentRooms + .forEach { room -> + val ongoingVoiceBroadcasts = getRoomLiveVoiceBroadcastsUseCase.execute(room.roomId) + val myOngoingVoiceBroadcastId = ongoingVoiceBroadcasts.find { it.root.stateKey == session.myUserId }?.reference?.eventId + val initialEvent = myOngoingVoiceBroadcastId?.let { room.timelineService().getTimelineEvent(it)?.root?.asVoiceBroadcastEvent() } + if (myOngoingVoiceBroadcastId != null && initialEvent?.content?.deviceId == session.sessionParams.deviceId) { + voiceBroadcastHelper.stopVoiceBroadcast(room.roomId) + return // No need to iterate more as we should not have more than one recording VB + } + } + } +} diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/StopVoiceBroadcastUseCase.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/StopVoiceBroadcastUseCase.kt similarity index 89% rename from vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/StopVoiceBroadcastUseCase.kt rename to vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/StopVoiceBroadcastUseCase.kt index bc6a3e7be6..b93bd346db 100644 --- a/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/StopVoiceBroadcastUseCase.kt +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/StopVoiceBroadcastUseCase.kt @@ -14,13 +14,13 @@ * limitations under the License. */ -package im.vector.app.features.voicebroadcast.usecase +package im.vector.app.features.voicebroadcast.recording.usecase import im.vector.app.features.voicebroadcast.VoiceBroadcastConstants -import im.vector.app.features.voicebroadcast.VoiceBroadcastRecorder import im.vector.app.features.voicebroadcast.model.MessageVoiceBroadcastInfoContent import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState import im.vector.app.features.voicebroadcast.model.asVoiceBroadcastEvent +import im.vector.app.features.voicebroadcast.recording.VoiceBroadcastRecorder import org.matrix.android.sdk.api.query.QueryStringValue import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.events.model.toContent @@ -54,17 +54,20 @@ class StopVoiceBroadcastUseCase @Inject constructor( private suspend fun stopVoiceBroadcast(room: Room, reference: RelationDefaultContent?) { Timber.d("## StopVoiceBroadcastUseCase: Send new voice broadcast info state event") + + // save the last sequence number and immediately stop the recording + val lastSequence = voiceBroadcastRecorder?.currentSequence + stopRecording() + room.stateService().sendStateEvent( eventType = VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO, stateKey = session.myUserId, body = MessageVoiceBroadcastInfoContent( relatesTo = reference, voiceBroadcastStateStr = VoiceBroadcastState.STOPPED.value, - lastChunkSequence = voiceBroadcastRecorder?.currentSequence, + lastChunkSequence = lastSequence, ).toContent(), ) - - stopRecording() } private fun stopRecording() { diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/GetVoiceBroadcastUseCase.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/GetRoomLiveVoiceBroadcastsUseCase.kt similarity index 55% rename from vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/GetVoiceBroadcastUseCase.kt rename to vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/GetRoomLiveVoiceBroadcastsUseCase.kt index d08fa14a95..fa5f06bfe6 100644 --- a/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/GetVoiceBroadcastUseCase.kt +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/GetRoomLiveVoiceBroadcastsUseCase.kt @@ -16,25 +16,28 @@ package im.vector.app.features.voicebroadcast.usecase +import im.vector.app.core.di.ActiveSessionHolder +import im.vector.app.features.voicebroadcast.VoiceBroadcastConstants +import im.vector.app.features.voicebroadcast.isLive import im.vector.app.features.voicebroadcast.model.VoiceBroadcastEvent import im.vector.app.features.voicebroadcast.model.asVoiceBroadcastEvent -import org.matrix.android.sdk.api.session.Session -import org.matrix.android.sdk.api.session.events.model.RelationType +import org.matrix.android.sdk.api.query.QueryStringValue import org.matrix.android.sdk.api.session.getRoom -import timber.log.Timber import javax.inject.Inject -class GetVoiceBroadcastUseCase @Inject constructor( - private val session: Session, +class GetRoomLiveVoiceBroadcastsUseCase @Inject constructor( + private val activeSessionHolder: ActiveSessionHolder, ) { - fun execute(roomId: String, eventId: String): VoiceBroadcastEvent? { + fun execute(roomId: String): List { + val session = activeSessionHolder.getSafeActiveSession() ?: return emptyList() val room = session.getRoom(roomId) ?: error("Unknown roomId: $roomId") - Timber.d("## GetVoiceBroadcastUseCase: get voice broadcast $eventId") - - val initialEvent = room.timelineService().getTimelineEvent(eventId)?.root?.asVoiceBroadcastEvent() // Fallback to initial event - val relatedEvents = room.timelineService().getTimelineEventsRelatedTo(RelationType.REFERENCE, eventId).sortedBy { it.root.originServerTs } - return relatedEvents.mapNotNull { it.root.asVoiceBroadcastEvent() }.lastOrNull() ?: initialEvent + return room.stateService().getStateEvents( + setOf(VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO), + QueryStringValue.IsNotEmpty + ) + .mapNotNull { it.asVoiceBroadcastEvent() } + .filter { it.isLive } } } diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/GetVoiceBroadcastStateEventLiveUseCase.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/GetVoiceBroadcastStateEventLiveUseCase.kt new file mode 100644 index 0000000000..b3bbdad635 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/GetVoiceBroadcastStateEventLiveUseCase.kt @@ -0,0 +1,160 @@ +/* + * Copyright (c) 2022 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 im.vector.app.features.voicebroadcast.usecase + +import im.vector.app.features.voicebroadcast.VoiceBroadcastConstants +import im.vector.app.features.voicebroadcast.model.VoiceBroadcast +import im.vector.app.features.voicebroadcast.model.VoiceBroadcastEvent +import im.vector.app.features.voicebroadcast.model.asVoiceBroadcastEvent +import im.vector.app.features.voicebroadcast.voiceBroadcastId +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.distinctUntilChangedBy +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.flow.transformWhile +import org.matrix.android.sdk.api.query.QueryStringValue +import org.matrix.android.sdk.api.session.Session +import org.matrix.android.sdk.api.session.events.model.RelationType +import org.matrix.android.sdk.api.session.getRoom +import org.matrix.android.sdk.api.session.room.Room +import org.matrix.android.sdk.api.util.Optional +import org.matrix.android.sdk.api.util.toOptional +import org.matrix.android.sdk.flow.flow +import org.matrix.android.sdk.flow.mapOptional +import timber.log.Timber +import javax.inject.Inject + +class GetVoiceBroadcastStateEventLiveUseCase @Inject constructor( + private val session: Session, +) { + + fun execute(voiceBroadcast: VoiceBroadcast): Flow> { + val room = session.getRoom(voiceBroadcast.roomId) ?: error("Unknown roomId: ${voiceBroadcast.roomId}") + return getMostRecentVoiceBroadcastEventFlow(room, voiceBroadcast) + .onEach { event -> + Timber.d( + "## VoiceBroadcast | " + + "voiceBroadcastId=${event.getOrNull()?.voiceBroadcastId}, " + + "state=${event.getOrNull()?.content?.voiceBroadcastState}" + ) + } + } + + /** + * Get a flow of the most recent event for the given voice broadcast. + */ + private fun getMostRecentVoiceBroadcastEventFlow(room: Room, voiceBroadcast: VoiceBroadcast): Flow> { + val startedEventFlow = room.flow().liveTimelineEvent(voiceBroadcast.voiceBroadcastId) + // observe started event changes + return startedEventFlow + .mapOptional { it.root.asVoiceBroadcastEvent() } + .flatMapLatest { startedEvent -> + if (startedEvent.hasValue().not() || startedEvent.get().root.isRedacted()) { + // if started event is null or redacted, send null + flowOf(Optional.empty()) + } else { + // otherwise, observe most recent event changes + getMostRecentRelatedEventFlow(room, voiceBroadcast) + .transformWhile { mostRecentEvent -> + val hasValue = mostRecentEvent.hasValue() + if (hasValue) { + // keep the most recent event + emit(mostRecentEvent) + } else { + // no most recent event, fallback to started event + emit(startedEvent) + } + hasValue + } + } + } + .distinctUntilChangedBy { it.getOrNull()?.content?.voiceBroadcastState } + } + + /** + * Get a flow of the most recent related event. + */ + private fun getMostRecentRelatedEventFlow(room: Room, voiceBroadcast: VoiceBroadcast): Flow> { + val mostRecentEvent = getMostRecentRelatedEvent(room, voiceBroadcast).toOptional() + return if (mostRecentEvent.hasValue()) { + val stateKey = mostRecentEvent.get().root.stateKey.orEmpty() + // observe incoming voice broadcast state events + room.flow() + .liveStateEvent(VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO, QueryStringValue.Equals(stateKey)) + .mapOptional { it.asVoiceBroadcastEvent() } + // drop first event sent by the matrix-sdk, we compute manually this first event + .drop(1) + // start with the computed most recent event + .onStart { emit(mostRecentEvent) } + // handle event if null or related to the given voice broadcast + .filter { it.hasValue().not() || it.get().voiceBroadcastId == voiceBroadcast.voiceBroadcastId } + // observe changes while event is not null + .transformWhile { event -> + emit(event) + event.hasValue() + } + .flatMapLatest { newMostRecentEvent -> + if (newMostRecentEvent.hasValue()) { + // observe most recent event changes + newMostRecentEvent.get().flow() + .transformWhile { event -> + // observe changes until event is null or redacted + emit(event) + event.hasValue() && event.get().root.isRedacted().not() + } + .flatMapLatest { event -> + val isRedactedOrNull = !event.hasValue() || event.get().root.isRedacted() + if (isRedactedOrNull) { + // event is null or redacted, switch to the latest not redacted event + getMostRecentRelatedEventFlow(room, voiceBroadcast) + } else { + // event is not redacted, send the event + flowOf(event) + } + } + } else { + // there is no more most recent event, just send it + flowOf(newMostRecentEvent) + } + } + } else { + // there is no more most recent event, just send it + flowOf(mostRecentEvent) + } + } + + /** + * Get the most recent event related to the given voice broadcast. + */ + private fun getMostRecentRelatedEvent(room: Room, voiceBroadcast: VoiceBroadcast): VoiceBroadcastEvent? { + return room.timelineService().getTimelineEventsRelatedTo(RelationType.REFERENCE, voiceBroadcast.voiceBroadcastId) + .mapNotNull { timelineEvent -> timelineEvent.root.asVoiceBroadcastEvent()?.takeUnless { it.root.isRedacted() } } + .maxByOrNull { it.root.originServerTs ?: 0 } + } + + /** + * Get a flow of the given voice broadcast event changes. + */ + private fun VoiceBroadcastEvent.flow(): Flow> { + val room = this.root.roomId?.let { session.getRoom(it) } ?: return flowOf(Optional.empty()) + return room.flow().liveTimelineEvent(root.eventId!!).mapOptional { it.root.asVoiceBroadcastEvent() } + } +} diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/views/VoiceBroadcastBufferingView.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/views/VoiceBroadcastBufferingView.kt new file mode 100644 index 0000000000..eabefa323e --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/views/VoiceBroadcastBufferingView.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2022 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 im.vector.app.features.voicebroadcast.views + +import android.content.Context +import android.util.AttributeSet +import android.view.LayoutInflater +import android.widget.LinearLayout +import im.vector.app.databinding.ViewVoiceBroadcastBufferingBinding + +class VoiceBroadcastBufferingView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : LinearLayout(context, attrs, defStyleAttr) { + + init { + ViewVoiceBroadcastBufferingBinding.inflate( + LayoutInflater.from(context), + this + ) + } +} diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/views/VoiceBroadcastMetadataView.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/views/VoiceBroadcastMetadataView.kt new file mode 100644 index 0000000000..c743d8a542 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/views/VoiceBroadcastMetadataView.kt @@ -0,0 +1,66 @@ +/* + * Copyright (c) 2022 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 im.vector.app.features.voicebroadcast.views + +import android.content.Context +import android.content.res.TypedArray +import android.util.AttributeSet +import android.view.LayoutInflater +import android.widget.LinearLayout +import androidx.core.content.res.use +import im.vector.app.R +import im.vector.app.databinding.ViewVoiceBroadcastMetadataBinding + +class VoiceBroadcastMetadataView @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, + defStyleAttr: Int = 0 +) : LinearLayout(context, attrs, defStyleAttr) { + + private val views = ViewVoiceBroadcastMetadataBinding.inflate( + LayoutInflater.from(context), + this + ) + + var value: String + get() = views.metadataText.text.toString() + set(newValue) { + views.metadataText.text = newValue + } + + init { + context.obtainStyledAttributes( + attrs, + R.styleable.VoiceBroadcastMetadataView, + 0, + 0 + ).use { + setIcon(it) + setValue(it) + } + } + + private fun setIcon(typedArray: TypedArray) { + val icon = typedArray.getDrawable(R.styleable.VoiceBroadcastMetadataView_metadataIcon) + views.metadataIcon.setImageDrawable(icon) + } + + private fun setValue(typedArray: TypedArray) { + val value = typedArray.getString(R.styleable.VoiceBroadcastMetadataView_metadataValue) + views.metadataText.text = value + } +} diff --git a/vector/src/main/java/im/vector/app/features/workers/signout/ServerBackupStatusViewModel.kt b/vector/src/main/java/im/vector/app/features/workers/signout/ServerBackupStatusViewModel.kt index f3eb04b54e..1fef4c8e66 100644 --- a/vector/src/main/java/im/vector/app/features/workers/signout/ServerBackupStatusViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/workers/signout/ServerBackupStatusViewModel.kt @@ -26,12 +26,12 @@ import com.airbnb.mvrx.Uninitialized import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject -import im.vector.app.BuildConfig import im.vector.app.core.di.DefaultPreferences import im.vector.app.core.di.MavericksAssistedViewModelFactory import im.vector.app.core.di.hiltMavericksViewModelFactory import im.vector.app.core.platform.EmptyViewEvents import im.vector.app.core.platform.VectorViewModel +import im.vector.app.features.VectorFeatures import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.distinctUntilChanged @@ -76,6 +76,7 @@ sealed interface BannerState { class ServerBackupStatusViewModel @AssistedInject constructor( @Assisted initialState: ServerBackupStatusViewState, + private val vectorFeatures: VectorFeatures, private val session: Session, @DefaultPreferences private val sharedPreferences: SharedPreferences, @@ -111,8 +112,8 @@ class ServerBackupStatusViewModel @AssistedInject constructor( private val keyBackupFlow = MutableSharedFlow(0) init { - // if key backup is not supported, do nothing - if (BuildConfig.IS_KEY_BACKUP_SUPPORTED) { + // Tchap: if key backup is not supported, do nothing + if (vectorFeatures.tchapIsKeyBackupEnabled()) { init() } } diff --git a/vector/src/main/java/im/vector/app/features/workers/signout/SignoutCheckViewModel.kt b/vector/src/main/java/im/vector/app/features/workers/signout/SignoutCheckViewModel.kt index 8e56e4f035..be3408a69e 100644 --- a/vector/src/main/java/im/vector/app/features/workers/signout/SignoutCheckViewModel.kt +++ b/vector/src/main/java/im/vector/app/features/workers/signout/SignoutCheckViewModel.kt @@ -23,19 +23,21 @@ import com.airbnb.mvrx.MavericksState import com.airbnb.mvrx.MavericksViewModelFactory import com.airbnb.mvrx.Success import com.airbnb.mvrx.Uninitialized +import com.airbnb.mvrx.ViewModelContext import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject -import im.vector.app.BuildConfig import im.vector.app.core.di.MavericksAssistedViewModelFactory import im.vector.app.core.di.hiltMavericksViewModelFactory import im.vector.app.core.platform.EmptyViewEvents +import im.vector.app.core.platform.VectorBaseActivity import im.vector.app.core.platform.VectorViewModel import im.vector.app.core.platform.VectorViewModelAction import im.vector.app.features.crypto.keys.KeysExporter import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch +import org.matrix.android.sdk.api.extensions.orFalse import org.matrix.android.sdk.api.session.Session import org.matrix.android.sdk.api.session.crypto.crosssigning.MASTER_KEY_SSSS_NAME import org.matrix.android.sdk.api.session.crypto.crosssigning.SELF_SIGNING_KEY_SSSS_NAME @@ -51,7 +53,7 @@ data class SignoutCheckViewState( val crossSigningSetupAllKeysKnown: Boolean = false, val keysBackupState: KeysBackupState = KeysBackupState.Unknown, val hasBeenExportedToFile: Async = Uninitialized, - val isKeyBackupSupported: Boolean = BuildConfig.IS_KEY_BACKUP_SUPPORTED + val isKeyBackupSupported: Boolean = false ) : MavericksState class SignoutCheckViewModel @AssistedInject constructor( @@ -70,7 +72,13 @@ class SignoutCheckViewModel @AssistedInject constructor( override fun create(initialState: SignoutCheckViewState): SignoutCheckViewModel } - companion object : MavericksViewModelFactory by hiltMavericksViewModelFactory() + // Tchap: init viewState with key backup + companion object : MavericksViewModelFactory by hiltMavericksViewModelFactory() { + override fun initialState(viewModelContext: ViewModelContext): SignoutCheckViewState? { + val isKeyBackupEnabled = (viewModelContext.activity as? VectorBaseActivity<*>)?.vectorFeatures?.tchapIsKeyBackupEnabled().orFalse() + return SignoutCheckViewState(isKeyBackupSupported = isKeyBackupEnabled) + } + } init { withState { state -> diff --git a/vector/src/main/res/drawable/bg_composer_rich_bottom_sheet.xml b/vector/src/main/res/drawable/bg_composer_rich_bottom_sheet.xml new file mode 100644 index 0000000000..47364373f7 --- /dev/null +++ b/vector/src/main/res/drawable/bg_composer_rich_bottom_sheet.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/vector/src/main/res/drawable/bg_composer_rich_edit_text_expanded.xml b/vector/src/main/res/drawable/bg_composer_rich_edit_text_expanded.xml deleted file mode 100644 index 26d997e7db..0000000000 --- a/vector/src/main/res/drawable/bg_composer_rich_edit_text_expanded.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - diff --git a/vector/src/main/res/drawable/bg_composer_rich_edit_text_single_line.xml b/vector/src/main/res/drawable/bg_composer_rich_edit_text_single_line.xml deleted file mode 100644 index 7e2745a137..0000000000 --- a/vector/src/main/res/drawable/bg_composer_rich_edit_text_single_line.xml +++ /dev/null @@ -1,13 +0,0 @@ - - - - - - - - - - diff --git a/vector/src/main/res/drawable/bg_seek_bar.xml b/vector/src/main/res/drawable/bg_seek_bar.xml index 0a33522dfd..eff461091e 100644 --- a/vector/src/main/res/drawable/bg_seek_bar.xml +++ b/vector/src/main/res/drawable/bg_seek_bar.xml @@ -13,9 +13,9 @@ - \ No newline at end of file + diff --git a/vector/src/main/res/drawable/bottomsheet_handle.xml b/vector/src/main/res/drawable/bottomsheet_handle.xml new file mode 100644 index 0000000000..89ccf57ed0 --- /dev/null +++ b/vector/src/main/res/drawable/bottomsheet_handle.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/vector/src/main/res/drawable/ic_composer_collapse.xml b/vector/src/main/res/drawable/ic_composer_collapse.xml new file mode 100644 index 0000000000..724a833761 --- /dev/null +++ b/vector/src/main/res/drawable/ic_composer_collapse.xml @@ -0,0 +1,9 @@ + + + diff --git a/vector/src/main/res/drawable/ic_composer_full_screen.xml b/vector/src/main/res/drawable/ic_composer_full_screen.xml new file mode 100644 index 0000000000..de1862c09b --- /dev/null +++ b/vector/src/main/res/drawable/ic_composer_full_screen.xml @@ -0,0 +1,9 @@ + + + diff --git a/vector/src/main/res/drawable/ic_composer_link.xml b/vector/src/main/res/drawable/ic_composer_link.xml new file mode 100644 index 0000000000..6d0f731ed9 --- /dev/null +++ b/vector/src/main/res/drawable/ic_composer_link.xml @@ -0,0 +1,12 @@ + + + diff --git a/vector/src/main/res/drawable/ic_composer_rich_mic_pressed.xml b/vector/src/main/res/drawable/ic_composer_rich_mic_pressed.xml new file mode 100644 index 0000000000..e9dbe610e4 --- /dev/null +++ b/vector/src/main/res/drawable/ic_composer_rich_mic_pressed.xml @@ -0,0 +1,17 @@ + + + + + diff --git a/vector/src/main/res/drawable/ic_composer_rich_text_editor_close.xml b/vector/src/main/res/drawable/ic_composer_rich_text_editor_close.xml new file mode 100644 index 0000000000..c461470de5 --- /dev/null +++ b/vector/src/main/res/drawable/ic_composer_rich_text_editor_close.xml @@ -0,0 +1,9 @@ + + + diff --git a/vector/src/main/res/drawable/ic_composer_rich_text_editor_edit.xml b/vector/src/main/res/drawable/ic_composer_rich_text_editor_edit.xml new file mode 100644 index 0000000000..4556974221 --- /dev/null +++ b/vector/src/main/res/drawable/ic_composer_rich_text_editor_edit.xml @@ -0,0 +1,12 @@ + + + + diff --git a/vector/src/main/res/drawable/ic_composer_rich_text_save.xml b/vector/src/main/res/drawable/ic_composer_rich_text_save.xml new file mode 100644 index 0000000000..f270d6f8ae --- /dev/null +++ b/vector/src/main/res/drawable/ic_composer_rich_text_save.xml @@ -0,0 +1,16 @@ + + + + diff --git a/vector/src/main/res/drawable/ic_player_backward_30.xml b/vector/src/main/res/drawable/ic_player_backward_30.xml new file mode 100644 index 0000000000..cb244806b3 --- /dev/null +++ b/vector/src/main/res/drawable/ic_player_backward_30.xml @@ -0,0 +1,12 @@ + + + + diff --git a/vector/src/main/res/drawable/ic_player_forward_30.xml b/vector/src/main/res/drawable/ic_player_forward_30.xml new file mode 100644 index 0000000000..be61fda8ff --- /dev/null +++ b/vector/src/main/res/drawable/ic_player_forward_30.xml @@ -0,0 +1,12 @@ + + + + diff --git a/vector/src/main/res/drawable/ic_rich_composer_add.xml b/vector/src/main/res/drawable/ic_rich_composer_add.xml new file mode 100644 index 0000000000..3a90a40902 --- /dev/null +++ b/vector/src/main/res/drawable/ic_rich_composer_add.xml @@ -0,0 +1,15 @@ + + + + diff --git a/vector/src/main/res/drawable/ic_rich_composer_send.xml b/vector/src/main/res/drawable/ic_rich_composer_send.xml new file mode 100644 index 0000000000..0f99c1670e --- /dev/null +++ b/vector/src/main/res/drawable/ic_rich_composer_send.xml @@ -0,0 +1,12 @@ + + + + diff --git a/vector/src/main/res/drawable/ic_text_formatting.xml b/vector/src/main/res/drawable/ic_text_formatting.xml new file mode 100644 index 0000000000..375c459692 --- /dev/null +++ b/vector/src/main/res/drawable/ic_text_formatting.xml @@ -0,0 +1,13 @@ + + + + + + diff --git a/vector/src/main/res/drawable/ic_text_formatting_disabled.xml b/vector/src/main/res/drawable/ic_text_formatting_disabled.xml new file mode 100644 index 0000000000..bb34211c7a --- /dev/null +++ b/vector/src/main/res/drawable/ic_text_formatting_disabled.xml @@ -0,0 +1,18 @@ + + + + + + + diff --git a/vector/src/main/res/drawable/ic_timer.xml b/vector/src/main/res/drawable/ic_timer.xml new file mode 100644 index 0000000000..11a42b0696 --- /dev/null +++ b/vector/src/main/res/drawable/ic_timer.xml @@ -0,0 +1,9 @@ + + + diff --git a/vector/src/main/res/drawable/ic_voice_broadcast_16.xml b/vector/src/main/res/drawable/ic_voice_broadcast.xml similarity index 100% rename from vector/src/main/res/drawable/ic_voice_broadcast_16.xml rename to vector/src/main/res/drawable/ic_voice_broadcast.xml diff --git a/vector/src/main/res/drawable/ic_voice_broadcast_mic.xml b/vector/src/main/res/drawable/ic_voice_broadcast_mic.xml new file mode 100644 index 0000000000..edadb55b81 --- /dev/null +++ b/vector/src/main/res/drawable/ic_voice_broadcast_mic.xml @@ -0,0 +1,12 @@ + + + + diff --git a/vector/src/main/res/drawable/ic_voice_mic_recording.xml b/vector/src/main/res/drawable/ic_voice_mic_recording.xml deleted file mode 100644 index a57852c92f..0000000000 --- a/vector/src/main/res/drawable/ic_voice_mic_recording.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - diff --git a/vector/src/main/res/layout/bottom_sheet_attachment_type_selector.xml b/vector/src/main/res/layout/bottom_sheet_attachment_type_selector.xml index 79a60624cf..7a22ab57f8 100644 --- a/vector/src/main/res/layout/bottom_sheet_attachment_type_selector.xml +++ b/vector/src/main/res/layout/bottom_sheet_attachment_type_selector.xml @@ -1,6 +1,7 @@ @@ -82,5 +83,24 @@ app:tint="?colorPrimary" app:titleTextColor="?vctr_content_primary" /> + + + + diff --git a/vector/src/main/res/layout/bottom_sheet_device_manager_filter.xml b/vector/src/main/res/layout/bottom_sheet_device_manager_filter.xml index a7987e70b5..fd66aec1ea 100644 --- a/vector/src/main/res/layout/bottom_sheet_device_manager_filter.xml +++ b/vector/src/main/res/layout/bottom_sheet_device_manager_filter.xml @@ -2,9 +2,7 @@ + android:orientation="vertical"> - + android:layout_height="match_parent" + android:clipToPadding="false" + android:paddingHorizontal="24dp" + android:paddingBottom="32dp" + android:scrollbarStyle="outsideOverlay"> - + android:paddingTop="24dp" + android:showDividers="none"> - + - + - + - + - + - + + + + + - + diff --git a/vector/src/main/res/layout/composer_layout.xml b/vector/src/main/res/layout/composer_layout.xml index fb0d80278a..7c465891c3 100644 --- a/vector/src/main/res/layout/composer_layout.xml +++ b/vector/src/main/res/layout/composer_layout.xml @@ -1,148 +1,210 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - + + - - - - - - - - + android:visibility="gone" + tools:visibility="visible"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/vector/src/main/res/layout/composer_layout_constraint_set_compact.xml b/vector/src/main/res/layout/composer_layout_constraint_set_compact.xml deleted file mode 100644 index 81b978caa6..0000000000 --- a/vector/src/main/res/layout/composer_layout_constraint_set_compact.xml +++ /dev/null @@ -1,197 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/vector/src/main/res/layout/composer_layout_constraint_set_expanded.xml b/vector/src/main/res/layout/composer_layout_constraint_set_expanded.xml deleted file mode 100644 index 8cdb388bf9..0000000000 --- a/vector/src/main/res/layout/composer_layout_constraint_set_expanded.xml +++ /dev/null @@ -1,197 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/vector/src/main/res/layout/composer_rich_text_layout.xml b/vector/src/main/res/layout/composer_rich_text_layout.xml index 09e4b03887..3484616c72 100644 --- a/vector/src/main/res/layout/composer_rich_text_layout.xml +++ b/vector/src/main/res/layout/composer_rich_text_layout.xml @@ -1,158 +1,202 @@ - - - - - - - - - - - - - - - - - - - - - - + android:layout_height="match_parent" + android:orientation="vertical" + android:background="@drawable/bg_composer_rich_bottom_sheet"> - - - - - - - - + + + + + + + + + + + + + + + + + + android:src="@drawable/ic_composer_rich_text_editor_close" + android:background="?android:selectableItemBackground" + android:layout_marginEnd="12dp" + android:contentDescription="@string/action_close" + app:layout_constraintTop_toTopOf="@id/composerModeIconView" + app:layout_constraintEnd_toEndOf="@id/composerEditTextOuterBorder" /> + + + + + + + + + + + + + + + + - + - + - + - + diff --git a/vector/src/main/res/layout/composer_rich_text_layout_constraint_set_compact.xml b/vector/src/main/res/layout/composer_rich_text_layout_constraint_set_compact.xml deleted file mode 100644 index 391a4596bf..0000000000 --- a/vector/src/main/res/layout/composer_rich_text_layout_constraint_set_compact.xml +++ /dev/null @@ -1,202 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/vector/src/main/res/layout/composer_rich_text_layout_constraint_set_expanded.xml b/vector/src/main/res/layout/composer_rich_text_layout_constraint_set_expanded.xml deleted file mode 100644 index aa372165b8..0000000000 --- a/vector/src/main/res/layout/composer_rich_text_layout_constraint_set_expanded.xml +++ /dev/null @@ -1,202 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/vector/src/main/res/layout/fragment_composer.xml b/vector/src/main/res/layout/fragment_composer.xml index 8703af7471..5038a9e179 100644 --- a/vector/src/main/res/layout/fragment_composer.xml +++ b/vector/src/main/res/layout/fragment_composer.xml @@ -4,12 +4,13 @@ xmlns:app="http://schemas.android.com/apk/res-auto" xmlns:tools="http://schemas.android.com/tools" android:layout_width="match_parent" - android:layout_height="wrap_content"> + android:layout_height="match_parent" + android:background="@android:color/transparent"> - - + android:layout_height="match_parent" + app:layout_behavior="@string/appbar_scrolling_view_behavior"> - + android:layout_marginStart="72dp" + android:layout_marginTop="32dp" + android:layout_marginEnd="16dp" + android:orientation="vertical" + app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintTop_toTopOf="parent"> + + - + android:layout_marginTop="20dp" + android:gravity="start" + android:padding="0dp" + android:text="@string/device_manager_other_sessions_clear_filter" /> - + + + + + + + + + + + + + + + + + android:layout_gravity="end" + android:layout_marginEnd="8dp" + android:padding="8dp"> - + - + - + - + + - - - - - - - + -