diff --git a/.github/ISSUE_TEMPLATE/release.yml b/.github/ISSUE_TEMPLATE/release.yml index a84f4dfd3b..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,27 +23,7 @@ body: ### Do the release - - [ ] Make sure `develop` and `main` are up to date and create a release with gitflow: `git checkout main; git pull; git checkout develop; git pull; git flow release start '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): `git flow release finish '1.2.3'` - - [ ] Push `main` and the new tag `v1.2.3` to origin: `git push origin main; git push origin 'v1.2.3'` - - [ ] Checkout `develop`: `git 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`: `git commit -m 'version++'; git push origin 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 @@ -81,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 @@ -111,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/post-pr.yml b/.github/workflows/post-pr.yml index aac4fffa4e..af854bf371 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 @@ -27,11 +27,12 @@ jobs: if: github.event.pull_request.merged # Additionally require PR to have been completely merged. steps: - run: echo "Run those tests!" # no-op success - + ui-tests: 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/tests.yml b/.github/workflows/tests.yml index bb16d8abe8..931ec2da45 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] @@ -126,26 +127,26 @@ 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..f1458a1d11 100644 --- a/.github/workflows/triage-labelled.yml +++ b/.github/workflows/triage-labelled.yml @@ -79,8 +79,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 } } @@ -103,8 +103,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 } } @@ -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 } } @@ -154,8 +154,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 } } @@ -178,8 +178,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 } } @@ -203,8 +203,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 } } @@ -228,8 +228,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 } } @@ -258,8 +258,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 } } diff --git a/7658.bugfix b/7658.bugfix new file mode 100644 index 0000000000..a5ab85b191 --- /dev/null +++ b/7658.bugfix @@ -0,0 +1 @@ +[Rich text editor] Fix design and spacing of rich text editor diff --git a/CHANGES.md b/CHANGES.md index 442d3641dd..022591f5a1 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,39 @@ +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) ====================================== diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6e3c784dac..40ae848415 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -13,6 +13,7 @@ * [Code quality](#code-quality) * [Internal tool](#internal-tool) * [ktlint](#ktlint) + * [knit](#knit) * [lint](#lint) * [Unit tests](#unit-tests) * [Tests](#tests) @@ -126,6 +127,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/README.md b/README.md index e351b64927..e8fceb2eb2 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -[![Buildkite](https://badge.buildkite.com/ad0065c1b70f557cd3b1d3d68f9c2154010f83c4d6f71706a9.svg?branch=develop)](https://buildkite.com/matrix-dot-org/element-android/builds?branch=develop) +[![Latest build](https://github.com/vector-im/element-android/actions/workflows/build.yml/badge.svg?query=branch%3Adevelop)](https://github.com/vector-im/element-android/actions/workflows/build.yml?query=branch%3Adevelop) [![Weblate](https://translate.element.io/widgets/element-android/-/svg-badge.svg)](https://translate.element.io/engage/element-android/?utm_source=widget) [![Element Android Matrix room #element-android:matrix.org](https://img.shields.io/matrix/element-android:matrix.org.svg?label=%23element-android:matrix.org&logo=matrix&server_fqdn=matrix.org)](https://matrix.to/#/#element-android:matrix.org) [![Quality Gate Status](https://sonarcloud.io/api/project_badges/measure?project=vector-im_element-android&metric=alert_status)](https://sonarcloud.io/summary/new_code?id=vector-im_element-android) @@ -14,7 +14,7 @@ It is a total rewrite of [Riot-Android](https://github.com/vector-im/riot-androi [](https://play.google.com/store/apps/details?id=im.vector.app) [](https://f-droid.org/app/im.vector.app) -Nightly build: [![Buildkite](https://badge.buildkite.com/ad0065c1b70f557cd3b1d3d68f9c2154010f83c4d6f71706a9.svg?branch=develop)](https://buildkite.com/matrix-dot-org/element-android/builds?branch=develop) Nightly test status: [![allScreensTest](https://github.com/vector-im/element-android/actions/workflows/nightly.yml/badge.svg)](https://github.com/vector-im/element-android/actions/workflows/nightly.yml) +Build of develop branch: [![GitHub Action](https://github.com/vector-im/element-android/actions/workflows/build.yml/badge.svg?query=branch%3Adevelop)](https://github.com/vector-im/element-android/actions/workflows/build.yml?query=branch%3Adevelop) Nightly test status: [![allScreensTest](https://github.com/vector-im/element-android/actions/workflows/nightly.yml/badge.svg)](https://github.com/vector-im/element-android/actions/workflows/nightly.yml) # New Android SDK @@ -40,7 +40,7 @@ If you would like to receive releases more quickly (bearing in mind that they ma 1. [Sign up to receive beta releases](https://play.google.com/apps/testing/im.vector.app) via the Google Play Store. 2. Install a [release APK](https://github.com/vector-im/element-android/releases) directly - download the relevant .apk file and allow installing from untrusted sources in your device settings. Note: these releases are the Google Play version, which depend on some Google services. If you prefer to avoid that, try the latest dev builds, and choose the F-Droid version. -3. If you're really brave, install the [very latest dev build](https://buildkite.com/matrix-dot-org/element-android/builds/latest?branch=develop&state=passed) - click on *Assemble (GPlay or FDroid) Debug version* then on *Artifacts*. +3. If you're really brave, install the [very latest dev build](https://github.com/vector-im/element-android/actions/workflows/build.yml?query=branch%3Adevelop) - pick a build, then click on `Summary` to download the APKs from there: `vector-Fdroid-debug` and `vector-Gplay-debug` contains the APK for the desired store. Each file contains 5 APKs. 4 APKs for every supported specific architecture of device. In doubt you can install the `universal` APK. ## Contributing diff --git a/build.gradle b/build.gradle index 78cc9abb02..51604b67a8 100644 --- a/build.gradle +++ b/build.gradle @@ -24,7 +24,7 @@ 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.5.0.2730' classpath 'com.google.android.gms:oss-licenses-plugin:0.10.5' @@ -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.21-1.0.8" // Dependency Analysis - id 'com.autonomousapps.dependency-analysis' version "1.13.1" + id 'com.autonomousapps.dependency-analysis' version "1.16.0" // Gradle doctor id "com.osacky.doctor" version "0.8.1" } diff --git a/dependencies.gradle b/dependencies.gradle index dfe8870484..b0dc1820b5 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -10,7 +10,7 @@ def gradle = "7.3.1" // Ref: https://kotlinlang.org/releases.html def kotlin = "1.7.21" 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" @@ -84,7 +84,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.13.0" + 'phonenumber' : "com.googlecode.libphonenumber:libphonenumber:8.13.1" ], dagger : [ 'dagger' : "com.google.dagger:dagger:$dagger", @@ -99,7 +99,7 @@ ext.libs = [ ], element : [ 'opusencoder' : "io.element.android:opusencoder:1.1.0", - 'wysiwyg' : "io.element.android:wysiwyg:0.4.0" + 'wysiwyg' : "io.element.android:wysiwyg:0.7.0.1" ], squareup : [ 'moshi' : "com.squareup.moshi:moshi:$moshi", 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/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/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/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/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/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/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/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/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/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/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/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/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/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/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/library/ui-strings/src/main/res/values-ca/strings.xml b/library/ui-strings/src/main/res/values-ca/strings.xml index f9d7145b66..7e3e019ee5 100644 --- a/library/ui-strings/src/main/res/values-ca/strings.xml +++ b/library/ui-strings/src/main/res/values-ca/strings.xml @@ -2837,4 +2837,6 @@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 47caa52149..f260a129fc 100644 --- a/library/ui-strings/src/main/res/values-cs/strings.xml +++ b/library/ui-strings/src/main/res/values-cs/strings.xml @@ -2899,4 +2899,27 @@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 \ 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 cd215e175d..be53c15026 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. @@ -2843,4 +2843,26 @@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 \ 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 ++ + \ 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 22572a0f36..156221379d 100644 --- a/library/ui-strings/src/main/res/values-et/strings.xml +++ b/library/ui-strings/src/main/res/values-et/strings.xml @@ -2835,4 +2835,26 @@- %1$d seleccionado
+- %1$d seleccionados
+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. \ 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 313734290f..cc8d60a87b 100644 --- a/library/ui-strings/src/main/res/values-fa/strings.xml +++ b/library/ui-strings/src/main/res/values-fa/strings.xml @@ -2825,4 +2825,23 @@۳۰ ثانیه پیشروی ۳۰ ثانیه پسروی قالببندی متن +خروج ++ +- خروج از ۱ نشست
+- خروج از %1$d نشست
+%1$s مانده +پیام صوتیای فرستاد. +پروندهٔ صوتیای فرستاد. +نظرسنجیای ایجاد کرد. +عکسبرگردانی فرستاد. +ویدیویی فرستاد. +تصویری فرستاد. +پروندهای فرستاد. +در پاسخ به +نهفتن نشانی آیپی +نمایش نشانی آیپی +نقل کردن +پاسخ دادن به %s +ویرایش کردن \ 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 a02b062596..cf49733bdf 100644 --- a/library/ui-strings/src/main/res/values-fr/strings.xml +++ b/library/ui-strings/src/main/res/values-fr/strings.xml @@ -2844,4 +2844,26 @@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 \ No newline at end of file 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 cde367faf9..ce9e524067 100644 --- a/library/ui-strings/src/main/res/values-in/strings.xml +++ b/library/ui-strings/src/main/res/values-in/strings.xml @@ -2791,4 +2791,25 @@ Di masa mendatang proses verifikasi ini akan dimutakhirkan.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 \ 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..d244f26a43 100644 --- a/library/ui-strings/src/main/res/values-it/strings.xml +++ b/library/ui-strings/src/main/res/values-it/strings.xml @@ -2827,4 +2827,34 @@- %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 \ 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 d3061371fa..8baba5df53 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 @@ -2844,4 +2844,26 @@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ê está 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 necessitadas 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 9eac092a62..078ffc44eb 100644 --- a/library/ui-strings/src/main/res/values-sk/strings.xml +++ b/library/ui-strings/src/main/res/values-sk/strings.xml @@ -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 @@ -2899,4 +2899,27 @@ 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 \ 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 773454c39f..800ec17dcf 100644 --- a/library/ui-strings/src/main/res/values-sq/strings.xml +++ b/library/ui-strings/src/main/res/values-sq/strings.xml @@ -2822,4 +2822,33 @@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ë \ 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..65318096c7 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,20 @@- %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 \ 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 8cbfeca6ba..19889892ff 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 @@Вхід з іншого пристрою вже виконано. Під час налаштування захищеного обміну повідомленнями виникла проблема з безпекою. Можливо, порушено одне з таких налаштувань: Ваш домашній сервер; Ваше інтернет-з\'єднання; Ваш пристрій; Запит не виконаний. -Можливість записувати та надсилати голосові повідомлення до стрічки кімнати. -Увімкнути голосові повідомлення (в активній розробці) +Можливість записувати та надсилати голосові трансляції до стрічки кімнати. +Увімкнути голосові трансляції (в активній розробці) Буферизація -Призупинити голосове повідомлення -Відтворити або поновити відтворення голосового повідомлення -Припинити запис голосового повідомлення -Призупинити запис голосового повідомлення -Відновити запис голосового повідомлення +Призупинити голосову трансляцію +Відтворити або поновити відтворення голосової трансляції +Припинити запис голосової трансляції +Призупинити запис голосової трансляції +Відновити запис голосової трансляції Наживо Вибрати сеанси Контакт Камера Місце перебування Опитування -Голосові повідомлення +Голосові трансляції Вкладення Наліпки Фотобібліотека @@ -2948,10 +2948,34 @@Вибрати все Перемкнути повноекранний режим Форматування тексту -Ви вже записуєте голосове повідомлення. Завершіть поточну трансляцію, щоб розпочати нову. -Хтось інший вже записує голосове повідомлення. Зачекайте, поки закінчиться трансляція, щоб розпочати нову. -Ви не маєте необхідних дозволів для початку передавання голосового повідомлення в цю кімнату. Зверніться до адміністратора кімнати, щоб оновити ваші дозволи. -Не вдалося розпочати передавання нового голосового повідомлення +Ви вже записуєте голосову трансляцію. Завершіть поточну трансляцію, щоб розпочати нову. +Хтось інший вже записує голосову трансляцію. Зачекайте, поки вона завершиться, щоб розпочати нову. +Ви не маєте необхідних дозволів для початку голосової трансляції в цю кімнату. Зверніться до адміністратора кімнати, щоб оновити ваші дозволи. +Не вдалося розпочати нову голосову трансляцію Перемотати вперед на 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-rTW/strings.xml b/library/ui-strings/src/main/res/values-zh-rTW/strings.xml index 91e08c803a..dc5f6d85e3 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 @@ -2789,4 +2789,25 @@無法開始新的語音廣播 快轉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/strings.xml b/library/ui-strings/src/main/res/values/strings.xml index e503cb3fe7..58fc62b347 100644 --- a/library/ui-strings/src/main/res/values/strings.xml +++ b/library/ui-strings/src/main/res/values/strings.xml @@ -1032,6 +1032,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 @@ -1642,7 +1644,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 @@ -3089,12 +3094,13 @@(%1$s) Live + +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 @@ -3353,6 +3359,8 @@- Sign out of %1$d session
- Sign out of %1$d sessions
+Show IP address +Hide IP address Sign out of this session Session details Application, device, and activity information. @@ -3461,4 +3469,13 @@Apply underline format Toggle full screen mode + +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 dfce10adb6..d9c621a22a 100644 --- a/library/ui-styles/src/main/res/values/dimens.xml +++ b/library/ui-styles/src/main/res/values/dimens.xml @@ -53,6 +53,7 @@1dp 28dp 14dp +44dp 28dp 6dp 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/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..7ad342b22f 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 @@ -100,8 +100,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 > { diff --git a/matrix-sdk-android/build.gradle b/matrix-sdk-android/build.gradle index f50b672077..60b0329fbc 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.8\"" + buildConfigField "String", "SDK_VERSION", "\"1.5.10\"" buildConfigField "String", "GIT_SDK_REVISION", "\"${gitRevision()}\"" buildConfigField "String", "GIT_SDK_REVISION_UNIX_DATE", "\"${gitRevisionUnixDate()}\"" 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..b92d13dab2 Binary files /dev/null and b/matrix-sdk-android/src/androidTest/assets/session_42.realm differ diff --git a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/common/CommonTestHelper.kt b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/common/CommonTestHelper.kt index eeb2def582..8edecb273d 100644 --- a/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/common/CommonTestHelper.kt +++ b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/common/CommonTestHelper.kt @@ -50,6 +50,7 @@ import org.matrix.android.sdk.api.session.room.send.SendState 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 org.matrix.android.sdk.api.session.sync.filter.SyncFilterBuilder import timber.log.Timber import java.util.UUID import java.util.concurrent.CountDownLatch @@ -346,6 +347,10 @@ class CommonTestHelper internal constructor(context: Context, val cryptoConfig: assertTrue(registrationResult is RegistrationResult.Success) val session = (registrationResult as RegistrationResult.Success).session session.open() + session.filterService().setSyncFilter( + SyncFilterBuilder() + .lazyLoadMembersForStateEvents(true) + ) if (sessionTestParams.withInitialSync) { syncSession(session, 120_000) } 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..e74aa52495 --- /dev/null +++ b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/database/RealmSessionStoreMigration43Test.kt @@ -0,0 +1,130 @@ +/* + * 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() + } + + @Test + fun migrationShouldBeNeeed() { + val realmName = "session_42.realm" + val realmConfiguration = configurationFactory.createConfiguration( + realmName, + "efa9ab2c77ae06b0e767ffdb1c45b12be3c77d48d94f1ac41a7cd1d637fc59ac41f869a250453074e21ce13cfe7ed535593e7d150c08ce2bad7a2ab8c7b841f0", + SessionRealmModule(), + 43, + null + ) + configurationFactory.copyRealmFromAssets(context, realmName, realmName) + + try { + realm = Realm.getInstance(realmConfiguration) + fail("Should need a migration") + } catch (failure: Throwable) { + // nop + } + } + + // Database key for alias `session_db_e00482619b2597069b1f192b86de7da9`: efa9ab2c77ae06b0e767ffdb1c45b12be3c77d48d94f1ac41a7cd1d637fc59ac41f869a250453074e21ce13cfe7ed535593e7d150c08ce2bad7a2ab8c7b841f0 + // $WEJ8U6Zsx3TDZx3qmHIOKh-mXe5kqL_MnPcIkStEwwI + // $11EtAQ8RYcudJVtw7e6B5Vm4ufCqKTOWKblY2U_wrpo + @Test + fun testMigration43() { + val realmName = "session_42.realm" + val migration = RealmSessionStoreMigration(Normalizer()) + val realmConfiguration = configurationFactory.createConfiguration( + realmName, + "efa9ab2c77ae06b0e767ffdb1c45b12be3c77d48d94f1ac41a7cd1d637fc59ac41f869a250453074e21ce13cfe7ed535593e7d150c08ce2bad7a2ab8c7b841f0", + 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!!, "\$WEJ8U6Zsx3TDZx3qmHIOKh-mXe5kqL_MnPcIkStEwwI") + .findFirst() + ?.editSummary + ?.editions + + editions shouldNotBe null + editions!!.size shouldBe 1 + val firstEdition = editions.first() + firstEdition?.eventId shouldBeEqualTo "\$DvOyA8vJxwGfTaJG3OEJVcL4isShyaVDnprihy38W28" + 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!!, "\$11EtAQ8RYcudJVtw7e6B5Vm4ufCqKTOWKblY2U_wrpo") + .findFirst() + ?.editSummary + ?.editions + + editionsOfE2E shouldNotBe null + editionsOfE2E!!.size shouldBe 1 + val firstEditionE2E = editionsOfE2E.first() + firstEditionE2E?.eventId shouldBeEqualTo "\$HUwJOQRCJwfPv7XSKvBPcvncjM0oR3q2tGIIIdv9Zts" + 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..fc1a78835b --- /dev/null +++ b/matrix-sdk-android/src/androidTest/java/org/matrix/android/sdk/internal/database/SessionSanityMigrationTest.kt @@ -0,0 +1,64 @@ +/* + * 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() + } + + @Test + fun sessionDatabaseShouldMigrateGracefully() { + val realmName = "session_42.realm" + val migration = RealmSessionStoreMigration(Normalizer()) + val realmConfiguration = configurationFactory.createConfiguration( + realmName, + "efa9ab2c77ae06b0e767ffdb1c45b12be3c77d48d94f1ac41a7cd1d637fc59ac41f869a250453074e21ce13cfe7ed535593e7d150c08ce2bad7a2ab8c7b841f0", + 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/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 6ae585a273..40ce6ecb5c 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 @@ -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,18 @@ fun Event.isLocationMessage(): Boolean { } } -fun Event.isPoll(): Boolean = getClearType() in EventType.POLL_START || getClearType() in EventType.POLL_END +fun Event.isPoll(): Boolean = getClearType() in EventType.POLL_START.values || 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 +416,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 +439,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 36e66cc000..1688ca6a34 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 @@ -49,11 +49,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 +80,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 +88,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 +106,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") // Emotes const val ROOM_EMOTES = "im.ponies.room_emotes" 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/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 828949f13c..fe24b7bb35 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 8b66de67f4..5ebabb093c 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. @@ -69,10 +71,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 @@ -84,4 +86,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 b4b2ea8b8f..3651a96edb 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,7 +33,9 @@ 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 // SC addition | this is the Element behaviour previous to Element v1.0.7 val PREVIEWABLE_TYPES_ALL = listOf( @@ -53,7 +55,9 @@ object RoomSummaryConstants { EventType.STICKER, EventType.REACTION, EventType.STATE_ROOM_CREATE - ) + EventType.POLL_START + EventType.STATE_ROOM_BEACON_INFO + ) + + EventType.POLL_START.values + + EventType.STATE_ROOM_BEACON_INFO.values // SC addition | no reactions in here val PREVIEWABLE_ORIGINAL_CONTENT_TYPES = listOf( @@ -64,5 +68,7 @@ object RoomSummaryConstants { EventType.CALL_ANSWER, EventType.ENCRYPTED, EventType.STICKER - ) + 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/timeline/TimelineEvent.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/room/timeline/TimelineEvent.kt index 02764abaad..ee94be78c5 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 @@ -143,13 +144,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. */ diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/sync/FilterService.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/sync/FilterService.kt index bc592df474..7347bee165 100644 --- a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/sync/FilterService.kt +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/sync/FilterService.kt @@ -16,19 +16,12 @@ package org.matrix.android.sdk.api.session.sync +import org.matrix.android.sdk.api.session.sync.filter.SyncFilterBuilder + interface FilterService { - enum class FilterPreset { - NoFilter, - - /** - * Filter for Element, will include only known event type. - */ - ElementFilter - } - /** * Configure the filter for the sync. */ - fun setFilter(filterPreset: FilterPreset) + suspend fun setSyncFilter(filterBuilder: SyncFilterBuilder) } diff --git a/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/sync/filter/SyncFilterBuilder.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/sync/filter/SyncFilterBuilder.kt new file mode 100644 index 0000000000..ad55b26dfd --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/api/session/sync/filter/SyncFilterBuilder.kt @@ -0,0 +1,129 @@ +/* + * 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 + +import org.matrix.android.sdk.api.session.homeserver.HomeServerCapabilities +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 +import org.matrix.android.sdk.internal.sync.filter.SyncFilterParams + +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 extractParams(): SyncFilterParams { + return SyncFilterParams( + useThreadNotifications = useThreadNotifications, + lazyLoadMembersForMessageEvents = lazyLoadMembersForMessageEvents, + lazyLoadMembersForStateEvents = lazyLoadMembersForStateEvents, + listOfSupportedEventTypes = listOfSupportedEventTypes, + listOfSupportedStateEventTypes = listOfSupportedStateEventTypes, + ) + } + + 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/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/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/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 dbe5bd3007..b873ed27ce 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 -> if (!bgRealm.isInTransaction) { 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 a3eb6d5254..52f050602f 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 @@ -66,6 +66,9 @@ 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.util.Normalizer import org.matrix.android.sdk.internal.util.database.MatrixRealmMigration import timber.log.Timber @@ -89,7 +92,7 @@ internal class RealmSessionStoreMigration @Inject constructor( private val scSchemaVersion = 7L private val scSchemaVersionOffset = (1L shl 12) - val schemaVersion = 42L + + val schemaVersion = 45L + scSchemaVersion * scSchemaVersionOffset } @@ -148,6 +151,9 @@ internal class RealmSessionStoreMigration @Inject constructor( 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 (oldScVersion <= 0) MigrateScSessionTo001(realm).perform() if (oldScVersion <= 1) MigrateScSessionTo002(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 c81cc72b31..bc20bc0a32 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 @@ -84,7 +84,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 @@ -134,7 +133,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 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() - // 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 + } - 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..0ac8dc7902 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 @@ -103,32 +102,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, 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 323bf55194..ae7e05dee9 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 @@ -36,18 +35,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/FilterParamsMapper.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/FilterParamsMapper.kt new file mode 100644 index 0000000000..645cb41af5 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/mapper/FilterParamsMapper.kt @@ -0,0 +1,61 @@ +/* + * 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.realm.RealmList +import org.matrix.android.sdk.internal.database.model.SyncFilterParamsEntity +import org.matrix.android.sdk.internal.sync.filter.SyncFilterParams +import javax.inject.Inject + +internal class FilterParamsMapper @Inject constructor() { + + fun map(entity: SyncFilterParamsEntity): SyncFilterParams { + val eventTypes = if (entity.listOfSupportedEventTypesHasBeenSet) { + entity.listOfSupportedEventTypes?.toList() + } else { + null + } + val stateEventTypes = if (entity.listOfSupportedStateEventTypesHasBeenSet) { + entity.listOfSupportedStateEventTypes?.toList() + } else { + null + } + return SyncFilterParams( + useThreadNotifications = entity.useThreadNotifications, + lazyLoadMembersForMessageEvents = entity.lazyLoadMembersForMessageEvents, + lazyLoadMembersForStateEvents = entity.lazyLoadMembersForStateEvents, + listOfSupportedEventTypes = eventTypes, + listOfSupportedStateEventTypes = stateEventTypes, + ) + } + + fun map(params: SyncFilterParams): SyncFilterParamsEntity { + return SyncFilterParamsEntity( + useThreadNotifications = params.useThreadNotifications, + lazyLoadMembersForMessageEvents = params.lazyLoadMembersForMessageEvents, + lazyLoadMembersForStateEvents = params.lazyLoadMembersForStateEvents, + listOfSupportedEventTypes = params.listOfSupportedEventTypes.toRealmList(), + listOfSupportedEventTypesHasBeenSet = params.listOfSupportedEventTypes != null, + listOfSupportedStateEventTypes = params.listOfSupportedStateEventTypes.toRealmList(), + listOfSupportedStateEventTypesHasBeenSet = params.listOfSupportedStateEventTypes != null, + ) + } + + private fun List ?.toRealmList(): RealmList ? { + return this?.toTypedArray()?.let { RealmList(*it) } + } +} 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/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/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/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..93ff67a911 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 @@ -70,7 +70,8 @@ import org.matrix.android.sdk.internal.database.model.threads.ThreadSummaryEntit SpaceChildSummaryEntity::class, SpaceParentSummaryEntity::class, UserPresenceEntity::class, - ThreadSummaryEntity::class + ThreadSummaryEntity::class, + SyncFilterParamsEntity::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/query/ReadQueries.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/database/query/ReadQueries.kt index c0e8708df2..c982ac8833 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 @@ -20,18 +20,21 @@ import io.realm.Realm import io.realm.RealmConfiguration import org.matrix.android.sdk.api.extensions.orFalse 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.RoomSummaryEntity +import org.matrix.android.sdk.internal.database.model.getThreadId internal fun isEventRead( realmConfiguration: RealmConfiguration, userId: String?, roomId: String?, eventId: String?, + shouldCheckIfReadInEventsThread: Boolean, eventTs: Long? = null, ignoreSenderId: Boolean = false, handleAsUnreadForNonZeroUnreadCount: Boolean = false @@ -63,7 +66,8 @@ internal fun isEventRead( !ignoreSenderId && eventToCheck.root?.sender == userId -> true // If new event exists and the latest event is from ourselves we can infer the event is read !ignoreSenderId && 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 } } @@ -82,7 +86,7 @@ private fun isReadMarkerMoreRecentThanMissingEvent(realm: Realm, roomId: String, // Assume a missing event is read if: // - The read receipt is in the last forward chunk and // - The timestamp of the notification is smaller than the read receipt's one - return ReadReceiptEntity.where(realm, roomId, userId).findFirst()?.let { readReceipt -> + return ReadReceiptEntity.where(realm, roomId, userId, null).findFirst()?.let { readReceipt -> val readReceiptEvent = TimelineEventEntity.where(realm, roomId, readReceipt.eventId).findFirst() //Timber.i("isReadMarkerMoreRecentThanMissing? ${readReceiptEvent?.chunk?.firstOrNull()?.isLastForward} && ${(readReceiptEvent?.root?.originServerTs ?: 0) > eventTs} <- ${(readReceiptEvent?.root?.originServerTs ?: 0)} > $eventTs") readReceiptEvent?.chunk?.firstOrNull()?.isLastForward.orFalse() && (readReceiptEvent?.root?.originServerTs ?: 0) > eventTs @@ -92,27 +96,33 @@ private fun isReadMarkerMoreRecentThanMissingEvent(realm: Realm, roomId: String, 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/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/filter/DefaultFilterRepository.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/filter/DefaultFilterRepository.kt index 1d1bb0e715..4e5b005584 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,74 +17,71 @@ 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.mapper.FilterParamsMapper 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.model.SyncFilterParamsEntity 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.sync.filter.SyncFilterParams 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, + private val filterParamsMapper: FilterParamsMapper +) : 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() - - val filterEntity = FilterEntity.getOrCreate(realm) - - filterEntity.filterBodyJson = filterJson - filterEntity.roomEventFilterJson = roomEventFilterJson - // Reset filterId - filterEntity.filterId = "" - } - } - } - } - - override suspend fun storeFilterId(filter: Filter, filterId: String) { - monarchy.awaitTransaction { + 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() - // Update the filter id, only if the filter body matches - it.where () - .equalTo(FilterEntityFields.FILTER_BODY_JSON, filterJson) - ?.findFirst() - ?.filterId = filterId + val filterEntity = FilterEntity.getOrCreate(realm) + + filterEntity.filterBodyJson = filterJson + filterEntity.roomEventFilterJson = roomEventFilterJson + filterEntity.filterId = filterId } } - override suspend fun getFilter(): String { + override suspend fun getStoredSyncFilterBody(): String { return monarchy.awaitTransaction { - val filter = FilterEntity.getOrCreate(it) - if (filter.filterId.isBlank()) { - // Use the Json format - filter.filterBodyJson + FilterEntity.getOrCreate(it).filterBodyJson + } + } + + override suspend fun getStoredSyncFilterId(): String? { + return monarchy.awaitTransaction { + 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 } } + + override suspend fun getStoredFilterParams(): SyncFilterParams? { + return monarchy.awaitTransaction { realm -> + realm.where ().findFirst()?.let { + filterParamsMapper.map(it) + } + } + } + + override suspend fun storeFilterParams(params: SyncFilterParams) { + return monarchy.awaitTransaction { realm -> + val entity = filterParamsMapper.map(params) + realm.insertOrUpdate(entity) + } + } } 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 index 2e68d02d8c..c54e7de07a 100644 --- 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 @@ -17,19 +17,27 @@ 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 org.matrix.android.sdk.api.session.sync.filter.SyncFilterBuilder +import org.matrix.android.sdk.internal.session.homeserver.HomeServerCapabilitiesDataSource import javax.inject.Inject internal class DefaultFilterService @Inject constructor( private val saveFilterTask: SaveFilterTask, - private val taskExecutor: TaskExecutor + private val filterRepository: FilterRepository, + private val homeServerCapabilitiesDataSource: HomeServerCapabilitiesDataSource, ) : FilterService { // TODO Pass a list of support events instead - override fun setFilter(filterPreset: FilterService.FilterPreset) { - saveFilterTask - .configureWith(SaveFilterTask.Params(filterPreset)) - .executeBy(taskExecutor) + override suspend fun setSyncFilter(filterBuilder: SyncFilterBuilder) { + filterRepository.storeFilterParams(filterBuilder.extractParams()) + + // don't upload/store filter until homeserver capabilities are fetched + homeServerCapabilitiesDataSource.getHomeServerCapabilities()?.let { homeServerCapabilities -> + saveFilterTask.execute( + SaveFilterTask.Params( + filter = filterBuilder.build(homeServerCapabilities) + ) + ) + } } } 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..ca9f798fd9 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 @@ -44,4 +44,7 @@ internal abstract class FilterModule { @Binds abstract fun bindSaveFilterTask(task: DefaultSaveFilterTask): SaveFilterTask + + @Binds + 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..71d7391e87 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,42 @@ package org.matrix.android.sdk.internal.session.filter +import org.matrix.android.sdk.internal.sync.filter.SyncFilterParams + +/** + * 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 + + /** + * Returns filter params stored in local storage if it exists. + */ + suspend fun getStoredFilterParams(): SyncFilterParams? + + /** + * Stores filter params to local storage. + */ + suspend fun storeFilterParams(params: SyncFilterParams) } 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..e88f286e27 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/filter/GetCurrentFilterTask.kt @@ -0,0 +1,55 @@ +/* + * 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.session.homeserver.HomeServerCapabilities +import org.matrix.android.sdk.api.session.sync.filter.SyncFilterBuilder +import org.matrix.android.sdk.internal.session.homeserver.HomeServerCapabilitiesDataSource +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 +) : 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(filterRepository.getStoredFilterParams()) + .build(homeServerCapabilities) + + val currentFilterBody = currentFilter.toJSONString() + + return when (storedFilterBody) { + currentFilterBody -> storedFilterId ?: storedFilterBody + else -> saveFilter(currentFilter) + } + } + + 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..82d5ff4d2f 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,6 @@ package org.matrix.android.sdk.internal.session.filter -import org.matrix.android.sdk.api.session.sync.FilterService import org.matrix.android.sdk.internal.di.UserId import org.matrix.android.sdk.internal.network.GlobalErrorReceiver import org.matrix.android.sdk.internal.network.executeRequest @@ -26,10 +25,10 @@ import javax.inject.Inject /** * Save a filter, in db and if any changes, upload to the server. */ -internal interface SaveFilterTask : Task { +internal interface SaveFilterTask : Task { data class Params( - val filterPreset: FilterService.FilterPreset + val filter: Filter ) } @@ -37,33 +36,21 @@ 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() - } - } - val updated = filterRepository.storeFilter(filterBody, roomFilter) - if (updated) { - val filterResponse = executeRequest(globalErrorReceiver) { - // TODO auto retry - filterAPI.uploadFilter(userId, filterBody) - } - filterRepository.storeFilterId(filterBody, filterResponse.filterId) + override suspend fun execute(params: SaveFilterTask.Params): String { + val filter = params.filter + val filterResponse = executeRequest(globalErrorReceiver) { + // TODO auto retry + filterAPI.uploadFilter(userId, filter) } + + filterRepository.storeSyncFilter( + filter = filter, + filterId = filterResponse.filterId, + roomEventFilter = FilterFactory.createDefaultRoomFilter() + ) + return filterResponse.filterId } } 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 173396e067..d418cd6c07 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) } @@ -503,7 +483,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 @@ -601,12 +581,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..31bed90b62 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 @@ -33,6 +33,7 @@ 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.reporting.ReportContentBody import org.matrix.android.sdk.internal.session.room.send.SendResponse @@ -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 ) /** 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..0cda6eca99 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 kotlinx.coroutines.runBlocking 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 @@ -95,7 +96,7 @@ 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, createRoomBody: CreateRoomBody) { RoomEntity.getOrCreate(realm, roomId).apply { membership = Membership.JOIN chunks.add(createLocalRoomChunk(realm, roomId, createRoomBody)) @@ -148,13 +149,16 @@ internal class DefaultCreateLocalRoomTask @Inject constructor( * * @return a chunk entity */ - private suspend fun createLocalRoomChunk(realm: Realm, roomId: String, createRoomBody: CreateRoomBody): ChunkEntity { + private fun createLocalRoomChunk(realm: Realm, roomId: String, createRoomBody: CreateRoomBody): ChunkEntity { val chunkEntity = realm.createObject ().apply { isLastBackward = true isLastForward = true } - val eventList = createLocalRoomStateEventsTask.execute(CreateLocalRoomStateEventsTask.Params(createRoomBody)) + // Can't suspend when using realm as it could jump thread + val eventList = runBlocking { + createLocalRoomStateEventsTask.execute(CreateLocalRoomStateEventsTask.Params(createRoomBody)) + } val roomMemberContentsByUser = HashMap () for (event in eventList) { 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..13753115ac 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) + 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 + // 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 + 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) - } -// EventType.REACTION -> { -// eventRelationsAggregationUpdater.handleReactionRedact(eventToPrune, realm, userId) -// } + 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 3e3ba194ea..04a472be05 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,10 +30,12 @@ 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, @@ -41,7 +43,8 @@ internal class DefaultReadService @AssistedInject constructor( private val setReadMarkersTask: SetReadMarkersTask, private val setMarkedUnreadTask: SetMarkedUnreadTask, private val readReceiptsSummaryMapper: ReadReceiptsSummaryMapper, - @UserId private val userId: String + @UserId private val userId: String, + private val homeServerCapabilitiesDataSource: HomeServerCapabilitiesDataSource ) : ReadService { @AssistedFactory @@ -49,19 +52,30 @@ 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) // Automatically unset unread marker setMarkedUnreadFlag(false) } - 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) } @@ -86,7 +100,8 @@ internal class DefaultReadService @AssistedInject constructor( } override fun isEventRead(eventId: String, eventTs: Long?): Boolean { - return isEventRead(monarchy.realmConfiguration, userId, roomId, eventId, eventTs) + val shouldCheckIfReadInEventsThread = homeServerCapabilitiesDataSource.getHomeServerCapabilities()?.canUseThreadReadReceiptsAndNotifications == true + return isEventRead(monarchy.realmConfiguration, userId, roomId, eventId, shouldCheckIfReadInEventsThread, eventTs) } override fun getReadMarkerLive(): LiveData > { @@ -99,9 +114,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) { @@ -112,10 +127,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 2cfd92de2c..6ff96ce9e3 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 @@ -21,6 +21,7 @@ import de.spiritcroc.matrixsdk.util.DbgUtil import de.spiritcroc.matrixsdk.util.Dimber 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 @@ -47,8 +48,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, ) } @@ -63,6 +65,7 @@ internal class DefaultSetReadMarkersTask @Inject constructor( @UserId private val userId: String, private val globalErrorReceiver: GlobalErrorReceiver, private val clock: Clock, + private val homeServerCapabilitiesService: HomeServerCapabilitiesService, ) : SetReadMarkersTask { private val rmDimber = Dimber("ReadMarkerDbg", DbgUtil.DBG_READ_MARKER) @@ -71,6 +74,7 @@ internal class DefaultSetReadMarkersTask @Inject constructor( 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 { @@ -81,6 +85,7 @@ internal class DefaultSetReadMarkersTask @Inject constructor( } else { params.readReceiptEventId } + if (fullyReadEventId != null && !isReadMarkerMoreRecent(monarchy.realmConfiguration, params.roomId, fullyReadEventId, rmDimber)) { rmDimber.i { "Set to $fullyReadEventId if it's not local..." } if (LocalEcho.isLocalEchoId(fullyReadEventId)) { @@ -91,8 +96,12 @@ internal class DefaultSetReadMarkersTask @Inject constructor( } else { rmDimber.i { "Did not set to $fullyReadEventId" } } + + val shouldCheckIfReadInEventsThread = readReceiptThreadId != null && + homeServerCapabilitiesService.getHomeServerCapabilities().canUseThreadReadReceiptsAndNotifications + if (readReceiptEventId != null && - !isEventRead(monarchy.realmConfiguration, userId, params.roomId, readReceiptEventId, ignoreSenderId = true, handleAsUnreadForNonZeroUnreadCount = true)) { + !isEventRead(monarchy.realmConfiguration, userId, params.roomId, readReceiptEventId, shouldCheckIfReadInEventsThread, ignoreSenderId = true, handleAsUnreadForNonZeroUnreadCount = true)) { if (LocalEcho.isLocalEchoId(readReceiptEventId)) { Timber.w("Can't set read receipt for local event $readReceiptEventId") } else { @@ -102,7 +111,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( @@ -111,7 +120,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 @@ -126,7 +136,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] @@ -134,7 +144,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/send/LocalEchoEventFactory.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/room/send/LocalEchoEventFactory.kt index 1a0022210a..5847339e90 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 @@ -182,7 +182,7 @@ internal class LocalEchoEventFactory @Inject constructor( originServerTs = dummyOriginServerTs(), senderId = userId, eventId = localId, - type = EventType.POLL_START.first(), + type = EventType.POLL_START.stable, content = newContent.toContent().plus(additionalContent.orEmpty()) ) } @@ -207,7 +207,7 @@ internal class LocalEchoEventFactory @Inject constructor( originServerTs = dummyOriginServerTs(), senderId = userId, eventId = localId, - type = EventType.POLL_RESPONSE.first(), + type = EventType.POLL_RESPONSE.stable, content = content.toContent().plus(additionalContent.orEmpty()), unsignedData = UnsignedData(age = null, transactionId = localId) ) @@ -227,7 +227,7 @@ internal class LocalEchoEventFactory @Inject constructor( originServerTs = dummyOriginServerTs(), senderId = userId, eventId = localId, - type = EventType.POLL_START.first(), + type = EventType.POLL_START.stable, content = content.toContent().plus(additionalContent.orEmpty()), unsignedData = UnsignedData(age = null, transactionId = localId) ) @@ -250,7 +250,7 @@ internal class LocalEchoEventFactory @Inject constructor( originServerTs = dummyOriginServerTs(), senderId = userId, eventId = localId, - type = EventType.POLL_END.first(), + type = EventType.POLL_END.stable, content = content.toContent().plus(additionalContent.orEmpty()), unsignedData = UnsignedData(age = null, transactionId = localId) ) @@ -301,7 +301,7 @@ internal class LocalEchoEventFactory @Inject constructor( originServerTs = dummyOriginServerTs(), senderId = userId, eventId = localId, - type = EventType.BEACON_LOCATION_DATA.first(), + type = EventType.BEACON_LOCATION_DATA.stable, content = content.toContent().plus(additionalContent.orEmpty()), unsignedData = UnsignedData(age = null, transactionId = localId) ) 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 a6861cdae7..0542ff2c29 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 @@ -81,7 +82,8 @@ 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, ) { fun refreshLatestPreviewContent(realm: Realm, roomId: String, attemptDecrypt: Boolean = true) { @@ -208,18 +210,19 @@ internal class RoomSummaryUpdater @Inject constructor( roomSummaryEntity.hasUnreadContentMessages = hasUnreadMessages roomSummaryEntity.hasUnreadOriginalContentMessages = hasUnreadMessages } else { + val shouldCheckIfReadInEventsThread = homeServerCapabilitiesService.getHomeServerCapabilities().canUseThreadReadReceiptsAndNotifications roomSummaryEntity.hasUnreadMessages = roomSummaryEntity.notificationCount > 0 || //(roomSummaryEntity.unreadCount?.let { it > 0 } ?: false) || // avoid this call if we are sure there are unread events - roomSummaryEntity.latestPreviewableEvent?.let { !isEventRead(realm.configuration, userId, roomId, it.eventId) } ?: false + roomSummaryEntity.latestPreviewableEvent?.let { !isEventRead(realm.configuration, userId, roomId, it.eventId, shouldCheckIfReadInEventsThread) } ?: false roomSummaryEntity.hasUnreadContentMessages = roomSummaryEntity.notificationCount > 0 || //(roomSummaryEntity.unreadCount?.let { it > 0 } ?: false) || // avoid this call if we are sure there are unread events - roomSummaryEntity.latestPreviewableContentEvent?.let { !isEventRead(realm.configuration, userId, roomId, it.eventId) } ?: false + roomSummaryEntity.latestPreviewableContentEvent?.let { !isEventRead(realm.configuration, userId, roomId, it.eventId, shouldCheckIfReadInEventsThread) } ?: false roomSummaryEntity.hasUnreadOriginalContentMessages = roomSummaryEntity.notificationCount > 0 || //(roomSummaryEntity.unreadCount?.let { it > 0 } ?: false) || // avoid this call if we are sure there are unread events - roomSummaryEntity.latestPreviewableOriginalContentEvent?.let { !isEventRead(realm.configuration, userId, roomId, it.eventId) } ?: false + roomSummaryEntity.latestPreviewableOriginalContentEvent?.let { !isEventRead(realm.configuration, userId, roomId, it.eventId, shouldCheckIfReadInEventsThread) } ?: false } roomSummaryEntity.setDisplayName(roomDisplayNameResolver.resolve(realm, 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 0803f46609..a9f43ad3c8 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 @@ -465,7 +465,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 f56a1e6dcd..d7165cb418 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. // In case we change this in the future, we want to make sure that the ReplyPreviewRetriever still only fetches the necessary event. 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/SyncTask.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/session/sync/SyncTask.kt index 7ae7d9a883..a66bfd7bed 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/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 7ddc2e6c0b..c34e58fa03 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 @@ -487,23 +490,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/sync/filter/SyncFilterParams.kt b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/sync/filter/SyncFilterParams.kt new file mode 100644 index 0000000000..a7de7f5579 --- /dev/null +++ b/matrix-sdk-android/src/main/java/org/matrix/android/sdk/internal/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.internal.sync.filter + +internal 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/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/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/PollEventsTestData.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/aggregation/poll/PollEventsTestData.kt index 129d49633e..bdd1fd9b0d 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.stable, 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.stable, 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.stable, 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..4a10795647 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.stable, 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..a5c126cf72 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.stable, 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..a7adadfc63 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.stable, 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..d6edb69d93 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.stable) 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/send/LocalEchoEventFactoryTests.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/session/room/send/LocalEchoEventFactoryTests.kt index b30428e5e1..19f58d690f 100644 --- 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 @@ -23,8 +23,6 @@ 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.EditAggregatedSummary -import org.matrix.android.sdk.api.session.room.model.EventAnnotationsSummary 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 @@ -226,16 +224,14 @@ class LocalEchoEventFactoryTests { ).toMessageTextContent().toContent() } return TimelineEvent( - root = A_START_EVENT, + 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), - annotations = if (textContent != null) { - EventAnnotationsSummary( - editSummary = EditAggregatedSummary(latestContent = textContent, emptyList(), emptyList()) - ) - } else null ) } } 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..201423685c --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/internal/sync/DefaultGetCurrentFilterTaskTest.kt @@ -0,0 +1,100 @@ +/* + * 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 kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.amshove.kluent.shouldBeEqualTo +import org.junit.Test +import org.matrix.android.sdk.api.session.homeserver.HomeServerCapabilities +import org.matrix.android.sdk.api.session.sync.filter.SyncFilterBuilder +import org.matrix.android.sdk.internal.session.filter.DefaultGetCurrentFilterTask +import org.matrix.android.sdk.internal.sync.filter.SyncFilterParams +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( + lazyLoadMembersForMessageEvents = true, + 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 + ) + + @Test + fun `given no filter is stored, when execute, then executes task to save new filter`() = runTest { + filterRepository.givenFilterParamsAreStored(A_SYNC_FILTER_PARAMS) + + 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 { + filterRepository.givenFilterParamsAreStored(A_SYNC_FILTER_PARAMS) + + 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 { + filterRepository.givenFilterParamsAreStored(A_SYNC_FILTER_PARAMS) + + 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/FakeFilterRepository.kt b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeFilterRepository.kt new file mode 100644 index 0000000000..b8225f21d6 --- /dev/null +++ b/matrix-sdk-android/src/test/java/org/matrix/android/sdk/test/fakes/FakeFilterRepository.kt @@ -0,0 +1,34 @@ +/* + * 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 +import org.matrix.android.sdk.internal.sync.filter.SyncFilterParams + +internal class FakeFilterRepository : FilterRepository by mockk() { + + fun givenFilterStored(filterId: String?, filterBody: String?) { + coEvery { getStoredSyncFilterId() } returns filterId + coEvery { getStoredSyncFilterBody() } returns filterBody + } + + fun givenFilterParamsAreStored(syncFilterParams: SyncFilterParams?) { + coEvery { getStoredFilterParams() } returns syncFilterParams + } +} 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/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/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/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/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..d8980b9da7 --- /dev/null +++ b/tools/release/releaseScript.sh @@ -0,0 +1,254 @@ +#!/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" +printf "================================================================================\n" +printf "| Welcome to the release script! |\n" +printf "================================================================================\n" + +releaseScriptLocation="${RELEASE_SCRIPT_PATH}" + +if [[ -z "${releaseScriptLocation}" ]]; then + printf "Fatal: RELEASE_SCRIPT_PATH is not defined in the environment. Please set to the path of your local file 'releaseElement2.sh'.\n" + exit 1 +fi + +releaseScriptFullPath="${releaseScriptLocation}/releaseElement2.sh" + +if [[ ! -f ${releaseScriptFullPath} ]]; then + printf "Fatal: release script not found at ${releaseScriptFullPath}.\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" +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 + +# 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}" + +printf "\n" +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" +read -p "Wait for the GitHub action https://github.com/vector-im/element-android/actions/workflows/build.yml?query=branch%3Amain to build the 'main' branch. Press enter when it's done." + +printf "\n================================================================================\n" +printf "Running the release script...\n" +cd ${releaseScriptLocation} +${releaseScriptFullPath} "v${version}" +cd - + +printf "\n================================================================================\n" +apkPath="${releaseScriptLocation}/Element/v${version}/vector-gplay-arm64-v8a-release-signed.apk" +printf "Installing apk on a real device...\n" +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. Press enter when it's done." + +printf "\n================================================================================\n" +printf "Ping the Android Internal room. Here is an example of message which can be sent:\n\n" +printf "@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!\n\n" +read -p "Press enter when it's done." + +printf "\n================================================================================\n" +printf "Congratulation! Kudos for using this script! Have a nice day!\n" +printf "================================================================================\n" diff --git a/vector-app/build.gradle b/vector-app/build.gradle index 1fcb4f8db5..519a2e6bc5 100644 --- a/vector-app/build.gradle +++ b/vector-app/build.gradle @@ -37,7 +37,7 @@ ext.versionMinor = 5 // 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 = 8 +ext.versionPatch = 10 ext.scVersion = 62 @@ -78,15 +78,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 @@ -124,8 +117,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 @@ -153,7 +144,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" 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/main/java/im/vector/app/VectorApplication.kt b/vector-app/src/main/java/im/vector/app/VectorApplication.kt index 3a2f90c646..623c2811dd 100644 --- a/vector-app/src/main/java/im/vector/app/VectorApplication.kt +++ b/vector-app/src/main/java/im/vector/app/VectorApplication.kt @@ -258,7 +258,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 3c1cea57ec..28ca761ace 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 @@ -221,7 +221,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-config/src/main/res/values/config-settings.xml b/vector-config/src/main/res/values/config-settings.xml index fd0a11ed5a..ce7c9b10fd 100755 --- a/vector-config/src/main/res/values/config-settings.xml +++ b/vector-config/src/main/res/values/config-settings.xml @@ -57,5 +57,7 @@ + + false 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/main/AndroidManifest.xml b/vector/src/main/AndroidManifest.xml index 91a34b99d6..c67bbbcd88 100644 --- a/vector/src/main/AndroidManifest.xml +++ b/vector/src/main/AndroidManifest.xml @@ -119,7 +119,7 @@- + - + = Build.VERSION_CODES.Q) { - VoiceBroadcastRecorderQ(context) + VoiceBroadcastRecorderQ( + context = context, + sessionHolder = sessionHolder, + getVoiceBroadcastEventUseCase = getMostRecentVoiceBroadcastStateEventUseCase + ) } else { null } 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/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/pushers/VectorPushHandler.kt b/vector/src/main/java/im/vector/app/core/pushers/VectorPushHandler.kt index b74028d579..0d2cd56995 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 @@ -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/session/ConfigureAndStartSessionUseCase.kt b/vector/src/main/java/im/vector/app/core/session/ConfigureAndStartSessionUseCase.kt index c47769052c..96c3f8a6ce 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 @@ -24,9 +24,9 @@ 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.sync.SyncUtils 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 @@ -41,7 +41,9 @@ class ConfigureAndStartSessionUseCase @Inject constructor( 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) + session.coroutineScope.launch { + session.filterService().setSyncFilter(SyncUtils.getSyncFilterBuilder()) + } if (startSyncing) { session.startSyncing(context) } 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..0474cdea7e --- /dev/null +++ b/vector/src/main/java/im/vector/app/core/utils/ExpandingBottomSheetBehavior.kt @@ -0,0 +1,791 @@ +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 applyInsetsFromAnimation = false + + // This will animated 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 onProgress(insets: WindowInsetsCompat, runningAnimations: MutableList ): WindowInsetsCompat { + return applyInsets(view, insets) + } + + override fun onEnd(animation: WindowInsetsAnimationCompat) { + applyInsetsFromAnimation = false + view.requestApplyInsets() + } + }) + + ViewCompat.setOnApplyWindowInsetsListener(view) { _: View, insets: WindowInsetsCompat -> + if (!applyInsetsFromAnimation) { + applyInsetsFromAnimation = true + applyInsets(view, insets) + } else { + 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/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/room/detail/JumpToBottomViewVisibilityManager.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/JumpToBottomViewVisibilityManager.kt index 1368b71ec6..0f7dc251ae 100644 --- a/vector/src/main/java/im/vector/app/features/home/room/detail/JumpToBottomViewVisibilityManager.kt +++ b/vector/src/main/java/im/vector/app/features/home/room/detail/JumpToBottomViewVisibilityManager.kt @@ -34,8 +34,6 @@ class JumpToBottomViewVisibilityManager( private val layoutManager: LinearLayoutManager ) { - private var canShowButtonOnScroll = true - init { recyclerView.addOnScrollListener(object : RecyclerView.OnScrollListener() { override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { @@ -45,7 +43,7 @@ class JumpToBottomViewVisibilityManager( if (scrollingToPast) { jumpToBottomView.hide() - } else if (canShowButtonOnScroll) { + } else { maybeShowJumpToBottomViewVisibility() } } @@ -68,13 +66,7 @@ class JumpToBottomViewVisibilityManager( } } - fun hideAndPreventVisibilityChangesWithScrolling() { - jumpToBottomView.hide() - canShowButtonOnScroll = false - } - private fun maybeShowJumpToBottomViewVisibility() { - canShowButtonOnScroll = true if (layoutManager.findFirstVisibleItemPosition() > 1) { jumpToBottomView.show() } else { 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/TimelineFragment.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineFragment.kt index 07f53c0bfc..a6325916de 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 @@ -37,17 +37,18 @@ import android.widget.TextView import androidx.activity.addCallback import androidx.annotation.StringRes import androidx.appcompat.view.menu.MenuBuilder -import androidx.constraintlayout.widget.ConstraintSet import androidx.core.content.ContextCompat import androidx.core.graphics.drawable.DrawableCompat 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.isGone 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 @@ -78,7 +79,6 @@ import im.vector.app.core.dialogs.ConfirmationDialogBuilder import im.vector.app.core.dialogs.GalleryOrCameraDialogHelper import im.vector.app.core.dialogs.GalleryOrCameraDialogHelperFactory import im.vector.app.core.epoxy.LayoutManagerStateRestorer -import im.vector.app.core.extensions.animateLayoutChange import im.vector.app.core.extensions.cleanup import im.vector.app.core.extensions.commitTransaction import im.vector.app.core.extensions.containsRtLOverride @@ -206,9 +206,7 @@ import im.vector.app.features.widgets.WidgetKind import im.vector.app.features.widgets.permissions.RoomWidgetPermissionBottomSheet import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Runnable -import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.coroutines.withContext @@ -453,20 +451,12 @@ class TimelineFragment : } } - 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 } - - views.scrim.setOnClickListener { - messageComposerViewModel.handle(MessageComposerAction.SetFullScreen(false)) - } - - messageComposerViewModel.stateFlow.map { it.isFullScreen } - .distinctUntilChanged() - .onEach { isFullScreen -> - toggleFullScreenEditor(isFullScreen) - } - .launchIn(viewLifecycleOwner.lifecycleScope) } private fun setupBackPressHandling() { @@ -1157,6 +1147,7 @@ class TimelineFragment : override fun onResume() { super.onResume() notificationDrawerManager.setCurrentRoom(timelineArgs.roomId) + notificationDrawerManager.setCurrentThread(timelineArgs.threadTimelineArgs?.rootThreadEventId) roomDetailPendingActionStore.data?.let { handlePendingAction(it) } roomDetailPendingActionStore.data = null } @@ -1176,6 +1167,7 @@ class TimelineFragment : override fun onPause() { super.onPause() notificationDrawerManager.setCurrentRoom(null) + notificationDrawerManager.setCurrentThread(null) } private val emojiActivityResultLauncher = registerStartForActivityResult { activityResult -> @@ -1293,13 +1285,7 @@ class TimelineFragment : override fun onLayoutCompleted(state: RecyclerView.State) { super.onLayoutCompleted(state) updateJumpToReadMarkerViewVisibility() - withState(messageComposerViewModel) { composerState -> - if (!composerState.isFullScreen) { - jumpToBottomViewVisibilityManager.maybeShowJumpToBottomViewVisibilityWithDelay() - } else { - jumpToBottomViewVisibilityManager.hideAndPreventVisibilityChangesWithScrolling() - } - } + jumpToBottomViewVisibilityManager.maybeShowJumpToBottomViewVisibilityWithDelay() } }.apply { // For local rooms, pin the view's content to the top edge (the layout is reversed) @@ -1432,7 +1418,6 @@ class TimelineFragment : if (mainState.tombstoneEvent == null) { views.composerContainer.isInvisible = !messageComposerState.isComposerVisible views.voiceMessageRecorderContainer.isVisible = messageComposerState.isVoiceMessageRecorderVisible - when (messageComposerState.canSendMessage) { CanSendStatus.Allowed -> { NotificationAreaView.State.Hidden @@ -2119,7 +2104,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)) @@ -2380,19 +2365,6 @@ class TimelineFragment : } } - private fun toggleFullScreenEditor(isFullScreen: Boolean) { - views.composerContainer.animateLayoutChange(200) - - val constraintSet = ConstraintSet() - val constraintSetId = if (isFullScreen) { - R.layout.fragment_timeline_fullscreen - } else { - R.layout.fragment_timeline - } - constraintSet.clone(requireContext(), constraintSetId) - constraintSet.applyTo(views.rootConstraintLayout) - } - /** * 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/TimelineViewModel.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/TimelineViewModel.kt index 352557e294..4b14cefc66 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 @@ -278,7 +278,7 @@ class TimelineViewModel @AssistedInject constructor( tryOrNullAnon { room.readService().setMarkedUnread(false) } } } else { - tryOrNullAnon { room.readService().markAsRead(ReadService.MarkAsReadParams.READ_RECEIPT) } + tryOrNullAnon { room.readService().markAsRead(ReadService.MarkAsReadParams.READ_RECEIPT, mainTimeLineOnly = true) } } } // Inform the SDK that the room is displayed @@ -831,7 +831,8 @@ class TimelineViewModel @AssistedInject constructor( rmDimber.i{"set RM and RR to $it"} tryOrNullAnon { room.readService().setReadMarker(it) } //if (loadRoomAtFirstUnread()) { - tryOrNullAnon { room.readService().setReadReceipt(it) } + val threadId = initialState.rootThreadEventId ?: ReadService.THREAD_ID_MAIN + tryOrNullAnon { room.readService().setReadReceipt(it, threadId) } //} } } @@ -1208,7 +1209,8 @@ class TimelineViewModel @AssistedInject constructor( } bufferedMostRecentDisplayedEvent.root.eventId?.let { eventId -> session.coroutineScope.launch { - tryOrNullAnon { room.readService().setReadReceipt(eventId) } + val threadId = initialState.rootThreadEventId ?: ReadService.THREAD_ID_MAIN + tryOrNullAnon { room.readService().setReadReceipt(eventId, threadId = threadId) } } } } @@ -1226,7 +1228,7 @@ class TimelineViewModel @AssistedInject constructor( if (room == null) return setState { copy(unreadState = UnreadState.HasNoUnread) } viewModelScope.launch { - tryOrNullAnon(action.forceIfOpenedAnonymously) { room.readService().markAsRead(ReadService.MarkAsReadParams.BOTH) } + tryOrNullAnon(action.forceIfOpenedAnonymously) { 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 b5ea528bd7..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 @@ -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 -> 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 a9313fd4ee..61bd7953f4 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,11 +33,11 @@ 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() + // SC object ClearFocus : MessageComposerAction() - data class SetFullScreen(val isFullScreen: Boolean) : MessageComposerAction() - // Voice Message data class InitializeVoiceRecorder(val attachmentData: ContentAttachmentData) : MessageComposerAction() data class OnVoiceRecordingUiStateChanged(val uiState: VoiceMessageRecorderView.RecordingUiState) : 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 783f7cfb41..04622f5e5e 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 @@ -25,7 +25,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 @@ -33,10 +32,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 @@ -53,7 +49,6 @@ import dagger.hilt.android.AndroidEntryPoint import de.spiritcroc.util.ThumbnailGenerationVideoDownloadDecider import im.vector.app.R 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 @@ -61,7 +56,7 @@ 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 @@ -88,14 +83,9 @@ import im.vector.app.features.home.room.detail.RoomDetailAction.VoiceBroadcastAc import im.vector.app.features.home.room.detail.TimelineViewModel 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 @@ -107,18 +97,9 @@ 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 org.matrix.android.sdk.internal.session.room.send.pills.requiresFormattedMessage import reactivecircus.flowbinding.android.view.focusChanges import reactivecircus.flowbinding.android.widget.textChanges @@ -134,12 +115,7 @@ 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 generationVideoDownloadDecider: ThumbnailGenerationVideoDownloadDecider @Inject lateinit var vectorPreferences: VectorPreferences @Inject lateinit var vectorFeatures: VectorFeatures @@ -152,10 +128,6 @@ class MessageComposerFragment : VectorBaseFragment (), A autoCompleterFactory.create(roomId, isThreadTimeLine()) } - private val pillsPostProcessor by lazy { - pillsPostProcessorFactory.create(roomId) - } - private val emojiPopup: EmojiPopup by lifecycleAwareLazy { createEmojiPopup() } @@ -171,6 +143,7 @@ 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() @@ -197,6 +170,7 @@ class MessageComposerFragment : VectorBaseFragment (), A attachmentsHelper = AttachmentsHelper(requireContext(), this, buildMeta).register() + setupBottomSheet() setupComposer() setupEmojiButton() @@ -226,22 +200,15 @@ class MessageComposerFragment : VectorBaseFragment (), A } } - messageComposerViewModel.stateFlow.map { it.isFullScreen } - .distinctUntilChanged() - .onEach { isFullScreen -> - composer.toggleFullScreen(isFullScreen) - } - .launchIn(viewLifecycleOwner.lifecycleScope) - messageComposerViewModel.onEach(MessageComposerViewState::sendMode, MessageComposerViewState::canSendMessage) { mode, canSend -> if (!canSend.boolean()) { return@onEach } 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) } } @@ -251,6 +218,14 @@ class MessageComposerFragment : VectorBaseFragment (), A .onEach { onTypeSelected(it.attachmentType) } .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() } @@ -289,11 +264,45 @@ class MessageComposerFragment : VectorBaseFragment (), A ) { mainState, messageComposerState, attachmentState -> if (mainState.tombstoneEvent != null) return@withState - composer.setInvisible(!messageComposerState.isComposerVisible) + (composer as? View)?.isInvisible = !messageComposerState.isComposerVisible composer.sendButton.isInvisible = !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) + + messageComposerViewModel.handle(MessageComposerAction.SetFullScreen(setFullScreen)) + } + + override fun onSlidePositionChanged(view: View, yPosition: Float) { + (composer as? RichTextComposerLayout)?.notifyIsBeingDragged(yPosition) + } + } + } + } + private fun setupComposer() { val composerEditText = composer.editText composerEditText.setHint(R.string.room_message_placeholder) @@ -391,8 +400,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(""), timelineViewModel) lockSendButton = true if (formattedText != null) { messageComposerViewModel.handle(MessageComposerAction.SendMessage(text, formattedText, false)) @@ -420,77 +428,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), timelineViewModel) } - private fun getMemberNameColor(matrixItem: MatrixItem) = matrixItemColorProvider.getColor( - matrixItem, - withState(timelineViewModel) { - MatrixItemColorProvider.UserInRoomInformation( - it.isDm(), - it.isPublic(), - it.powerLevelsHelper?.getUserPowerLevelValue(matrixItem.id) - ) - } - ) - - 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(getMemberNameColor(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), generationVideoDownloadDecider.enableVideoDownloadForThumbnailGeneration()) - 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, timelineViewModel) } private fun observerUserTyping() { @@ -513,7 +456,7 @@ class MessageComposerFragment : VectorBaseFragment (), A } private fun focusComposerAndShowKeyboard() { - if (composer.isVisible) { + if ((composer as? View)?.isVisible == true) { composer.editText.showKeyboard(andRequestFocus = true) } } @@ -529,7 +472,7 @@ class MessageComposerFragment : VectorBaseFragment (), A views.composerLayout.sendButton.isVisible = true views.composerLayout.sendButton.animate().alpha(1f).setDuration(150).start() } - } else if (!event.isVisible) { + } else { composer.sendButton.isInvisible = true } } @@ -540,15 +483,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, @@ -872,11 +806,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..a401f04bf5 --- /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) + class Quote(override val event: TimelineEvent, override val defaultContent: CharSequence) : Special(event, defaultContent) + 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 b7e0e29679..22603946f5 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,35 +19,25 @@ 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 +import im.vector.app.features.home.room.detail.TimelineViewModel 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 fullScreenButton: ImageButton? - val composerRelatedMessageTitle: TextView - val composerRelatedMessageContent: TextView - val composerRelatedMessageImage: ImageView - val composerRelatedMessageActionIcon: ImageView - val composerRelatedMessageAvatar: ImageView var callback: Callback? - var isVisible: Boolean - - 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 toggleFullScreen(newValue: Boolean) - - fun setInvisible(isInvisible: Boolean) + fun renderComposerMode(mode: MessageComposerMode, timelineViewModel: TimelineViewModel?) } interface Callback : ComposerEditText.Callback { 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 7e450f5b9e..1e45534c13 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,44 +19,62 @@ 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.annotation.ColorInt +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 com.airbnb.mvrx.withState +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.TimelineViewModel +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 { + + @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 @@ -65,37 +83,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 fullScreenButton: ImageButton? = null - 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 { @@ -121,27 +125,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() } @@ -149,36 +141,106 @@ class PlainTextComposerLayout @JvmOverloads constructor( return views.composerEditText.setTextIfDifferent(text) } - override fun toggleFullScreen(newValue: Boolean) { - // Plain text composer has no full screen + // SC-TODO maybe just some interface instead of timelineViewModel + override fun renderComposerMode(mode: MessageComposerMode, timelineViewModel: TimelineViewModel?) { + val specialMode = mode as? MessageComposerMode.Special + if (specialMode != null) { + renderSpecialMode(specialMode, timelineViewModel) + } 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 applyNewConstraintSet(animate: Boolean, transitionComplete: (() -> Unit)?) { - // val wasSendButtonInvisible = views.sendButton.isInvisible - if (animate) { - configureAndBeginTransition(transitionComplete) - } - ConstraintSet().also { - it.clone(context, currentConstraintSetId) - it.getConstraint(R.id.composerEmojiButton).propertySet.visibility = views.composerEmojiButton.visibility - it.applyTo(this) - } - // 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 getMemberNameColor(matrixItem: MatrixItem, timelineViewModel: TimelineViewModel?) = matrixItemColorProvider.getColor( + matrixItem, + timelineViewModel?.let { model -> + withState(model) { + MatrixItemColorProvider.UserInRoomInformation( + it.isDm(), + it.isPublic(), + it.powerLevelsHelper?.getUserPowerLevelValue(matrixItem.id) + ) } - }) + } + ) + + private fun renderSpecialMode(specialMode: MessageComposerMode.Special, timelineViewModel: TimelineViewModel?) { + 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(getMemberNameColor(MatrixItem.UserItem(event.root.senderId ?: "@"), timelineViewModel)) + } + + 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) + } + views.composerRelatedMessageContent.text = (formattedBody ?: nonFormattedBody) + + // Image Event + val data = event.buildImageContentRendererData(dimensionConverter.dpToPx(66), generateMissingVideoThumbnails = false) + 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 + } + } + + 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 fb372b7e24..be92c4fbb4 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,54 +16,70 @@ 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.core.view.updateLayoutParams +import com.google.android.material.shape.MaterialShapeDrawable import im.vector.app.R -import im.vector.app.core.extensions.animateLayoutChange +import im.vector.app.core.extensions.hideKeyboard 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 im.vector.app.features.home.room.detail.TimelineViewModel import io.element.android.wysiwyg.EditorEditText import io.element.android.wysiwyg.inputhandlers.models.InlineFormat +import uniffi.wysiwyg_composer.ActionState import uniffi.wysiwyg_composer.ComposerAction -import uniffi.wysiwyg_composer.MenuState 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: Callback? = null - private var currentConstraintSetId: Int = -1 - private val animationDuration = 100L - private val maxEditTextLinesWhenCollapsed = 12 - - private val isFullScreen: Boolean get() = currentConstraintSetId == R.layout.composer_rich_text_layout_constraint_set_fullscreen + // 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 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? @@ -82,37 +98,96 @@ class RichTextComposerLayout @JvmOverloads constructor( get() = views.sendButton override val attachmentButton: ImageButton get() = views.attachmentButton - override val fullScreenButton: ImageButton? - get() = views.composerFullScreenButton - 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) { + editText.updateLayoutParams { + height = if (isFullScreen) 0 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) + } else { + editText.hideKeyboard() + } + 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) + + renderComposerMode(MessageComposerMode.Normal(null), null) views.richTextComposerEditText.addTextChangedListener( - TextChangeListener({ callback?.onTextChanged(it) }, { updateTextFieldBorder() }) + TextChangeListener({ callback?.onTextChanged(it) }, { updateTextFieldBorder(isFullScreen) }) ) views.plainTextComposerEditText.addTextChangedListener( - TextChangeListener({ callback?.onTextChanged(it) }, { updateTextFieldBorder() }) + TextChangeListener({ callback?.onTextChanged(it) }, { updateTextFieldBorder(isFullScreen) }) ) - views.composerRelatedMessageCloseButton.setOnClickListener { - collapse() + disallowParentInterceptTouchEvent(views.richTextComposerEditText) + disallowParentInterceptTouchEvent(views.plainTextComposerEditText) + + views.composerModeCloseView.setOnClickListener { callback?.onCloseRelatedMessage() } @@ -125,37 +200,56 @@ class RichTextComposerLayout @JvmOverloads constructor( callback?.onAddAttachment() } - views.composerFullScreenButton.setOnClickListener { - callback?.onFullScreenModeChanged() + 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) { + 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) { + 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_underlined, R.string.rich_text_editor_format_underline, ComposerAction.Underline) { + 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_strikethrough, R.string.rich_text_editor_format_strikethrough, ComposerAction.StrikeThrough) { + addRichTextMenuItem(R.drawable.ic_composer_strikethrough, R.string.rich_text_editor_format_strikethrough, ComposerAction.STRIKE_THROUGH) { views.richTextComposerEditText.toggleInlineFormat(InlineFormat.StrikeThrough) } } + @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.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) + views.richTextComposerEditText.actionStatesChangedListener = EditorEditText.OnActionStatesChangedListener { state -> + for (action in state.keys) { + updateMenuStateFor(action, state) } } @@ -166,6 +260,35 @@ class RichTextComposerLayout @JvmOverloads constructor( 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) } /** @@ -173,9 +296,9 @@ class RichTextComposerLayout @JvmOverloads constructor( */ private fun syncEditTexts() = if (isTextFormattingEnabled) { - views.plainTextComposerEditText.setText(views.richTextComposerEditText.getPlainText()) + views.plainTextComposerEditText.setText(views.richTextComposerEditText.getMarkdown()) } else { - views.richTextComposerEditText.setText(views.plainTextComposerEditText.text.toString()) + views.richTextComposerEditText.setMarkdown(views.plainTextComposerEditText.text.toString()) } private fun addRichTextMenuItem(@DrawableRes iconId: Int, @StringRes description: Int, action: ComposerAction, onClick: () -> Unit) { @@ -191,92 +314,106 @@ 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() { - val isExpanded = editText.editableText.lines().count() > 1 - val borderResource = if (isExpanded || isFullScreen) { - R.drawable.bg_composer_rich_edit_text_expanded + 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 + } + + 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 { - R.drawable.bg_composer_rich_edit_text_single_line - } - views.composerEditTextOuterBorder.setBackgroundResource(borderResource) + resources.getDimensionPixelSize(R.dimen.rich_text_composer_corner_radius_single_line) + }.toFloat() + borderShapeDrawable.setCornerSize(cornerSize) } - override fun replaceFormattedContent(text: CharSequence) { + private fun replaceFormattedContent(text: CharSequence) { views.richTextComposerEditText.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) - updateEditTextVisibility() - } - - 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) - updateEditTextVisibility() + updateTextFieldBorder(isFullScreen) } override fun setTextIfDifferent(text: CharSequence?): Boolean { - return editText.setTextIfDifferent(text) - } - - override fun toggleFullScreen(newValue: Boolean) { - val constraintSetId = if (newValue) R.layout.composer_rich_text_layout_constraint_set_fullscreen else currentConstraintSetId - ConstraintSet().also { - it.clone(context, constraintSetId) - it.applyTo(this) - } - - updateTextFieldBorder() - updateEditTextVisibility() - - updateEditTextFullScreenState(views.richTextComposerEditText, newValue) - updateEditTextFullScreenState(views.plainTextComposerEditText, newValue) + val result = editText.setTextIfDifferent(text) + updateTextFieldBorder(isFullScreen) + return result } private fun updateEditTextFullScreenState(editText: EditText, isFullScreen: Boolean) { if (isFullScreen) { editText.maxLines = Int.MAX_VALUE - // This is a workaround to fix incorrect scroll position when maximised - post { editText.requestLayout() } } else { - editText.maxLines = maxEditTextLinesWhenCollapsed + editText.maxLines = MessageComposerView.MAX_LINES_WHEN_COLLAPSED } } - private fun applyNewConstraintSet(animate: Boolean, transitionComplete: (() -> Unit)?) { - // val wasSendButtonInvisible = views.sendButton.isInvisible - if (animate) { - animateLayoutChange(animationDuration, transitionComplete) - } - ConstraintSet().also { - it.clone(context, currentConstraintSetId) - // SC-TODO? - //it.getConstraint(R.id.composerEmojiButton).propertySet.visibility = views.composerEmojiButton.visibility - it.applyTo(this) + override fun renderComposerMode(mode: MessageComposerMode, timelineViewModel: TimelineViewModel?) { + if (mode is MessageComposerMode.Special) { + views.composerModeGroup.isVisible = true + replaceFormattedContent(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) + } + } + views.sendButton.contentDescription = resources.getString(R.string.action_send) + hasRelatedMessage = false } - // Might be updated by view state just after, but avoid blinks - // views.sendButton.isInvisible = wasSendButtonInvisible - } + 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) + } + } - override fun setInvisible(isInvisible: Boolean) { - this.isInvisible = isInvisible + updateTextFieldBorder(isFullScreen) + + 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 + } } private class TextChangeListener( 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 fcfe000e40..ea9296508f 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 @@ -150,7 +150,8 @@ class VoiceMessageViews( fun showRecordingViews() { views.voiceMessageMicButton.isVisible = true - 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) /* This is a no-op for SchildiChat views.voiceMessageMicButton.updateLayoutParams { @@ -177,6 +178,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() @@ -288,6 +290,7 @@ class VoiceMessageViews( fun showDraftViews() { hideRecordingViews(RecordingUiState.Idle) + views.voiceMessageBackgroundView.isVisible = true views.voiceMessageMicButton.isVisible = false views.voiceMessageSendButton.isVisible = true views.voiceMessagePlaybackLayout.isVisible = true @@ -298,6 +301,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 ff9f812b57..efa36c8247 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 @@ -78,6 +78,7 @@ 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.powerlevels.PowerLevelsHelper +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 @@ -488,6 +489,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, @@ -561,7 +563,7 @@ class TimelineEventController @Inject constructor( event.eventId, readReceipts, callback, - partialState.isFromThreadTimeline() + partialState.isFromThreadTimeline(), ), formattedDayModel = formattedDayModel, mergedHeaderModel = mergedHeaderModel @@ -604,7 +606,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, @@ -622,6 +624,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 50533e2e31..14f4fc02bd 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.STICKER, 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 + EventType.STICKER) return false + if (event.root.getClearType() !in EventType.STATE_ROOM_BEACON_INFO.values + EventType.POLL_START.values + EventType.MESSAGE + EventType.STICKER) 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 2dd206804b..ee8d6c9c33 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() ?: "" } @@ -383,7 +383,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, @@ -530,13 +530,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 () @@ -582,14 +582,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/MessageItemFactory.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/factory/MessageItemFactory.kt index c141cf6037..9abd319ec2 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.style.TimelineMessageLayout import im.vector.app.features.home.room.detail.timeline.tools.createLinkMovementMethod import im.vector.app.features.home.room.detail.timeline.tools.linkify @@ -112,6 +114,7 @@ import org.matrix.android.sdk.api.session.room.model.message.getCaption import org.matrix.android.sdk.api.session.room.model.message.getFileName 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 timber.log.Timber @@ -146,6 +149,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? @@ -207,7 +211,7 @@ 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, callback, attributes) - is MessageBeaconInfoContent -> liveLocationShareMessageItemFactory.create(params.event, highlight, attributes) + is MessageBeaconInfoContent -> liveLocationShareMessageItemFactory.create(event, highlight, attributes) is MessageVoiceBroadcastInfoContent -> voiceBroadcastItemFactory.create(params, messageContent, highlight, attributes) else -> buildNotHandledMessageItem(messageContent, informationData, highlight, callback, attributes) } @@ -341,9 +345,11 @@ class MessageItemFactory @Inject constructor( highlight: Boolean, callback: TimelineEventController.Callback?, 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) @@ -466,7 +472,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( @@ -582,7 +595,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) } @@ -594,11 +608,23 @@ 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 val pseudoEmojiBody = htmlRenderer.get().render(customToPseudoEmoji(compressed), pillsPostProcessor) as Spanned - return buildMessageTextItem(renderedFormattedBody, true, informationData, highlight, callback, attributes, pseudoEmojiBody) + return buildMessageTextItem( + renderedFormattedBody, + true, + informationData, + highlight, + callback, + attributes, + pseudoEmojiBody, + ) } 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 0b0ac8c95c..15b6e73759 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 @@ -23,19 +23,22 @@ 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 im.vector.app.features.home.room.detail.timeline.style.TimelineMessageLayoutFactory +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, - private val messageLayoutFactory: TimelineMessageLayoutFactory, - private val localeProvider: LocaleProvider, +class ReadReceiptsItemFactory @Inject constructor( + private val avatarRenderer: AvatarRenderer, + private val messageLayoutFactory: TimelineMessageLayoutFactory, + private val localeProvider: LocaleProvider, + private val session: Session ) { fun create( eventId: String, readReceipts: List , callback: TimelineEventController.Callback?, - isFromThreadTimeLine: Boolean + isFromThreadTimeLine: Boolean, ): ReadReceiptsItem? { if (readReceipts.isEmpty()) { return null @@ -45,13 +48,14 @@ 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) .messageLayout(messageLayoutFactory.createDummy()) .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 e4f7bed72f..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 @@ -23,6 +23,7 @@ import im.vector.app.features.home.room.detail.timeline.helper.AvatarSizeProvide 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 @@ -47,6 +48,7 @@ class VoiceBroadcastItemFactory @Inject constructor( private val voiceBroadcastRecorder: VoiceBroadcastRecorder?, private val voiceBroadcastPlayer: VoiceBroadcastPlayer, private val playbackTracker: AudioMessagePlaybackTracker, + private val noticeItemFactory: NoticeItemFactory, ) { fun create( @@ -54,9 +56,11 @@ class VoiceBroadcastItemFactory @Inject constructor( messageContent: MessageVoiceBroadcastInfoContent, highlight: Boolean, attributes: AbsMessageItem.Attributes, - ): AbsMessageVoiceBroadcastItem<*>? { + ): BaseEventItem<*>? { // Only display item of the initial event with updated data - if (messageContent.voiceBroadcastState != VoiceBroadcastState.STARTED) return null + 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 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 cd488bf29b..ab4de689f3 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 @@ -131,17 +131,17 @@ 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) } else -> { @@ -229,17 +229,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 -> { 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 ca0ef6d0f9..03f210c3dd 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,7 @@ 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 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 @@ -107,9 +108,10 @@ 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, + VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO -> formatDebug(timelineEvent/*.root*/) else -> { Timber.v("Type $type not handled by this formatter") null 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 90fd66f9ab..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 @@ -67,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 @@ -85,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)) } } @@ -110,21 +111,23 @@ 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 } } - 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 } } 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 b26da5ffa4..1725759307 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 @@ -35,11 +35,13 @@ import im.vector.app.features.themes.BubbleThemeUtils 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.members.roomMemberQueryParams import org.matrix.android.sdk.api.session.room.model.Membership import org.matrix.android.sdk.api.session.room.model.ReferencesAggregatedContent @@ -87,7 +89,9 @@ class MessageInformationDataFactory @Inject constructor( it } } - val e2eDecoration = getE2EDecoration(roomSummary, event) + + val e2eDecoration = getE2EDecoration(roomSummary, params.lastEdit ?: event.root) + val senderId = getSenderId(event) // Sometimes, member information is not available at this point yet, so let's completely rely on the DM flag for now. // Since this can change while processing multiple messages in the same chat, we want to stick to information that is always available, @@ -136,7 +140,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, @@ -184,6 +188,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?, @@ -201,34 +213,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 { @@ -255,8 +267,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 @@ -267,13 +279,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 2411cb3877..51e961f247 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 @@ -54,9 +54,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 17db8b1f3b..a212566761 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 @@ -243,10 +248,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 != VoiceBroadcastState.STARTED) { + 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/item/MessageVoiceBroadcastListeningItem.kt b/vector/src/main/java/im/vector/app/features/home/room/detail/timeline/item/MessageVoiceBroadcastListeningItem.kt index e5cb677763..38fe1e8f17 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 @@ -17,7 +17,6 @@ package im.vector.app.features.home.room.detail.timeline.item import android.text.format.DateUtils -import android.view.View import android.widget.ImageButton import android.widget.SeekBar import android.widget.TextView @@ -30,6 +29,7 @@ import im.vector.app.features.home.room.detail.RoomDetailAction.VoiceBroadcastAc 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 im.vector.app.features.voicebroadcast.views.VoiceBroadcastBufferingView import im.vector.app.features.voicebroadcast.views.VoiceBroadcastMetadataView @EpoxyModelClass @@ -63,10 +63,10 @@ abstract class MessageVoiceBroadcastListeningItem : AbsMessageVoiceBroadcastItem playPauseButton.setOnClickListener { if (player.currentVoiceBroadcast == voiceBroadcast) { when (player.playingState) { - VoiceBroadcastPlayer.State.PLAYING -> callback?.onTimelineItemAction(VoiceBroadcastAction.Listening.Pause) + VoiceBroadcastPlayer.State.PLAYING, + VoiceBroadcastPlayer.State.BUFFERING -> callback?.onTimelineItemAction(VoiceBroadcastAction.Listening.Pause) VoiceBroadcastPlayer.State.PAUSED, VoiceBroadcastPlayer.State.IDLE -> callback?.onTimelineItemAction(VoiceBroadcastAction.Listening.PlayOrResume(voiceBroadcast)) - VoiceBroadcastPlayer.State.BUFFERING -> Unit } } else { callback?.onTimelineItemAction(VoiceBroadcastAction.Listening.PlayOrResume(voiceBroadcast)) @@ -86,7 +86,6 @@ abstract class MessageVoiceBroadcastListeningItem : AbsMessageVoiceBroadcastItem override fun renderMetadata(holder: Holder) { with(holder) { broadcasterNameMetadata.value = recorderName - voiceBroadcastMetadata.isVisible = true listenersCountMetadata.isVisible = false } } @@ -102,10 +101,11 @@ abstract class MessageVoiceBroadcastListeningItem : AbsMessageVoiceBroadcastItem 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_pause_voice_broadcast) } @@ -114,7 +114,6 @@ abstract class MessageVoiceBroadcastListeningItem : AbsMessageVoiceBroadcastItem playPauseButton.setImageResource(R.drawable.ic_play_pause_play) playPauseButton.contentDescription = view.resources.getString(R.string.a11y_play_voice_broadcast) } - VoiceBroadcastPlayer.State.BUFFERING -> Unit } renderLiveIndicator(holder) @@ -142,14 +141,14 @@ abstract class MessageVoiceBroadcastListeningItem : AbsMessageVoiceBroadcastItem renderBackwardForwardButtons(holder, playbackState) renderLiveIndicator(holder) if (!isUserSeeking) { - holder.seekBar.progress = playbackTracker.getPlaybackTime(voiceBroadcast.voiceBroadcastId) + holder.seekBar.progress = playbackTracker.getPlaybackTime(voiceBroadcast.voiceBroadcastId) ?: 0 } } } private fun renderBackwardForwardButtons(holder: Holder, playbackState: State) { val isPlayingOrPaused = playbackState is State.Playing || playbackState is State.Paused - val playbackTime = playbackTracker.getPlaybackTime(voiceBroadcast.voiceBroadcastId) + val playbackTime = playbackTracker.getPlaybackTime(voiceBroadcast.voiceBroadcastId) ?: 0 val canBackward = isPlayingOrPaused && playbackTime > 0 val canForward = isPlayingOrPaused && playbackTime < duration holder.fastBackwardButton.isInvisible = !canBackward @@ -174,7 +173,7 @@ abstract class MessageVoiceBroadcastListeningItem : AbsMessageVoiceBroadcastItem class Holder : AbsMessageVoiceBroadcastItem.Holder(STUB_ID) { val playPauseButton by bind (R.id.playPauseButton) - val bufferingView by bind (R.id.bufferingView) + 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) 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 8f51f7930a..5b941fb94d 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 @@ -55,8 +55,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/html/EventHtmlRenderer.kt b/vector/src/main/java/im/vector/app/features/html/EventHtmlRenderer.kt index 029b6c6cf0..4da9e5caf4 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 @@ -28,6 +28,7 @@ package im.vector.app.features.html import android.content.Context import android.content.res.Resources import android.graphics.Bitmap +import android.graphics.Typeface import android.graphics.drawable.Drawable import android.text.Spannable import androidx.core.text.toSpannable @@ -46,8 +47,9 @@ import im.vector.app.features.themes.ThemeUtils import io.noties.markwon.AbstractMarkwonPlugin import io.noties.markwon.Markwon import io.noties.markwon.MarkwonPlugin -import io.noties.markwon.core.MarkwonTheme +import io.noties.markwon.MarkwonSpansFactory import io.noties.markwon.PrecomputedFutureTextSetterCompat +import io.noties.markwon.core.MarkwonTheme import io.noties.markwon.ext.latex.JLatexMathPlugin import io.noties.markwon.ext.latex.JLatexMathTheme import io.noties.markwon.html.HtmlPlugin @@ -57,6 +59,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 @@ -246,6 +250,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/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/tracking/LocationSharingAndroidService.kt b/vector/src/main/java/im/vector/app/features/location/live/tracking/LocationSharingAndroidService.kt index 545f98f01e..ccab23a83b 100644 --- a/vector/src/main/java/im/vector/app/features/location/live/tracking/LocationSharingAndroidService.kt +++ b/vector/src/main/java/im/vector/app/features/location/live/tracking/LocationSharingAndroidService.kt @@ -21,7 +21,6 @@ import android.os.IBinder import android.os.Parcelable import androidx.core.app.NotificationManagerCompat import dagger.hilt.android.AndroidEntryPoint -import im.vector.app.R import im.vector.app.core.di.ActiveSessionHolder import im.vector.app.core.services.VectorAndroidService import im.vector.app.features.location.LocationData @@ -125,10 +124,7 @@ class LocationSharingAndroidService : VectorAndroidService(), LocationTracker.Ca val updateLiveResult = session .getRoom(roomArgs.roomId) ?.locationSharingService() - ?.startLiveLocationShare( - timeoutMillis = roomArgs.durationMillis, - description = getString(R.string.live_location_description) - ) + ?.startLiveLocationShare(roomArgs.durationMillis) updateLiveResult ?.let { result -> 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 86c9ac43db..41388fec6a 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 @@ -68,8 +68,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..455f4778e8 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) } } } } @@ -148,6 +148,7 @@ class NotificationBroadcastReceiver : BroadcastReceiver() { body = message, imageUriString = null, roomId = room.roomId, + threadId = null, // needs to be changed: https://github.com/vector-im/element-android/issues/7475 roomName = room.roomSummary()?.displayName ?: room.roomId, roomIsDirect = room.roomSummary()?.isDirect == true, outGoingMessage = true, 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/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 b62e72daee..3c8f3c25d9 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/rageshake/BugReporter.kt b/vector/src/main/java/im/vector/app/features/rageshake/BugReporter.kt index 64561d02d7..ad6f1b8719 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 @@ -289,7 +289,7 @@ class BugReporter @Inject constructor( .addFormDataPart("can_contact", canContact.toString()) .addFormDataPart("is_debug_build", BuildConfig.DEBUG.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) @@ -324,11 +324,6 @@ class BugReporter @Inject constructor( .addFormDataPart("reportTime", reportTime) .addFormDataPart("packageName", buildMeta.applicationId) - 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/roomprofile/uploads/RoomUploadsViewModel.kt b/vector/src/main/java/im/vector/app/features/roomprofile/uploads/RoomUploadsViewModel.kt index 87dff2f00b..a71490f4a7 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,10 +26,12 @@ 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.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 @@ -78,6 +80,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 48e94f0daa..51efaa8b74 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 @@ -133,6 +133,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" @@ -218,6 +219,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" @@ -1278,6 +1282,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. */ @@ -1496,4 +1504,14 @@ 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(VectorPreferences.SETTINGS_SESSION_MANAGER_SHOW_IP_ADDRESS, isVisible) + } + } } 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/devices/v2/DevicesAction.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/DevicesAction.kt index 21cbb86e94..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 @@ -29,4 +29,5 @@ sealed class DevicesAction : VectorViewModelAction { 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/DevicesViewModel.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/DevicesViewModel.kt index cd97795b69..f42d5af398 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 @@ -25,6 +26,7 @@ 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.InterceptSignoutFlowResponseUseCase import im.vector.app.features.settings.devices.v2.signout.SignoutSessionsReAuthNeeded @@ -49,7 +51,12 @@ class DevicesViewModel @AssistedInject constructor( private val interceptSignoutFlowResponseUseCase: InterceptSignoutFlowResponseUseCase, 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 { @@ -63,6 +70,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() { @@ -112,9 +141,14 @@ class DevicesViewModel @AssistedInject constructor( 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() 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 3a3c3463fb..b27d8a7270 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 @@ -146,11 +146,19 @@ class VectorSettingsDevicesFragment : 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) @@ -240,7 +248,7 @@ class VectorSettingsDevicesFragment : renderSecurityRecommendations(state.inactiveSessionsCount, state.unverifiedSessionsCount, isCurrentSessionVerified) renderCurrentDevice(currentDeviceInfo) - renderOtherSessionsView(otherDevices) + renderOtherSessionsView(otherDevices, state.isShowingIpAddress) } else { hideSecurityRecommendations() hideCurrentSessionView() @@ -297,7 +305,7 @@ class VectorSettingsDevicesFragment : hideInactiveSessionsRecommendation() } - private fun renderOtherSessionsView(otherDevices: List ?) { + private fun renderOtherSessionsView(otherDevices: List ?, isShowingIpAddress: Boolean) { if (otherDevices.isNullOrEmpty()) { hideOtherSessionsView() } else { @@ -308,12 +316,18 @@ class VectorSettingsDevicesFragment : multiSignoutItem.title = stringProvider.getQuantityString(R.plurals.device_manager_other_sessions_multi_signout_all, nbDevices, nbDevices) multiSignoutItem.setTextColor(color) 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) + } + } } private fun hideOtherSessionsView() { 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..9d9cb15c28 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 @@ -100,6 +104,7 @@ abstract class OtherSessionItem : VectorEpoxyModel (R.la 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/SessionInfoView.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/list/SessionInfoView.kt index 3d9c3a8f37..c6044d04a4 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 @@ -76,6 +76,7 @@ class SessionInfoView @JvmOverloads constructor( sessionInfoViewState.deviceFullInfo.isInactive, sessionInfoViewState.deviceFullInfo.deviceInfo, sessionInfoViewState.isLastSeenDetailsVisible, + sessionInfoViewState.isShowingIpAddress, dateFormatter, drawableProvider, colorProvider, @@ -157,6 +158,7 @@ class SessionInfoView @JvmOverloads constructor( isInactive: Boolean, deviceInfo: DeviceInfo, isLastSeenDetailsVisible: Boolean, + isShowingIpAddress: Boolean, dateFormatter: VectorDateFormatter, drawableProvider: DrawableProvider, colorProvider: ColorProvider, @@ -186,7 +188,7 @@ class SessionInfoView @JvmOverloads constructor( } else { views.sessionInfoLastActivityTextView.isGone = true } - views.sessionInfoLastIPAddressTextView.setTextOrHide(deviceInfo.lastSeenIp?.takeIf { isLastSeenDetailsVisible }) + views.sessionInfoLastIPAddressTextView.setTextOrHide(deviceInfo.lastSeenIp?.takeIf { isLastSeenDetailsVisible && 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..5d3c4b4f4b 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 @@ -25,4 +25,5 @@ data class SessionInfoViewState( val isDetailsButtonVisible: Boolean = true, val isLearnMoreLinkVisible: Boolean = false, val isLastSeenDetailsVisible: Boolean = false, + val isShowingIpAddress: Boolean = false, ) 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 24d2a08bdc..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 @@ -33,4 +33,5 @@ sealed class OtherSessionsAction : VectorViewModelAction { 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/OtherSessionsFragment.kt b/vector/src/main/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsFragment.kt index 74a78b2415..87330b087a 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 @@ -85,6 +85,12 @@ 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) } } @@ -130,10 +136,18 @@ class OtherSessionsFragment : 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) @@ -213,7 +227,7 @@ class OtherSessionsFragment : updateLoading(state.isLoading) if (state.devices is Success) { val devices = state.devices.invoke() - renderDevices(devices, state.currentFilter) + renderDevices(devices, state.currentFilter, state.isShowingIpAddress) updateToolbar(devices, state.isSelectModeEnabled) } } @@ -237,7 +251,7 @@ class OtherSessionsFragment : 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 @@ -299,7 +313,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) } } 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 9b4c26ee4f..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 @@ -25,8 +26,10 @@ 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 @@ -42,10 +45,12 @@ class OtherSessionsViewModel @AssistedInject constructor( private val getDeviceFullInfoListUseCase: GetDeviceFullInfoListUseCase, private val signoutSessionsUseCase: SignoutSessionsUseCase, private val pendingAuthHandler: PendingAuthHandler, - refreshDevicesUseCase: RefreshDevicesUseCase + refreshDevicesUseCase: RefreshDevicesUseCase, + private val vectorPreferences: VectorPreferences, + private val toggleIpAddressVisibilityUseCase: ToggleIpAddressVisibilityUseCase, ) : VectorSessionsListViewModel ( initialState, activeSessionHolder, refreshDevicesUseCase -) { +), SharedPreferences.OnSharedPreferenceChangeListener { @AssistedFactory interface Factory : MavericksAssistedViewModelFactory { @@ -58,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) { @@ -85,9 +112,14 @@ class OtherSessionsViewModel @AssistedInject constructor( 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( 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 c0b50fded8..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 @@ -28,6 +28,7 @@ data class OtherSessionsViewState( 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 d722cda7a1..be60b3b805 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,6 +19,7 @@ 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 @@ -156,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) } @@ -206,6 +225,7 @@ class SessionOverviewFragment : isDetailsButtonVisible = false, isLearnMoreLinkVisible = deviceInfo.roomEncryptionTrustLevel != RoomEncryptionTrustLevel.Default, isLastSeenDetailsVisible = !isCurrentSession, + isShowingIpAddress = viewState.isShowingIpAddress, ) views.sessionOverviewInfo.render(infoViewState, dateFormatter, drawableProvider, colorProvider, stringProvider) views.sessionOverviewInfo.onLearnMoreClickListener = { 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 a56872e648..472e0a4269 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,6 +16,7 @@ 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 @@ -25,7 +26,9 @@ 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.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.notification.GetNotificationsStatusUseCase import im.vector.app.features.settings.devices.v2.notification.TogglePushNotificationUseCase @@ -54,9 +57,11 @@ class SessionOverviewViewModel @AssistedInject constructor( private val togglePushNotificationUseCase: TogglePushNotificationUseCase, 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() @@ -70,6 +75,27 @@ class SessionOverviewViewModel @AssistedInject constructor( observeSessionInfo(initialState.deviceId) observeCurrentSessionInfo() 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() { @@ -111,9 +137,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() 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 019dd2d724..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 @@ -28,6 +28,7 @@ data class SessionOverviewViewState( val deviceInfo: Async = Uninitialized, val isLoading: 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/sync/SyncUtils.kt b/vector/src/main/java/im/vector/app/features/sync/SyncUtils.kt new file mode 100644 index 0000000000..e3408d8814 --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/sync/SyncUtils.kt @@ -0,0 +1,48 @@ +/* + * 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.sync + +import org.matrix.android.sdk.api.session.events.model.EventType +import org.matrix.android.sdk.api.session.sync.filter.SyncFilterBuilder + +object SyncUtils { + // Get only managed types by Element + private val listOfSupportedTimelineEventTypes = 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 + ) + + fun getSyncFilterBuilder(): SyncFilterBuilder { + return SyncFilterBuilder() + .useThreadNotifications(true) + .lazyLoadMembersForStateEvents(true) + /** + * Currently we don't set [lazy_load_members = true] for Filter.room.timeline even though we set it for RoomFilter which is used later to + * fetch messages in a room. It's not clear if it's done so by mistake or intentionally, so changing it could case side effects and need + * careful testing + * */ +// .lazyLoadMembersForMessageEvents(true) +// .listOfSupportedStateEventTypes(listOfSupportedStateEventTypes) +// .listOfSupportedTimelineEventTypes(listOfSupportedTimelineEventTypes) + } +} 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/listening/VoiceBroadcastPlayerImpl.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/listening/VoiceBroadcastPlayerImpl.kt index 5b0e5b2b1c..f8025d078e 100644 --- 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 @@ -21,6 +21,7 @@ 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 @@ -30,13 +31,12 @@ import im.vector.app.features.voicebroadcast.listening.VoiceBroadcastPlayer.Stat 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.GetVoiceBroadcastEventUseCase +import im.vector.app.features.voicebroadcast.usecase.GetMostRecentVoiceBroadcastStateEventUseCase 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.orFalse import org.matrix.android.sdk.api.extensions.tryOrNull import org.matrix.android.sdk.api.session.room.model.message.MessageAudioContent import timber.log.Timber @@ -48,7 +48,7 @@ import javax.inject.Singleton class VoiceBroadcastPlayerImpl @Inject constructor( private val sessionHolder: ActiveSessionHolder, private val playbackTracker: AudioMessagePlaybackTracker, - private val getVoiceBroadcastEventUseCase: GetVoiceBroadcastEventUseCase, + private val getVoiceBroadcastEventUseCase: GetMostRecentVoiceBroadcastStateEventUseCase, private val getLiveVoiceBroadcastChunksUseCase: GetLiveVoiceBroadcastChunksUseCase ) : VoiceBroadcastPlayer { @@ -66,14 +66,14 @@ class VoiceBroadcastPlayerImpl @Inject constructor( private var nextMediaPlayer: MediaPlayer? = null private var isPreparingNextPlayer: Boolean = false - private var currentVoiceBroadcastEvent: VoiceBroadcastEvent? = null + private var mostRecentVoiceBroadcastEvent: VoiceBroadcastEvent? = null override var currentVoiceBroadcast: VoiceBroadcast? = null override var isLiveListening: Boolean = false @MainThread set(value) { if (field != value) { - Timber.w("isLiveListening: $field -> $value") + Timber.d("## Voice Broadcast | isLiveListening: $field -> $value") field = value onLiveListeningChanged(value) } @@ -83,7 +83,7 @@ class VoiceBroadcastPlayerImpl @Inject constructor( @MainThread set(value) { if (field != value) { - Timber.w("playingState: $field -> $value") + Timber.d("## Voice Broadcast | playingState: $field -> $value") field = value onPlayingStateChanged(value) } @@ -121,7 +121,7 @@ class VoiceBroadcastPlayerImpl @Inject constructor( // Clear playlist playlist.reset() - currentVoiceBroadcastEvent = null + mostRecentVoiceBroadcastEvent = null currentVoiceBroadcast = null } @@ -145,19 +145,25 @@ class VoiceBroadcastPlayerImpl @Inject constructor( playingState = State.BUFFERING - observeVoiceBroadcastLiveState(voiceBroadcast) - fetchPlaylistAndStartPlayback(voiceBroadcast) + observeVoiceBroadcastStateEvent(voiceBroadcast) } - private fun observeVoiceBroadcastLiveState(voiceBroadcast: VoiceBroadcast) { + private fun observeVoiceBroadcastStateEvent(voiceBroadcast: VoiceBroadcast) { voiceBroadcastStateObserver = getVoiceBroadcastEventUseCase.execute(voiceBroadcast) - .onEach { - currentVoiceBroadcastEvent = it.getOrNull() - updateLiveListeningMode() - } + .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 { @@ -169,41 +175,35 @@ class VoiceBroadcastPlayerImpl @Inject constructor( private fun onPlaylistUpdated() { when (playingState) { - State.PLAYING -> { - if (nextMediaPlayer == null && !isPreparingNextPlayer) { - prepareNextMediaPlayer() - } - } + State.PLAYING, State.PAUSED -> { if (nextMediaPlayer == null && !isPreparingNextPlayer) { prepareNextMediaPlayer() } } State.BUFFERING -> { - val nextItem = playlist.getNextItem() + 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) { - val savedPosition = currentVoiceBroadcast?.let { playbackTracker.getPlaybackTime(it.voiceBroadcastId) } - startPlayback(savedPosition?.takeIf { it > 0 }) + startPlayback(nextItem.startTime) } } - State.IDLE -> { - val savedPosition = currentVoiceBroadcast?.let { playbackTracker.getPlaybackTime(it.voiceBroadcastId) } - startPlayback(savedPosition?.takeIf { it > 0 }) - } + State.IDLE -> Unit // Should not happen } } - private fun startPlayback(position: Int? = null) { + private fun startPlayback(position: Int) { stopPlayer() - val playlistItem = when { - position != null -> playlist.findByPosition(position) - currentVoiceBroadcastEvent?.isLive.orFalse() -> playlist.lastOrNull() - else -> playlist.firstOrNull() - } - val content = playlistItem?.audioEvent?.content ?: run { Timber.w("## VoiceBroadcastPlayer: No content to play"); return } - val sequence = playlistItem.sequence ?: run { Timber.w("## VoiceBroadcastPlayer: playlist item has no sequence"); return } - val sequencePosition = position?.let { it - playlistItem.startTime } ?: 0 + 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 -> @@ -217,33 +217,28 @@ class VoiceBroadcastPlayerImpl @Inject constructor( prepareNextMediaPlayer() } } catch (failure: Throwable) { - Timber.e(failure, "Unable to start playback") + Timber.e(failure, "## Voice Broadcast | Unable to start playback: $failure") throw VoiceFailure.UnableToPlay(failure) } } } - private fun pausePlayback(positionMillis: Int? = null) { - if (positionMillis == null) { + 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() - val voiceBroadcastId = currentVoiceBroadcast?.voiceBroadcastId - val duration = playlist.duration.takeIf { it > 0 } - if (voiceBroadcastId != null && duration != null) { - playbackTracker.updatePausedAtPlaybackTime(voiceBroadcastId, positionMillis, positionMillis.toFloat() / duration) - } } - playingState = State.PAUSED } private fun resumePlayback() { if (currentMediaPlayer != null) { - currentMediaPlayer?.start() playingState = State.PLAYING + currentMediaPlayer?.start() } else { - val position = currentVoiceBroadcast?.voiceBroadcastId?.let { playbackTracker.getPlaybackTime(it) } - startPlayback(position) + val savedPosition = currentVoiceBroadcast?.voiceBroadcastId?.let { playbackTracker.getPlaybackTime(it) } ?: 0 + startPlayback(savedPosition) } } @@ -257,7 +252,8 @@ class VoiceBroadcastPlayerImpl @Inject constructor( startPlayback(positionMillis) } playingState == State.IDLE || playingState == State.PAUSED -> { - pausePlayback(positionMillis) + stopPlayer() + playbackTracker.updatePausedAtPlaybackTime(voiceBroadcast.voiceBroadcastId, positionMillis, positionMillis.toFloat() / duration) } } } @@ -268,9 +264,19 @@ class VoiceBroadcastPlayerImpl @Inject constructor( isPreparingNextPlayer = true sessionScope.launch { prepareMediaPlayer(nextItem.audioEvent.content) { mp -> - nextMediaPlayer = mp - currentMediaPlayer?.setNextMediaPlayer(mp) isPreparingNextPlayer = false + nextMediaPlayer = mp + when (playingState) { + State.PLAYING, + State.PAUSED -> { + currentMediaPlayer?.setNextMediaPlayer(mp) + } + State.BUFFERING -> { + mp.start() + onNextMediaPlayerStarted(mp) + } + State.IDLE -> stopPlayer() + } } } } @@ -281,7 +287,7 @@ class VoiceBroadcastPlayerImpl @Inject constructor( val audioFile = try { session.fileService().downloadFile(messageAudioContent) } catch (failure: Throwable) { - Timber.e(failure, "Unable to start playback") + Timber.e(failure, "Voice Broadcast | Download has failed: $failure") throw VoiceFailure.UnableToPlay(failure) } @@ -340,7 +346,7 @@ class VoiceBroadcastPlayerImpl @Inject constructor( private fun updateLiveListeningMode(seekPosition: Int? = null) { isLiveListening = when { // the current voice broadcast is not live (ended) - currentVoiceBroadcastEvent?.isLive?.not().orFalse() -> false + mostRecentVoiceBroadcastEvent?.isLive != true -> false // the player is stopped or paused playingState == State.IDLE || playingState == State.PAUSED -> false seekPosition != null -> { @@ -357,8 +363,12 @@ class VoiceBroadcastPlayerImpl @Inject constructor( isLiveListening && newSequence == playlist.currentSequence } } - // otherwise, stay in live or go in live if we reached the latest sequence - else -> isLiveListening || playlist.currentSequence == playlist.lastOrNull()?.sequence + // 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 } } @@ -367,12 +377,25 @@ class VoiceBroadcastPlayerImpl @Inject constructor( // Notify live mode change to all the listeners attached to the current voice broadcast id listeners[voiceBroadcastId]?.forEach { listener -> listener.onLiveModeChanged(isLiveListening) } } + + // 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 playlistPosition = playlist.currentItem?.startTime - val computedPosition = currentMediaPlayer?.currentPosition?.let { playlistPosition?.plus(it) } ?: playlistPosition - val savedPosition = currentVoiceBroadcast?.voiceBroadcastId?.let { playbackTracker.getPlaybackTime(it) } + val voiceBroadcastId = currentVoiceBroadcast?.voiceBroadcastId ?: return null + val computedPosition = currentMediaPlayer?.currentPosition?.let { playlist.currentItem?.startTime?.plus(it) } + val savedPosition = playbackTracker.getPlaybackTime(voiceBroadcastId) return computedPosition ?: savedPosition } @@ -392,27 +415,24 @@ class VoiceBroadcastPlayerImpl @Inject constructor( override fun onInfo(mp: MediaPlayer, what: Int, extra: Int): Boolean { when (what) { - MediaPlayer.MEDIA_INFO_STARTED_AS_NEXT -> { - playlist.currentSequence = playlist.currentSequence?.inc() - currentMediaPlayer = mp - nextMediaPlayer = null - playingState = State.PLAYING - prepareNextMediaPlayer() - } + 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 content = currentVoiceBroadcastEvent?.content - val isLive = content?.isLive.orFalse() - if (!isLive && content?.lastChunkSequence == playlist.currentSequence) { + 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 } } 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 index 16b15b9a77..03e713eeaa 100644 --- 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 @@ -24,7 +24,7 @@ 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.GetVoiceBroadcastEventUseCase +import im.vector.app.features.voicebroadcast.usecase.GetMostRecentVoiceBroadcastStateEventUseCase import im.vector.app.features.voicebroadcast.voiceBroadcastId import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.Flow @@ -48,7 +48,7 @@ import javax.inject.Inject */ class GetLiveVoiceBroadcastChunksUseCase @Inject constructor( private val activeSessionHolder: ActiveSessionHolder, - private val getVoiceBroadcastEventUseCase: GetVoiceBroadcastEventUseCase, + private val getVoiceBroadcastEventUseCase: GetMostRecentVoiceBroadcastStateEventUseCase, ) { fun execute(voiceBroadcast: VoiceBroadcast): Flow > { diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/VoiceBroadcastRecorder.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/VoiceBroadcastRecorder.kt index bc13d1fea8..00e4bb17dd 100644 --- a/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/VoiceBroadcastRecorder.kt +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/VoiceBroadcastRecorder.kt @@ -18,6 +18,7 @@ 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 { @@ -31,7 +32,7 @@ interface VoiceBroadcastRecorder : VoiceRecorder { /** Current remaining time of recording, in seconds, if any. */ val currentRemainingTime: Long? - fun startRecord(roomId: String, chunkLength: Int, maxLength: Int) + fun startRecordVoiceBroadcast(voiceBroadcast: VoiceBroadcast, chunkLength: Int, maxLength: Int) fun addListener(listener: Listener) fun removeListener(listener: Listener) 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 index c5408b768b..b751417ca6 100644 --- 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 @@ -20,8 +20,17 @@ 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.GetMostRecentVoiceBroadcastStateEventUseCase 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 @@ -30,10 +39,17 @@ import java.util.concurrent.TimeUnit @RequiresApi(Build.VERSION_CODES.Q) class VoiceBroadcastRecorderQ( context: Context, + private val sessionHolder: ActiveSessionHolder, + private val getVoiceBroadcastEventUseCase: GetMostRecentVoiceBroadcastStateEventUseCase ) : 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 currentRoomId: String? = null + private var currentVoiceBroadcast: VoiceBroadcast? = null private var currentMaxLength: Int = 0 override var currentSequence = 0 @@ -68,17 +84,20 @@ class VoiceBroadcastRecorderQ( } } - override fun startRecord(roomId: String, chunkLength: Int, maxLength: Int) { - currentRoomId = roomId + 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 - startRecord(roomId) - recordingState = VoiceBroadcastRecorder.State.Recording - recordingTicker.start() + + observeVoiceBroadcastStateEvent(voiceBroadcast) } override fun pauseRecord() { + if (recordingState != VoiceBroadcastRecorder.State.Recording) return tryOrNull { mediaRecorder?.stop() } mediaRecorder?.reset() recordingState = VoiceBroadcastRecorder.State.Paused @@ -87,8 +106,9 @@ class VoiceBroadcastRecorderQ( } override fun resumeRecord() { + if (recordingState != VoiceBroadcastRecorder.State.Paused) return currentSequence++ - currentRoomId?.let { startRecord(it) } + currentVoiceBroadcast?.let { startRecord(it.roomId) } recordingState = VoiceBroadcastRecorder.State.Recording recordingTicker.resume() } @@ -104,11 +124,15 @@ class VoiceBroadcastRecorderQ( // Remove listeners listeners.clear() + // Do not observe anymore voice broadcast changes + voiceBroadcastStateObserver?.cancel() + voiceBroadcastStateObserver = null + // Reset data currentSequence = 0 currentMaxLength = 0 currentRemainingTime = null - currentRoomId = null + currentVoiceBroadcast = null } override fun release() { @@ -126,6 +150,26 @@ class VoiceBroadcastRecorderQ( 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) } diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/PauseVoiceBroadcastUseCase.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/PauseVoiceBroadcastUseCase.kt index 58e1f26f44..0b22d7adf5 100644 --- a/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/PauseVoiceBroadcastUseCase.kt +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/PauseVoiceBroadcastUseCase.kt @@ -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/recording/usecase/ResumeVoiceBroadcastUseCase.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/ResumeVoiceBroadcastUseCase.kt index 524b64e095..5be726c03e 100644 --- a/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/ResumeVoiceBroadcastUseCase.kt +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/ResumeVoiceBroadcastUseCase.kt @@ -20,7 +20,6 @@ import im.vector.app.features.voicebroadcast.VoiceBroadcastConstants 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 @@ -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/recording/usecase/StartVoiceBroadcastUseCase.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/StartVoiceBroadcastUseCase.kt index 45f622ad92..e3814608ea 100644 --- a/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/StartVoiceBroadcastUseCase.kt +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/StartVoiceBroadcastUseCase.kt @@ -24,11 +24,13 @@ import im.vector.app.features.session.coroutineScope import im.vector.app.features.voicebroadcast.VoiceBroadcastConstants 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.recording.VoiceBroadcastRecorder import im.vector.app.features.voicebroadcast.usecase.GetOngoingVoiceBroadcastsUseCase 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 @@ -43,6 +45,8 @@ 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 @@ -63,6 +67,7 @@ class StartVoiceBroadcastUseCase @Inject constructor( assertCanStartVoiceBroadcast(room) startVoiceBroadcast(room) + return Result.success(Unit) } private suspend fun startVoiceBroadcast(room: Room) { @@ -79,13 +84,18 @@ class StartVoiceBroadcastUseCase @Inject constructor( ).toContent() ) - startRecording(room, eventId, chunkLength, maxLength) + 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, maxLength: 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?) { @@ -94,10 +104,10 @@ class StartVoiceBroadcastUseCase @Inject constructor( } } }) - voiceBroadcastRecorder?.startRecord(room.roomId, chunkLength, maxLength) + 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", @@ -109,7 +119,7 @@ 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() ) diff --git a/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/StopVoiceBroadcastUseCase.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/StopVoiceBroadcastUseCase.kt index da13100609..b93bd346db 100644 --- a/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/StopVoiceBroadcastUseCase.kt +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/recording/usecase/StopVoiceBroadcastUseCase.kt @@ -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/GetMostRecentVoiceBroadcastStateEventUseCase.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/GetMostRecentVoiceBroadcastStateEventUseCase.kt new file mode 100644 index 0000000000..e0179e403f --- /dev/null +++ b/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/GetMostRecentVoiceBroadcastStateEventUseCase.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 GetMostRecentVoiceBroadcastStateEventUseCase @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/usecase/GetVoiceBroadcastEventUseCase.kt b/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/GetVoiceBroadcastEventUseCase.kt deleted file mode 100644 index 94eca2b54e..0000000000 --- a/vector/src/main/java/im/vector/app/features/voicebroadcast/usecase/GetVoiceBroadcastEventUseCase.kt +++ /dev/null @@ -1,68 +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.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.VoiceBroadcastState -import im.vector.app.features.voicebroadcast.model.asVoiceBroadcastEvent -import im.vector.app.features.voicebroadcast.voiceBroadcastId -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.filter -import kotlinx.coroutines.flow.flowOf -import kotlinx.coroutines.flow.onStart -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.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 GetVoiceBroadcastEventUseCase @Inject constructor( - private val session: Session, -) { - - fun execute(voiceBroadcast: VoiceBroadcast): Flow > { - val room = session.getRoom(voiceBroadcast.roomId) ?: error("Unknown roomId: ${voiceBroadcast.roomId}") - - Timber.d("## GetVoiceBroadcastUseCase: get voice broadcast $voiceBroadcast") - - val initialEvent = room.timelineService().getTimelineEvent(voiceBroadcast.voiceBroadcastId)?.root?.asVoiceBroadcastEvent() - val latestEvent = room.timelineService().getTimelineEventsRelatedTo(RelationType.REFERENCE, voiceBroadcast.voiceBroadcastId) - .mapNotNull { it.root.asVoiceBroadcastEvent() } - .maxByOrNull { it.root.originServerTs ?: 0 } - ?: initialEvent - - return when (latestEvent?.content?.voiceBroadcastState) { - null, VoiceBroadcastState.STOPPED -> flowOf(latestEvent.toOptional()) - else -> { - room.flow() - .liveStateEvent(VoiceBroadcastConstants.STATE_ROOM_VOICE_BROADCAST_INFO, QueryStringValue.Equals(latestEvent.root.stateKey.orEmpty())) - .onStart { emit(latestEvent.root.toOptional()) } - .distinctUntilChanged() - .filter { !it.hasValue() || it.getOrNull()?.asVoiceBroadcastEvent()?.voiceBroadcastId == voiceBroadcast.voiceBroadcastId } - .mapOptional { it.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 index e142cb15ce..c743d8a542 100644 --- 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 @@ -37,9 +37,9 @@ class VoiceBroadcastMetadataView @JvmOverloads constructor( ) var value: String - get() = views.metadataValue.text.toString() + get() = views.metadataText.text.toString() set(newValue) { - views.metadataValue.text = newValue + views.metadataText.text = newValue } init { @@ -61,6 +61,6 @@ class VoiceBroadcastMetadataView @JvmOverloads constructor( private fun setValue(typedArray: TypedArray) { val value = typedArray.getString(R.styleable.VoiceBroadcastMetadataView_metadataValue) - views.metadataValue.text = value + views.metadataText.text = value } } diff --git a/vector/src/main/res/drawable-v24/ic_composer_rich_mic_pressed.xml b/vector/src/main/res/drawable-v24/ic_composer_rich_mic_pressed.xml new file mode 100644 index 0000000000..4f64df7a18 --- /dev/null +++ b/vector/src/main/res/drawable-v24/ic_composer_rich_mic_pressed.xml @@ -0,0 +1,17 @@ + + diff --git a/vector/src/main/res/drawable-v24/ic_composer_rich_text_save.xml b/vector/src/main/res/drawable-v24/ic_composer_rich_text_save.xml new file mode 100644 index 0000000000..a59e22a2be --- /dev/null +++ b/vector/src/main/res/drawable-v24/ic_composer_rich_text_save.xml @@ -0,0 +1,16 @@ ++ + + + diff --git a/vector/src/main/res/drawable-v24/ic_rich_composer_send.xml b/vector/src/main/res/drawable-v24/ic_rich_composer_send.xml new file mode 100644 index 0000000000..c3a1aeea1c --- /dev/null +++ b/vector/src/main/res/drawable-v24/ic_rich_composer_send.xml @@ -0,0 +1,12 @@ ++ + + diff --git a/vector/src/main/res/drawable-v24/ic_voice_mic_recording.xml b/vector/src/main/res/drawable-v24/ic_voice_mic_recording.xml deleted file mode 100644 index 09e2810549..0000000000 --- a/vector/src/main/res/drawable-v24/ic_voice_mic_recording.xml +++ /dev/null @@ -1,18 +0,0 @@ -+ + - 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 index 394dc52279..de1862c09b 100644 --- a/vector/src/main/res/drawable/ic_composer_full_screen.xml +++ b/vector/src/main/res/drawable/ic_composer_full_screen.xml @@ -1,9 +1,9 @@+ - 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..ffdfd38471 --- /dev/null +++ b/vector/src/main/res/drawable/ic_composer_rich_mic_pressed.xml @@ -0,0 +1,17 @@ ++ android:width="20dp" + android:height="20dp" + android:viewportWidth="20" + android:viewportHeight="20"> + + 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..6daa104dc8 --- /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..642824fd7f --- /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..50dba90143 --- /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_rich_composer_add.xml b/vector/src/main/res/drawable/ic_rich_composer_add.xml new file mode 100644 index 0000000000..64a92d05be --- /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..96ad6610d2 --- /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_voice_mic_recording.xml b/vector/src/main/res/drawable/ic_voice_mic_recording.xml deleted file mode 100644 index 50fff1d168..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/composer_layout.xml b/vector/src/main/res/layout/composer_layout.xml index a1d86559a7..8ad1298503 100644 --- a/vector/src/main/res/layout/composer_layout.xml +++ b/vector/src/main/res/layout/composer_layout.xml @@ -1,157 +1,210 @@ -- + android:orientation="vertical"> - - +- - - - - - - - - - + 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 08b0c90c86..0000000000 --- a/vector/src/main/res/layout/composer_layout_constraint_set_compact.xml +++ /dev/null @@ -1,208 +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 6870f44a78..0000000000 --- a/vector/src/main/res/layout/composer_layout_constraint_set_expanded.xml +++ /dev/null @@ -1,204 +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 c5afe1eb44..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,183 +1,202 @@ -- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +- - - - - - - - - - - - - - - - - - + android:orientation="vertical" + android:background="@drawable/bg_composer_rich_bottom_sheet"> - - + app:layout_constraintTop_toTopOf="parent" + app:layout_constraintStart_toStartOf="parent" + app:layout_constraintEnd_toEndOf="parent"> - - + - + - + - + +- + ++ + + + + android:layout_marginStart="6dp" + android:layout_marginTop="8dp" + android:paddingBottom="2dp" + android:fontFamily="sans-serif-medium" + tools:text="Editing" + style="@style/BottomSheetItemTime" + app:layout_constraintTop_toTopOf="@id/composerEditTextOuterBorder" + app:layout_constraintStart_toEndOf="@id/composerModeIconView" /> - - - + - + + + + + + + + + + + + + + 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 b2c0144bc4..0000000000 --- a/vector/src/main/res/layout/composer_rich_text_layout_constraint_set_compact.xml +++ /dev/null @@ -1,233 +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 d74ccbfff9..0000000000 --- a/vector/src/main/res/layout/composer_rich_text_layout_constraint_set_expanded.xml +++ /dev/null @@ -1,230 +0,0 @@ - -- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -- - - -- - diff --git a/vector/src/main/res/layout/composer_rich_text_layout_constraint_set_fullscreen.xml b/vector/src/main/res/layout/composer_rich_text_layout_constraint_set_fullscreen.xml deleted file mode 100644 index fa0a895a89..0000000000 --- a/vector/src/main/res/layout/composer_rich_text_layout_constraint_set_fullscreen.xml +++ /dev/null @@ -1,234 +0,0 @@ - -- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -- - - -- - diff --git a/vector/src/main/res/layout/fragment_composer.xml b/vector/src/main/res/layout/fragment_composer.xml index eec59c0382..62432516b5 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="match_parent"> + android:layout_height="match_parent" + android:background="@android:color/transparent">- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -- - - -- - - - +- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + android:layout_width="match_parent" + android:layout_height="match_parent" + android:id="@+id/rootConstraintLayout"> - -+ app:layout_constraintTop_toTopOf="parent" /> + + + + + + + app:layout_constraintTop_toBottomOf="@id/removeJitsiWidgetView" + tools:listitem="@layout/item_timeline_event_base" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/vector/src/main/res/layout/fragment_timeline_fullscreen.xml b/vector/src/main/res/layout/fragment_timeline_fullscreen.xml deleted file mode 100644 index 373ca74f56..0000000000 --- a/vector/src/main/res/layout/fragment_timeline_fullscreen.xml +++ /dev/null @@ -1,258 +0,0 @@ - - - - - - diff --git a/vector/src/main/res/layout/item_other_session.xml b/vector/src/main/res/layout/item_other_session.xml index f514cea56b..a6205e7d50 100644 --- a/vector/src/main/res/layout/item_other_session.xml +++ b/vector/src/main/res/layout/item_other_session.xml @@ -13,7 +13,7 @@ android:layout_width="0dp" android:layout_height="0dp" android:background="@drawable/bg_other_session" - app:layout_constraintBottom_toBottomOf="@id/otherSessionVerificationStatusImageView" + app:layout_constraintBottom_toBottomOf="@id/otherSessionSeparator" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toTopOf="parent" /> @@ -53,11 +53,12 @@ android:layout_height="wrap_content" android:layout_marginStart="16dp" android:layout_marginEnd="8dp" + android:layout_marginTop="8dp" android:ellipsize="end" android:lines="1" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintStart_toEndOf="@id/otherSessionDeviceTypeImageView" - app:layout_constraintTop_toTopOf="@id/otherSessionDeviceTypeImageView" + app:layout_constraintTop_toTopOf="@id/otherSessionItemBackground" tools:text="Element Mobile: Android" />- - - - - -- - - - - -- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -- - - - + + + app:layout_constraintTop_toBottomOf="@id/otherSessionIpAddressTextView" /> diff --git a/vector/src/main/res/layout/item_timeline_event_audio_stub.xml b/vector/src/main/res/layout/item_timeline_event_audio_stub.xml index c04ad2d27a..7389b9e47d 100644 --- a/vector/src/main/res/layout/item_timeline_event_audio_stub.xml +++ b/vector/src/main/res/layout/item_timeline_event_audio_stub.xml @@ -81,7 +81,7 @@ android:layout_marginTop="12dp" android:layout_marginBottom="10dp" android:progressDrawable="@drawable/bg_seek_bar" - android:thumbTint="?vctr_content_tertiary" + android:thumbTint="?vctr_content_secondary" app:layout_constraintStart_toStartOf="parent" app:layout_constraintEnd_toStartOf="@id/audioPlaybackTime" app:layout_constraintTop_toBottomOf="@id/audioPlaybackControlButton" @@ -93,7 +93,7 @@ style="@style/Widget.Vector.TextView.Caption" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:textColor="?vctr_content_tertiary" + android:textColor="?vctr_content_secondary" android:layout_marginEnd="4dp" app:layout_constraintEnd_toEndOf="parent" app:layout_constraintTop_toTopOf="@id/audioSeekBar" diff --git a/vector/src/main/res/layout/item_timeline_event_voice_broadcast_listening_stub.xml b/vector/src/main/res/layout/item_timeline_event_voice_broadcast_listening_stub.xml index 1d31afba99..3c59d49418 100644 --- a/vector/src/main/res/layout/item_timeline_event_voice_broadcast_listening_stub.xml +++ b/vector/src/main/res/layout/item_timeline_event_voice_broadcast_listening_stub.xml @@ -7,7 +7,9 @@ android:layout_height="wrap_content" android:background="@drawable/rounded_rect_shape_8" android:backgroundTint="?vctr_content_quinary" - android:padding="@dimen/layout_vertical_margin"> + android:clipChildren="false" + android:clipToPadding="false" + android:padding="12dp"> + + @@ -117,16 +124,6 @@ android:src="@drawable/ic_play_pause_play" app:tint="?vctr_content_secondary" /> - - + tools:progress="0" /> + android:padding="12dp"> + + + diff --git a/vector/src/main/res/layout/view_voice_broadcast_metadata.xml b/vector/src/main/res/layout/view_voice_broadcast_metadata.xml index 3bc31cd9a0..70de3e330e 100644 --- a/vector/src/main/res/layout/view_voice_broadcast_metadata.xml +++ b/vector/src/main/res/layout/view_voice_broadcast_metadata.xml @@ -18,7 +18,7 @@ tools:src="@drawable/ic_voice_broadcast" />+ + + + + @@ -109,7 +118,7 @@ android:id="@+id/voiceMessageLockImage" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:layout_marginTop="28dp" + android:layout_marginTop="16dp" android:importantForAccessibility="no" android:src="@drawable/ic_voice_message_unlocked" android:visibility="gone" @@ -123,7 +132,6 @@ android:id="@+id/voiceMessageLockArrow" android:layout_width="wrap_content" android:layout_height="wrap_content" - android:layout_marginStart="4dp" android:layout_marginBottom="14dp" android:importantForAccessibility="no" android:src="@drawable/ic_voice_lock_arrow" diff --git a/vector/src/main/res/menu/menu_other_sessions.xml b/vector/src/main/res/menu/menu_other_sessions.xml index 7893575dde..98f9dd8256 100644 --- a/vector/src/main/res/menu/menu_other_sessions.xml +++ b/vector/src/main/res/menu/menu_other_sessions.xml @@ -9,6 +9,11 @@ android:title="@string/device_manager_other_sessions_select" app:showAsAction="withText|never" /> + - +
- +
- +
- +
- +
- +
+ val event = givenAnEvent( diff --git a/vector/src/test/java/im/vector/app/features/home/room/detail/timeline/action/CheckIfCanReplyEventUseCaseTest.kt b/vector/src/test/java/im/vector/app/features/home/room/detail/timeline/action/CheckIfCanReplyEventUseCaseTest.kt index 83d23681fc..51082e0e06 100644 --- a/vector/src/test/java/im/vector/app/features/home/room/detail/timeline/action/CheckIfCanReplyEventUseCaseTest.kt +++ b/vector/src/test/java/im/vector/app/features/home/room/detail/timeline/action/CheckIfCanReplyEventUseCaseTest.kt @@ -43,7 +43,7 @@ class CheckIfCanReplyEventUseCaseTest { @Test fun `given reply is allowed for the event type when use case is executed then result is true`() { - val eventTypes = EventType.STATE_ROOM_BEACON_INFO + EventType.POLL_START + EventType.MESSAGE + val eventTypes = EventType.STATE_ROOM_BEACON_INFO.values + EventType.POLL_START.values + EventType.MESSAGE eventTypes.forEach { eventType -> val event = givenAnEvent(eventType) diff --git a/vector/src/test/java/im/vector/app/features/home/room/detail/timeline/render/ProcessBodyOfReplyToEventUseCaseTest.kt b/vector/src/test/java/im/vector/app/features/home/room/detail/timeline/render/ProcessBodyOfReplyToEventUseCaseTest.kt new file mode 100644 index 0000000000..f612861511 --- /dev/null +++ b/vector/src/test/java/im/vector/app/features/home/room/detail/timeline/render/ProcessBodyOfReplyToEventUseCaseTest.kt @@ -0,0 +1,268 @@ +/* + * 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 android.annotation.StringRes +import im.vector.app.R +import im.vector.app.test.fakes.FakeActiveSessionHolder +import im.vector.app.test.fakes.FakeStringProvider +import io.mockk.every +import io.mockk.mockk +import io.mockk.mockkStatic +import io.mockk.unmockkAll +import org.amshove.kluent.shouldBeEqualTo +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.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.room.model.relation.ReplyToContent +import org.matrix.android.sdk.api.session.room.timeline.TimelineEvent + +private const val A_ROOM_ID = "room-id" +private const val AN_EVENT_ID = "event-id" +private const val A_REPLY_TO_EVENT_MATRIX_FORMATTED_BODY = + " " + + " " + + "Reply text" +private const val A_NEW_PREFIX = "new-prefix" +private const val A_NEW_CONTENT = "new-content" +private const val PREFIX_PROCESSED_ONLY_REPLY_TO_EVENT_MATRIX_FORMATTED_BODY = + "" + + "In reply to " + + "@user:matrix.org" + + "" + + "
" + + "Message content" + + "" + + " " + + "Reply text" +private const val FULLY_PROCESSED_REPLY_TO_EVENT_MATRIX_FORMATTED_BODY = + "" + + "$A_NEW_PREFIX " + + "@user:matrix.org" + + "" + + "
" + + "Message content" + + "" + + " " + + "Reply text" + +class ProcessBodyOfReplyToEventUseCaseTest { + + private val fakeActiveSessionHolder = FakeActiveSessionHolder() + private val fakeStringProvider = FakeStringProvider() + private val fakeReplyToContent = ReplyToContent(eventId = AN_EVENT_ID) + private val fakeRepliedEvent = givenARepliedEvent() + + private val processBodyOfReplyToEventUseCase = ProcessBodyOfReplyToEventUseCase( + activeSessionHolder = fakeActiveSessionHolder.instance, + stringProvider = fakeStringProvider.instance, + ) + + @Before + fun setup() { + givenNewPrefix() + mockkStatic("org.matrix.android.sdk.api.session.events.model.EventKt") + } + + @After + fun tearDown() { + unmockkAll() + } + + @Test + fun `given a replied event of type file message when process the formatted body then content is replaced by correct string`() { + // Given + givenTypeOfRepliedEvent(isFileMessage = true) + givenNewContentForId(R.string.message_reply_to_sender_sent_file) + + executeAndAssertResult() + } + + @Test + fun `given a replied event of type voice message when process the formatted body then content is replaced by correct string`() { + // Given + givenTypeOfRepliedEvent(isVoiceMessage = true) + givenNewContentForId(R.string.message_reply_to_sender_sent_voice_message) + + executeAndAssertResult() + } + + @Test + fun `given a replied event of type audio message when process the formatted body then content is replaced by correct string`() { + // Given + givenTypeOfRepliedEvent(isAudioMessage = true) + givenNewContentForId(R.string.message_reply_to_sender_sent_audio_file) + + executeAndAssertResult() + } + + @Test + fun `given a replied event of type image message when process the formatted body then content is replaced by correct string`() { + // Given + givenTypeOfRepliedEvent(isImageMessage = true) + givenNewContentForId(R.string.message_reply_to_sender_sent_image) + + executeAndAssertResult() + } + + @Test + fun `given a replied event of type video message when process the formatted body then content is replaced by correct string`() { + // Given + givenTypeOfRepliedEvent(isVideoMessage = true) + givenNewContentForId(R.string.message_reply_to_sender_sent_video) + + executeAndAssertResult() + } + + @Test + fun `given a replied event of type sticker message when process the formatted body then content is replaced by correct string`() { + // Given + givenTypeOfRepliedEvent(isStickerMessage = true) + givenNewContentForId(R.string.message_reply_to_sender_sent_sticker) + + executeAndAssertResult() + } + + @Test + fun `given a replied event of type poll message with null question when process the formatted body then content is replaced by correct string`() { + // Given + givenTypeOfRepliedEvent(isPollMessage = true) + givenNewContentForId(R.string.message_reply_to_sender_created_poll) + every { fakeRepliedEvent.getPollQuestion() } returns null + + executeAndAssertResult() + } + + @Test + fun `given a replied event of type poll message with existing question when process the formatted body then content is replaced by correct string`() { + // Given + givenTypeOfRepliedEvent(isPollMessage = true) + givenNewContentForId(R.string.message_reply_to_sender_created_poll) + every { fakeRepliedEvent.getPollQuestion() } returns A_NEW_CONTENT + + executeAndAssertResult() + } + + @Test + fun `given a replied event of type live location message when process the formatted body then content is replaced by correct string`() { + // Given + givenTypeOfRepliedEvent(isLiveLocationMessage = true) + givenNewContentForId(R.string.live_location_description) + + executeAndAssertResult() + } + + @Test + fun `given a replied event of type not handled when process the formatted body only prefix is replaced by correct string`() { + // Given + givenTypeOfRepliedEvent() + + // When + val result = processBodyOfReplyToEventUseCase.execute( + roomId = A_ROOM_ID, + matrixFormattedBody = A_REPLY_TO_EVENT_MATRIX_FORMATTED_BODY, + replyToContent = fakeReplyToContent, + ) + + // Then + result shouldBeEqualTo PREFIX_PROCESSED_ONLY_REPLY_TO_EVENT_MATRIX_FORMATTED_BODY + } + + @Test + fun `given no replied event found when process the formatted body then only prefix is replaced by correct string`() { + // Given + givenARepliedEvent(timelineEvent = null) + + // When + val result = processBodyOfReplyToEventUseCase.execute( + roomId = A_ROOM_ID, + matrixFormattedBody = A_REPLY_TO_EVENT_MATRIX_FORMATTED_BODY, + replyToContent = fakeReplyToContent, + ) + + // Then + result shouldBeEqualTo PREFIX_PROCESSED_ONLY_REPLY_TO_EVENT_MATRIX_FORMATTED_BODY + } + + private fun executeAndAssertResult() { + // When + val result = processBodyOfReplyToEventUseCase.execute( + roomId = A_ROOM_ID, + matrixFormattedBody = A_REPLY_TO_EVENT_MATRIX_FORMATTED_BODY, + replyToContent = fakeReplyToContent, + ) + + // Then + result shouldBeEqualTo FULLY_PROCESSED_REPLY_TO_EVENT_MATRIX_FORMATTED_BODY + } + + private fun givenARepliedEvent(timelineEvent: TimelineEvent? = mockk()): Event { + val event = mockk" + + "$A_NEW_PREFIX " + + "@user:matrix.org" + + "" + + "
" + + A_NEW_CONTENT + + "() + timelineEvent?.let { every { it.root } returns event } + fakeActiveSessionHolder + .fakeSession + .roomService() + .getRoom(A_ROOM_ID) + .timelineService() + .givenTimelineEvent(timelineEvent) + return event + } + + private fun givenTypeOfRepliedEvent( + isFileMessage: Boolean = false, + isVoiceMessage: Boolean = false, + isAudioMessage: Boolean = false, + isImageMessage: Boolean = false, + isVideoMessage: Boolean = false, + isStickerMessage: Boolean = false, + isPollMessage: Boolean = false, + isLiveLocationMessage: Boolean = false, + ) { + every { fakeRepliedEvent.isFileMessage() } returns isFileMessage + every { fakeRepliedEvent.isVoiceMessage() } returns isVoiceMessage + every { fakeRepliedEvent.isAudioMessage() } returns isAudioMessage + every { fakeRepliedEvent.isImageMessage() } returns isImageMessage + every { fakeRepliedEvent.isVideoMessage() } returns isVideoMessage + every { fakeRepliedEvent.isSticker() } returns isStickerMessage + every { fakeRepliedEvent.isPoll() } returns isPollMessage + every { fakeRepliedEvent.isLiveLocation() } returns isLiveLocationMessage + } + + private fun givenNewPrefix() { + fakeStringProvider.given(R.string.message_reply_to_prefix, A_NEW_PREFIX) + } + + private fun givenNewContentForId(@StringRes resId: Int) { + fakeStringProvider.given(resId, A_NEW_CONTENT) + } +} diff --git a/vector/src/test/java/im/vector/app/features/notifications/NotifiableEventProcessorTest.kt b/vector/src/test/java/im/vector/app/features/notifications/NotifiableEventProcessorTest.kt index 131a423316..59e42a9568 100644 --- a/vector/src/test/java/im/vector/app/features/notifications/NotifiableEventProcessorTest.kt +++ b/vector/src/test/java/im/vector/app/features/notifications/NotifiableEventProcessorTest.kt @@ -27,6 +27,7 @@ import org.junit.Test import org.matrix.android.sdk.api.session.events.model.EventType private val NOT_VIEWING_A_ROOM: String? = null +private val NOT_VIEWING_A_THREAD: String? = null class NotifiableEventProcessorTest { @@ -42,7 +43,7 @@ class NotifiableEventProcessorTest { aSimpleNotifiableEvent(eventId = "event-2") ) - val result = eventProcessor.process(events, currentRoomId = NOT_VIEWING_A_ROOM, renderedEvents = emptyList()) + val result = eventProcessor.process(events, currentRoomId = NOT_VIEWING_A_ROOM, currentThreadId = NOT_VIEWING_A_THREAD, renderedEvents = emptyList()) result shouldBeEqualTo listOfProcessedEvents( Type.KEEP to events[0], @@ -54,7 +55,7 @@ class NotifiableEventProcessorTest { fun `given redacted simple event when processing then remove redaction event`() { val events = listOf(aSimpleNotifiableEvent(eventId = "event-1", type = EventType.REDACTION)) - val result = eventProcessor.process(events, currentRoomId = NOT_VIEWING_A_ROOM, renderedEvents = emptyList()) + val result = eventProcessor.process(events, currentRoomId = NOT_VIEWING_A_ROOM, currentThreadId = NOT_VIEWING_A_THREAD, renderedEvents = emptyList()) result shouldBeEqualTo listOfProcessedEvents( Type.REMOVE to events[0] @@ -69,7 +70,7 @@ class NotifiableEventProcessorTest { anInviteNotifiableEvent(roomId = "room-2") ) - val result = eventProcessor.process(events, currentRoomId = NOT_VIEWING_A_ROOM, renderedEvents = emptyList()) + val result = eventProcessor.process(events, currentRoomId = NOT_VIEWING_A_ROOM, currentThreadId = NOT_VIEWING_A_THREAD, renderedEvents = emptyList()) result shouldBeEqualTo listOfProcessedEvents( Type.REMOVE to events[0], @@ -85,7 +86,7 @@ class NotifiableEventProcessorTest { anInviteNotifiableEvent(roomId = "room-2") ) - val result = eventProcessor.process(events, currentRoomId = NOT_VIEWING_A_ROOM, renderedEvents = emptyList()) + val result = eventProcessor.process(events, currentRoomId = NOT_VIEWING_A_ROOM, currentThreadId = NOT_VIEWING_A_THREAD, renderedEvents = emptyList()) result shouldBeEqualTo listOfProcessedEvents( Type.KEEP to events[0], @@ -98,7 +99,7 @@ class NotifiableEventProcessorTest { val events = listOf(aNotifiableMessageEvent(eventId = "event-1", roomId = "room-1")) outdatedDetector.givenEventIsOutOfDate(events[0]) - val result = eventProcessor.process(events, currentRoomId = NOT_VIEWING_A_ROOM, renderedEvents = emptyList()) + val result = eventProcessor.process(events, currentRoomId = NOT_VIEWING_A_ROOM, currentThreadId = NOT_VIEWING_A_THREAD, renderedEvents = emptyList()) result shouldBeEqualTo listOfProcessedEvents( Type.REMOVE to events[0], @@ -110,7 +111,7 @@ class NotifiableEventProcessorTest { val events = listOf(aNotifiableMessageEvent(eventId = "event-1", roomId = "room-1")) outdatedDetector.givenEventIsInDate(events[0]) - val result = eventProcessor.process(events, currentRoomId = NOT_VIEWING_A_ROOM, renderedEvents = emptyList()) + val result = eventProcessor.process(events, currentRoomId = NOT_VIEWING_A_ROOM, currentThreadId = NOT_VIEWING_A_THREAD, renderedEvents = emptyList()) result shouldBeEqualTo listOfProcessedEvents( Type.KEEP to events[0], @@ -118,16 +119,51 @@ class NotifiableEventProcessorTest { } @Test - fun `given viewing the same room as message event when processing then removes message`() { - val events = listOf(aNotifiableMessageEvent(eventId = "event-1", roomId = "room-1")) + fun `given viewing the same room main timeline when processing main timeline message event then removes message`() { + val events = listOf(aNotifiableMessageEvent(eventId = "event-1", roomId = "room-1", threadId = null)) - val result = eventProcessor.process(events, currentRoomId = "room-1", renderedEvents = emptyList()) + val result = eventProcessor.process(events, currentRoomId = "room-1", currentThreadId = null, renderedEvents = emptyList()) result shouldBeEqualTo listOfProcessedEvents( Type.REMOVE to events[0], ) } + @Test + fun `given viewing the same thread timeline when processing thread message event then removes message`() { + val events = listOf(aNotifiableMessageEvent(eventId = "event-1", roomId = "room-1", threadId = "thread-1")) + + val result = eventProcessor.process(events, currentRoomId = "room-1", currentThreadId = "thread-1", renderedEvents = emptyList()) + + result shouldBeEqualTo listOfProcessedEvents( + Type.REMOVE to events[0], + ) + } + + @Test + fun `given viewing main timeline of the same room when processing thread timeline message event then keep message`() { + val events = listOf(aNotifiableMessageEvent(eventId = "event-1", roomId = "room-1", threadId = "thread-1")) + outdatedDetector.givenEventIsInDate(events[0]) + + val result = eventProcessor.process(events, currentRoomId = "room-1", currentThreadId = null, renderedEvents = emptyList()) + + result shouldBeEqualTo listOfProcessedEvents( + Type.KEEP to events[0], + ) + } + + @Test + fun `given viewing thread timeline of the same room when processing main timeline message event then keep message`() { + val events = listOf(aNotifiableMessageEvent(eventId = "event-1", roomId = "room-1")) + outdatedDetector.givenEventIsInDate(events[0]) + + val result = eventProcessor.process(events, currentRoomId = "room-1", currentThreadId = "thread-1", renderedEvents = emptyList()) + + result shouldBeEqualTo listOfProcessedEvents( + Type.KEEP to events[0], + ) + } + @Test fun `given events are different to rendered events when processing then removes difference`() { val events = listOf(aSimpleNotifiableEvent(eventId = "event-1")) @@ -136,7 +172,7 @@ class NotifiableEventProcessorTest { ProcessedEvent(Type.KEEP, anInviteNotifiableEvent(roomId = "event-2")) ) - val result = eventProcessor.process(events, currentRoomId = NOT_VIEWING_A_ROOM, renderedEvents = renderedEvents) + val result = eventProcessor.process(events, currentRoomId = NOT_VIEWING_A_ROOM, currentThreadId = NOT_VIEWING_A_THREAD, renderedEvents = renderedEvents) result shouldBeEqualTo listOfProcessedEvents( Type.REMOVE to renderedEvents[1].event, diff --git a/vector/src/test/java/im/vector/app/features/settings/devices/v2/DevicesViewModelTest.kt b/vector/src/test/java/im/vector/app/features/settings/devices/v2/DevicesViewModelTest.kt index 65da1a9385..03177aac47 100644 --- a/vector/src/test/java/im/vector/app/features/settings/devices/v2/DevicesViewModelTest.kt +++ b/vector/src/test/java/im/vector/app/features/settings/devices/v2/DevicesViewModelTest.kt @@ -29,15 +29,18 @@ import im.vector.app.features.settings.devices.v2.verification.GetCurrentSession import im.vector.app.test.fakes.FakeActiveSessionHolder import im.vector.app.test.fakes.FakePendingAuthHandler import im.vector.app.test.fakes.FakeSignoutSessionsUseCase +import im.vector.app.test.fakes.FakeVectorPreferences import im.vector.app.test.fakes.FakeVerificationService import im.vector.app.test.test import im.vector.app.test.testDispatcher import io.mockk.coEvery import io.mockk.coVerify import io.mockk.every +import io.mockk.just import io.mockk.justRun import io.mockk.mockk import io.mockk.mockkStatic +import io.mockk.runs import io.mockk.unmockkAll import io.mockk.verify import io.mockk.verifyAll @@ -72,6 +75,8 @@ class DevicesViewModelTest { private val fakeInterceptSignoutFlowResponseUseCase = mockk () private val fakePendingAuthHandler = FakePendingAuthHandler() private val fakeRefreshDevicesUseCase = mockk (relaxUnitFun = true) + private val fakeVectorPreferences = FakeVectorPreferences() + private val toggleIpAddressVisibilityUseCase = mockk () private fun createViewModel(): DevicesViewModel { return DevicesViewModel( @@ -85,6 +90,8 @@ class DevicesViewModelTest { interceptSignoutFlowResponseUseCase = fakeInterceptSignoutFlowResponseUseCase, pendingAuthHandler = fakePendingAuthHandler.instance, refreshDevicesUseCase = fakeRefreshDevicesUseCase, + vectorPreferences = fakeVectorPreferences.instance, + toggleIpAddressVisibilityUseCase = toggleIpAddressVisibilityUseCase, ) } @@ -97,6 +104,7 @@ class DevicesViewModelTest { givenVerificationService() givenCurrentSessionCrossSigningInfo() givenDeviceFullInfoList(deviceId1 = A_DEVICE_ID_1, deviceId2 = A_DEVICE_ID_2) + fakeVectorPreferences.givenSessionManagerShowIpAddress(false) } private fun givenVerificationService(): FakeVerificationService { @@ -343,6 +351,33 @@ class DevicesViewModelTest { } } + @Test + fun `given the viewModel when initializing it then view state of ip address visibility is false`() { + // When + val viewModelTest = createViewModel().test() + + // Then + viewModelTest.assertLatestState { it.isShowingIpAddress == false } + viewModelTest.finish() + } + + @Test + fun `given the viewModel when toggleIpAddressVisibility action is triggered then view state and preference change accordingly`() { + // When + val viewModel = createViewModel() + val viewModelTest = viewModel.test() + every { toggleIpAddressVisibilityUseCase.execute() } just runs + every { fakeVectorPreferences.instance.setIpAddressVisibilityInDeviceManagerScreens(true) } just runs + every { fakeVectorPreferences.instance.showIpAddressInSessionManagerScreens() } returns true + + viewModel.handle(DevicesAction.ToggleIpAddressVisibility) + viewModel.onSharedPreferenceChanged(null, null) + + // Then + viewModelTest.assertLatestState { it.isShowingIpAddress == true } + viewModelTest.finish() + } + private fun givenCurrentSessionCrossSigningInfo(): CurrentSessionCrossSigningInfo { val currentSessionCrossSigningInfo = mockk () every { currentSessionCrossSigningInfo.deviceId } returns A_CURRENT_DEVICE_ID diff --git a/vector/src/test/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsViewModelTest.kt b/vector/src/test/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsViewModelTest.kt index 1e8c511c42..82f40d911d 100644 --- a/vector/src/test/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsViewModelTest.kt +++ b/vector/src/test/java/im/vector/app/features/settings/devices/v2/othersessions/OtherSessionsViewModelTest.kt @@ -22,10 +22,12 @@ import com.airbnb.mvrx.test.MavericksTestRule import im.vector.app.features.settings.devices.v2.DeviceFullInfo 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.filter.DeviceManagerFilterType import im.vector.app.test.fakes.FakeActiveSessionHolder import im.vector.app.test.fakes.FakePendingAuthHandler import im.vector.app.test.fakes.FakeSignoutSessionsUseCase +import im.vector.app.test.fakes.FakeVectorPreferences import im.vector.app.test.fakes.FakeVerificationService import im.vector.app.test.fixtures.aDeviceFullInfo import im.vector.app.test.test @@ -66,6 +68,8 @@ class OtherSessionsViewModelTest { private val fakeRefreshDevicesUseCase = mockk (relaxed = true) private val fakeSignoutSessionsUseCase = FakeSignoutSessionsUseCase() private val fakePendingAuthHandler = FakePendingAuthHandler() + private val fakeVectorPreferences = FakeVectorPreferences() + private val toggleIpAddressVisibilityUseCase = mockk () private fun createViewModel(viewState: OtherSessionsViewState = OtherSessionsViewState(defaultArgs)) = OtherSessionsViewModel( @@ -75,6 +79,8 @@ class OtherSessionsViewModelTest { signoutSessionsUseCase = fakeSignoutSessionsUseCase.instance, pendingAuthHandler = fakePendingAuthHandler.instance, refreshDevicesUseCase = fakeRefreshDevicesUseCase, + vectorPreferences = fakeVectorPreferences.instance, + toggleIpAddressVisibilityUseCase = toggleIpAddressVisibilityUseCase, ) @Before @@ -84,6 +90,7 @@ class OtherSessionsViewModelTest { every { SystemClock.elapsedRealtime() } returns 1234 givenVerificationService() + fakeVectorPreferences.givenSessionManagerShowIpAddress(false) } private fun givenVerificationService(): FakeVerificationService { diff --git a/vector/src/test/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewViewModelTest.kt b/vector/src/test/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewViewModelTest.kt index 1a57b76020..287bdd159c 100644 --- a/vector/src/test/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewViewModelTest.kt +++ b/vector/src/test/java/im/vector/app/features/settings/devices/v2/overview/SessionOverviewViewModelTest.kt @@ -22,6 +22,7 @@ import com.airbnb.mvrx.Success import com.airbnb.mvrx.test.MavericksTestRule import im.vector.app.features.settings.devices.v2.DeviceFullInfo 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.notification.NotificationsStatus import im.vector.app.features.settings.devices.v2.signout.InterceptSignoutFlowResponseUseCase import im.vector.app.features.settings.devices.v2.verification.CheckIfCurrentSessionCanBeVerifiedUseCase @@ -30,6 +31,7 @@ import im.vector.app.test.fakes.FakeGetNotificationsStatusUseCase import im.vector.app.test.fakes.FakePendingAuthHandler import im.vector.app.test.fakes.FakeSignoutSessionsUseCase import im.vector.app.test.fakes.FakeTogglePushNotificationUseCase +import im.vector.app.test.fakes.FakeVectorPreferences import im.vector.app.test.fakes.FakeVerificationService import im.vector.app.test.test import im.vector.app.test.testDispatcher @@ -77,6 +79,8 @@ class SessionOverviewViewModelTest { private val togglePushNotificationUseCase = FakeTogglePushNotificationUseCase() private val fakeGetNotificationsStatusUseCase = FakeGetNotificationsStatusUseCase() private val notificationsStatus = NotificationsStatus.ENABLED + private val fakeVectorPreferences = FakeVectorPreferences() + private val toggleIpAddressVisibilityUseCase = mockk () private fun createViewModel() = SessionOverviewViewModel( initialState = SessionOverviewViewState(args), @@ -89,6 +93,8 @@ class SessionOverviewViewModelTest { refreshDevicesUseCase = refreshDevicesUseCase, togglePushNotificationUseCase = togglePushNotificationUseCase.instance, getNotificationsStatusUseCase = fakeGetNotificationsStatusUseCase.instance, + vectorPreferences = fakeVectorPreferences.instance, + toggleIpAddressVisibilityUseCase = toggleIpAddressVisibilityUseCase, ) @Before @@ -103,6 +109,7 @@ class SessionOverviewViewModelTest { A_SESSION_ID_1, notificationsStatus ) + fakeVectorPreferences.givenSessionManagerShowIpAddress(false) } private fun givenVerificationService(): FakeVerificationService { diff --git a/vector/src/test/java/im/vector/app/features/voicebroadcast/usecase/ResumeVoiceBroadcastUseCaseTest.kt b/vector/src/test/java/im/vector/app/features/voicebroadcast/usecase/ResumeVoiceBroadcastUseCaseTest.kt index 8b66d45dd4..7fe74052a9 100644 --- a/vector/src/test/java/im/vector/app/features/voicebroadcast/usecase/ResumeVoiceBroadcastUseCaseTest.kt +++ b/vector/src/test/java/im/vector/app/features/voicebroadcast/usecase/ResumeVoiceBroadcastUseCaseTest.kt @@ -19,7 +19,6 @@ package im.vector.app.features.voicebroadcast.usecase import im.vector.app.features.voicebroadcast.VoiceBroadcastConstants import im.vector.app.features.voicebroadcast.model.MessageVoiceBroadcastInfoContent import im.vector.app.features.voicebroadcast.model.VoiceBroadcastState -import im.vector.app.features.voicebroadcast.recording.VoiceBroadcastRecorder import im.vector.app.features.voicebroadcast.recording.usecase.ResumeVoiceBroadcastUseCase import im.vector.app.test.fakes.FakeRoom import im.vector.app.test.fakes.FakeRoomService @@ -27,7 +26,6 @@ import im.vector.app.test.fakes.FakeSession import io.mockk.clearAllMocks import io.mockk.coEvery import io.mockk.coVerify -import io.mockk.mockk import io.mockk.slot import kotlinx.coroutines.test.runTest import org.amshove.kluent.shouldBe @@ -47,8 +45,7 @@ class ResumeVoiceBroadcastUseCaseTest { private val fakeRoom = FakeRoom() private val fakeSession = FakeSession(fakeRoomService = FakeRoomService(fakeRoom)) - private val fakeVoiceBroadcastRecorder = mockk (relaxed = true) - private val resumeVoiceBroadcastUseCase = ResumeVoiceBroadcastUseCase(fakeSession, fakeVoiceBroadcastRecorder) + private val resumeVoiceBroadcastUseCase = ResumeVoiceBroadcastUseCase(fakeSession) @Test fun `given a room id with a potential existing voice broadcast state when calling execute then the voice broadcast is resumed or not`() = runTest { diff --git a/vector/src/test/java/im/vector/app/test/fakes/FakeCreatePollViewStates.kt b/vector/src/test/java/im/vector/app/test/fakes/FakeCreatePollViewStates.kt index 04f3526602..42a500671b 100644 --- a/vector/src/test/java/im/vector/app/test/fakes/FakeCreatePollViewStates.kt +++ b/vector/src/test/java/im/vector/app/test/fakes/FakeCreatePollViewStates.kt @@ -63,7 +63,7 @@ object FakeCreatePollViewStates { ) private val A_POLL_START_EVENT = Event( - type = EventType.POLL_START.first(), + type = EventType.POLL_START.stable, eventId = A_FAKE_EVENT_ID, originServerTs = 1652435922563, senderId = A_FAKE_USER_ID, @@ -80,8 +80,8 @@ object FakeCreatePollViewStates { ) val initialCreatePollViewState = CreatePollViewState(createPollArgs).copy( - canCreatePoll = false, - canAddMoreOptions = true + canCreatePoll = false, + canAddMoreOptions = true ) val pollViewStateWithOnlyQuestion = initialCreatePollViewState.copy( diff --git a/vector/src/test/java/im/vector/app/test/fakes/FakeFilterService.kt b/vector/src/test/java/im/vector/app/test/fakes/FakeFilterService.kt index 4332368127..9be59d31fd 100644 --- a/vector/src/test/java/im/vector/app/test/fakes/FakeFilterService.kt +++ b/vector/src/test/java/im/vector/app/test/fakes/FakeFilterService.kt @@ -16,20 +16,21 @@ package im.vector.app.test.fakes -import io.mockk.every +import io.mockk.coEvery +import io.mockk.coVerify import io.mockk.just import io.mockk.mockk import io.mockk.runs -import io.mockk.verify import org.matrix.android.sdk.api.session.sync.FilterService +import org.matrix.android.sdk.api.session.sync.filter.SyncFilterBuilder class FakeFilterService : FilterService by mockk() { fun givenSetFilterSucceeds() { - every { setFilter(any()) } just runs + coEvery { setSyncFilter(any()) } just runs } - fun verifySetFilter(filterPreset: FilterService.FilterPreset) { - verify { setFilter(filterPreset) } + fun verifySetSyncFilter(filterBuilder: SyncFilterBuilder) { + coVerify { setSyncFilter(filterBuilder) } } } diff --git a/vector/src/test/java/im/vector/app/test/fakes/FakeTimelineService.kt b/vector/src/test/java/im/vector/app/test/fakes/FakeTimelineService.kt index 56f38724b1..a5fac5f1a1 100644 --- a/vector/src/test/java/im/vector/app/test/fakes/FakeTimelineService.kt +++ b/vector/src/test/java/im/vector/app/test/fakes/FakeTimelineService.kt @@ -23,7 +23,7 @@ import org.matrix.android.sdk.api.session.room.timeline.TimelineService class FakeTimelineService : TimelineService by mockk() { - fun givenTimelineEvent(event: TimelineEvent) { + fun givenTimelineEvent(event: TimelineEvent?) { every { getTimelineEvent(any()) } returns event } } diff --git a/vector/src/test/java/im/vector/app/test/fakes/FakeVectorPreferences.kt b/vector/src/test/java/im/vector/app/test/fakes/FakeVectorPreferences.kt index 4baa7e2b90..d89764a77e 100644 --- a/vector/src/test/java/im/vector/app/test/fakes/FakeVectorPreferences.kt +++ b/vector/src/test/java/im/vector/app/test/fakes/FakeVectorPreferences.kt @@ -52,4 +52,8 @@ class FakeVectorPreferences { fun verifySetNotificationEnabledForDevice(enabled: Boolean, inverse: Boolean = false) { verify(inverse = inverse) { instance.setNotificationEnabledForDevice(enabled) } } + + fun givenSessionManagerShowIpAddress(showIpAddress: Boolean) { + every { instance.showIpAddressInSessionManagerScreens() } returns showIpAddress + } } diff --git a/vector/src/test/java/im/vector/app/test/fixtures/BuildMetaFixture.kt b/vector/src/test/java/im/vector/app/test/fixtures/BuildMetaFixture.kt index 4f4649106d..e4dee0e474 100644 --- a/vector/src/test/java/im/vector/app/test/fixtures/BuildMetaFixture.kt +++ b/vector/src/test/java/im/vector/app/test/fixtures/BuildMetaFixture.kt @@ -26,7 +26,6 @@ fun aBuildMeta() = BuildMeta( gitRevision = "abcdef", gitRevisionDate = "01-01-01", gitBranchName = "a-branch-name", - buildNumber = "100", flavorDescription = "Gplay", flavorShortDescription = "", ) diff --git a/vector/src/test/java/im/vector/app/test/fixtures/NotifiableEventFixture.kt b/vector/src/test/java/im/vector/app/test/fixtures/NotifiableEventFixture.kt index 397ca80f84..a6d21a46c9 100644 --- a/vector/src/test/java/im/vector/app/test/fixtures/NotifiableEventFixture.kt +++ b/vector/src/test/java/im/vector/app/test/fixtures/NotifiableEventFixture.kt @@ -63,6 +63,7 @@ fun anInviteNotifiableEvent( fun aNotifiableMessageEvent( eventId: String = "a-message-event-id", roomId: String = "a-message-room-id", + threadId: String? = null, isRedacted: Boolean = false ) = NotifiableMessageEvent( eventId = eventId, @@ -73,6 +74,7 @@ fun aNotifiableMessageEvent( senderId = "sending-id", body = "message-body", roomId = roomId, + threadId = threadId, roomName = "room-name", roomIsDirect = false, canBeReplaced = false,